From f3887dbd01ca1bca63ac0ee06461aca744107f06 Mon Sep 17 00:00:00 2001 From: End Date: Sun, 29 Mar 2026 23:07:27 -0700 Subject: [PATCH] delegated mail endpoint --- Gemfile | 3 + Gemfile.lock | 21 +++-- .../api/v1/delegated_controller.rb | 68 ++++++++++++++ app/services/hca_address_fetcher.rb | 59 +++++++++++++ app/services/hca_jwt_validator.rb | 88 +++++++++++++++++++ config/routes.rb | 3 + db/development.sqlite3 | 0 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/v1/delegated_controller.rb create mode 100644 app/services/hca_address_fetcher.rb create mode 100644 app/services/hca_jwt_validator.rb create mode 100644 db/development.sqlite3 diff --git a/Gemfile b/Gemfile index b2fd0ed..6fe7e35 100644 --- a/Gemfile +++ b/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" diff --git a/Gemfile.lock b/Gemfile.lock index 91979e4..6a28ea9 100644 --- a/Gemfile.lock +++ b/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) diff --git a/app/controllers/api/v1/delegated_controller.rb b/app/controllers/api/v1/delegated_controller.rb new file mode 100644 index 0000000..bbdb0fc --- /dev/null +++ b/app/controllers/api/v1/delegated_controller.rb @@ -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 diff --git a/app/services/hca_address_fetcher.rb b/app/services/hca_address_fetcher.rb new file mode 100644 index 0000000..63289f9 --- /dev/null +++ b/app/services/hca_address_fetcher.rb @@ -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 diff --git a/app/services/hca_jwt_validator.rb b/app/services/hca_jwt_validator.rb new file mode 100644 index 0000000..d1cb05b --- /dev/null +++ b/app/services/hca_jwt_validator.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index f762c9b..a225737 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/development.sqlite3 b/db/development.sqlite3 new file mode 100644 index 0000000..e69de29