mirror of
https://github.com/System-End/theseus.git
synced 2026-04-19 16:38:18 +00:00
delegated mail endpoint
This commit is contained in:
parent
2e7764dd8e
commit
f3887dbd01
7 changed files with 236 additions and 6 deletions
3
Gemfile
3
Gemfile
|
|
@ -182,3 +182,6 @@ gem "paper_trail", "~> 16.0"
|
|||
gem "ttfunk", github: "24c02/ttfunk"
|
||||
|
||||
gem "hcbv4", "~> 0.2"
|
||||
|
||||
gem "jwt", "~> 2.9"
|
||||
gem "ed25519", "~> 1.3"
|
||||
|
|
|
|||
21
Gemfile.lock
21
Gemfile.lock
|
|
@ -195,9 +195,9 @@ GEM
|
|||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
|
|
@ -213,6 +213,7 @@ GEM
|
|||
ferrum_pdf (0.3.0)
|
||||
ferrum (~> 0.15)
|
||||
rails (>= 6.0.0)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
|
|
@ -279,7 +280,7 @@ GEM
|
|||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
jwt (3.1.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
kamal (2.6.1)
|
||||
activesupport (>= 7.0)
|
||||
|
|
@ -336,7 +337,10 @@ GEM
|
|||
matrix (0.4.2)
|
||||
memoizer (1.0.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.2)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_xml (0.7.2)
|
||||
bigdecimal (~> 3.1)
|
||||
|
|
@ -361,6 +365,9 @@ GEM
|
|||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
|
|
@ -438,7 +445,7 @@ GEM
|
|||
pdf-core (~> 0.10.0)
|
||||
ttfunk (~> 1.8)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
|
|
@ -671,7 +678,7 @@ GEM
|
|||
unaccent (0.4.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
usps_intelligent_barcode (1.0.0)
|
||||
|
|
@ -745,6 +752,7 @@ DEPENDENCIES
|
|||
debug
|
||||
dotenv-rails (~> 3.1)
|
||||
easypost (~> 7.1)
|
||||
ed25519 (~> 1.3)
|
||||
factory_bot_rails
|
||||
faraday (~> 2.13)
|
||||
ferrum_pdf (~> 0.3.0)
|
||||
|
|
@ -758,6 +766,7 @@ DEPENDENCIES
|
|||
jb (~> 0.8.2)
|
||||
jbuilder (~> 2.13)
|
||||
jquery-rails (~> 4.6)
|
||||
jwt (~> 2.9)
|
||||
kamal
|
||||
kaminari (~> 1.2)
|
||||
letter_opener_web (~> 3.0)
|
||||
|
|
|
|||
68
app/controllers/api/v1/delegated_controller.rb
Normal file
68
app/controllers/api/v1/delegated_controller.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module API
|
||||
module V1
|
||||
# Handles delegated actions from external services (e.g. Hackatime)
|
||||
# that present a short-lived JWT from HCA.
|
||||
#
|
||||
# Does NOT inherit from API::V1::ApplicationController (which uses APIKey auth).
|
||||
class DelegatedController < ActionController::API
|
||||
before_action :validate_jwt!
|
||||
|
||||
# POST /api/v1/delegated/send_mail
|
||||
#
|
||||
# Accepts a JWT with scope=theseus:send_mail and queues a mail send.
|
||||
# The caller never sees the recipient's address.
|
||||
#
|
||||
# Body params:
|
||||
# - item [String] description of what to send
|
||||
# - metadata [Hash] (optional) arbitrary metadata
|
||||
def send_mail
|
||||
identity_public_id = @jwt_payload["sub"]
|
||||
azp = @jwt_payload["azp"]
|
||||
jti = @jwt_payload["jti"]
|
||||
|
||||
# Fetch address from HCA (S2S, mTLS)
|
||||
address_data = HcaAddressFetcher.fetch(identity_public_id)
|
||||
unless address_data
|
||||
return render json: { error: "no_address", message: "User has no primary address on file" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
Rails.logger.info "[Delegated] send_mail: sub=#{identity_public_id} azp=#{azp} jti=#{jti} item=#{params[:item]}"
|
||||
|
||||
render json: {
|
||||
ok: true,
|
||||
message: "Mail send request accepted",
|
||||
delegated_by: azp,
|
||||
identity: identity_public_id,
|
||||
item: params[:item],
|
||||
jti: jti,
|
||||
address_city: address_data["city"],
|
||||
address_country: address_data["country"]
|
||||
}, status: :accepted
|
||||
|
||||
rescue HcaAddressFetcher::FetchError => e
|
||||
Rails.logger.error "[Delegated] Address fetch failed: #{e.message}"
|
||||
render json: { error: "address_fetch_failed", message: e.message }, status: :bad_gateway
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_jwt!
|
||||
token = request.headers["Authorization"]&.sub(/\ABearer\s+/i, "")
|
||||
unless token.present?
|
||||
return render json: { error: "missing_token" }, status: :unauthorized
|
||||
end
|
||||
|
||||
validator = HcaJwtValidator.new(
|
||||
token,
|
||||
expected_aud: "https://mail.hackclub.com",
|
||||
required_scope: "theseus:send_mail"
|
||||
)
|
||||
@jwt_payload = validator.validate!
|
||||
rescue HcaJwtValidator::ValidationError => e
|
||||
render json: { error: "invalid_token", message: e.message }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
59
app/services/hca_address_fetcher.rb
Normal file
59
app/services/hca_address_fetcher.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HcaAddressFetcher
|
||||
class FetchError < StandardError; end
|
||||
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
def self.fetch(identity_public_id)
|
||||
new.fetch(identity_public_id)
|
||||
end
|
||||
|
||||
def fetch(identity_public_id)
|
||||
cache_key = "hca_address_#{identity_public_id}"
|
||||
|
||||
Rails.cache.fetch(cache_key, expires_in: CACHE_TTL.seconds) do
|
||||
fetch_from_hca(identity_public_id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_from_hca(identity_public_id)
|
||||
hca_issuer = ENV.fetch("HCA_ISSUER", "https://auth.hackclub.com")
|
||||
client_id = ENV.fetch("THESEUS_HCA_CLIENT_ID")
|
||||
client_secret = ENV.fetch("THESEUS_HCA_CLIENT_SECRET")
|
||||
|
||||
url = "#{hca_issuer}/api/v1/s2s/identities/#{identity_public_id}/address"
|
||||
uri = URI.parse(url)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
http.open_timeout = 5
|
||||
http.read_timeout = 10
|
||||
|
||||
# mTLS client cert (if configured)
|
||||
client_cert_pem = ENV["THESEUS_MTLS_CLIENT_CERT"]
|
||||
client_key_pem = ENV["THESEUS_MTLS_CLIENT_KEY"]
|
||||
|
||||
if client_cert_pem.present? && client_key_pem.present?
|
||||
http.cert = OpenSSL::X509::Certificate.new(client_cert_pem)
|
||||
http.key = OpenSSL::PKey.read(client_key_pem)
|
||||
end
|
||||
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
request.basic_auth(client_id, client_secret)
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
raise FetchError, "HCA S2S address fetch failed: HTTP #{response.code}"
|
||||
end
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
data["address"]
|
||||
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
raise FetchError, "Cannot reach HCA: #{e.message}"
|
||||
end
|
||||
end
|
||||
88
app/services/hca_jwt_validator.rb
Normal file
88
app/services/hca_jwt_validator.rb
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "jwt"
|
||||
require "ed25519"
|
||||
require "net/http"
|
||||
|
||||
class HcaJwtValidator
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
JWKS_CACHE_TTL = 300
|
||||
|
||||
def initialize(token, expected_aud:, required_scope:, allowed_azps: nil, hca_jwks_uri: nil, hca_issuer: nil)
|
||||
@token = token
|
||||
@expected_aud = expected_aud
|
||||
@required_scope = required_scope
|
||||
@allowed_azps = allowed_azps || ENV.fetch("THESEUS_ALLOWED_AZPS", "").split(",").map(&:strip).reject(&:blank?)
|
||||
@hca_jwks_uri = hca_jwks_uri || ENV.fetch("HCA_JWKS_URI", "#{hca_issuer_url}/.well-known/jwks.json")
|
||||
@hca_issuer = hca_issuer || hca_issuer_url
|
||||
end
|
||||
|
||||
def validate!
|
||||
header = JWT.decode(@token, nil, false).last
|
||||
kid = header["kid"]
|
||||
raise ValidationError, "missing kid" unless kid.present?
|
||||
|
||||
verify_key = find_verify_key(kid)
|
||||
|
||||
payload, = JWT.decode(@token, verify_key, true, {
|
||||
algorithm: "EdDSA",
|
||||
verify_iss: true, iss: @hca_issuer,
|
||||
verify_aud: true, aud: @expected_aud,
|
||||
verify_expiration: true,
|
||||
verify_not_before: true
|
||||
})
|
||||
|
||||
token_scopes = (payload["scope"] || "").split(" ")
|
||||
raise ValidationError, "missing scope '#{@required_scope}'" unless token_scopes.include?(@required_scope)
|
||||
|
||||
if @allowed_azps.any? && !@allowed_azps.include?(payload["azp"])
|
||||
raise ValidationError, "azp '#{payload['azp']}' not allowed"
|
||||
end
|
||||
|
||||
raise ValidationError, "missing jti" unless payload["jti"].present?
|
||||
raise ValidationError, "jti revoked" if revoked?(payload["jti"])
|
||||
|
||||
payload
|
||||
rescue JWT::DecodeError => e
|
||||
raise ValidationError, "decode failed: #{e.message}"
|
||||
rescue JWT::ExpiredSignature
|
||||
raise ValidationError, "token expired"
|
||||
rescue JWT::InvalidIssuerError
|
||||
raise ValidationError, "issuer mismatch"
|
||||
rescue JWT::InvalidAudError
|
||||
raise ValidationError, "audience mismatch"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hca_issuer_url
|
||||
ENV.fetch("HCA_ISSUER", "https://auth.hackclub.com")
|
||||
end
|
||||
|
||||
def find_verify_key(kid)
|
||||
jwk = fetch_jwks.find { |k| k["kid"] == kid }
|
||||
jwk ||= fetch_jwks(force_refresh: true).find { |k| k["kid"] == kid }
|
||||
raise ValidationError, "no key for kid '#{kid}'" unless jwk
|
||||
raise ValidationError, "key not OKP/Ed25519" unless jwk["kty"] == "OKP" && jwk["crv"] == "Ed25519"
|
||||
|
||||
Ed25519::VerifyKey.new(Base64.urlsafe_decode64(jwk["x"]))
|
||||
end
|
||||
|
||||
def fetch_jwks(force_refresh: false)
|
||||
cache_key = "hca_jwks_keys"
|
||||
Rails.cache.delete(cache_key) if force_refresh
|
||||
|
||||
Rails.cache.fetch(cache_key, expires_in: JWKS_CACHE_TTL.seconds) do
|
||||
uri = URI.parse(@hca_jwks_uri)
|
||||
resp = Net::HTTP.get_response(uri)
|
||||
raise ValidationError, "JWKS fetch failed: HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
||||
JSON.parse(resp.body)["keys"] || []
|
||||
end
|
||||
end
|
||||
|
||||
def revoked?(jti)
|
||||
revoked_jtis = Rails.cache.fetch("hca_revoked_jtis", expires_in: 60.seconds) { Set.new }
|
||||
revoked_jtis.include?(jti)
|
||||
end
|
||||
end
|
||||
|
|
@ -714,6 +714,9 @@ Rails.application.routes.draw do
|
|||
post "from_template/:template_id", to: "warehouse_orders#from_template", as: :from_template
|
||||
end
|
||||
end
|
||||
|
||||
# Delegated auth endpoints (JWT-authenticated, no APIKey)
|
||||
post "delegated/send_mail", to: "delegated#send_mail"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
0
db/development.sqlite3
Normal file
0
db/development.sqlite3
Normal file
Loading…
Add table
Reference in a new issue