delegated mail endpoint

This commit is contained in:
End 2026-03-29 23:07:27 -07:00
parent 2e7764dd8e
commit f3887dbd01
No known key found for this signature in database
7 changed files with 236 additions and 6 deletions

View file

@ -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"

View file

@ -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)

View 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

View 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

View 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

View file

@ -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
View file