Class: Mcp::Auth::Services::TokenService
- Inherits:
-
Object
- Object
- Mcp::Auth::Services::TokenService
- Defined in:
- lib/mcp/auth/services/token_service.rb
Class Method Summary collapse
-
.decode_with_known_keys(token) ⇒ Object
Decode against every key we currently trust.
-
.generate_access_token(data, base_url:) ⇒ Object
Generate JWT access token with proper audience binding.
-
.generate_id_token(data, base_url:) ⇒ Object
OpenID Connect ID Token.
-
.generate_refresh_token(data) ⇒ Object
Generate refresh token.
-
.generate_token_response(data, base_url:) ⇒ Object
Generate complete token response.
-
.reset_signing_keys! ⇒ Object
Clears memoized key material.
-
.resource_allowed?(resource, canonical_resource) ⇒ Boolean
RFC 8707 §2 / MCP authorization spec: an authorization server MUST only honor resource indicators that name a resource it actually serves.
-
.revoke_refresh_token(refresh_token) ⇒ Object
Revoke refresh token (RFC 7009).
-
.signing_jwk_export ⇒ Object
JWK for the active public key, suitable for the JWKS endpoint.
-
.signing_jwks_export ⇒ Object
All public JWKs to publish at the JWKS endpoint: the active key plus any additional rotation keys.
-
.signing_kid ⇒ Object
JWK identifier for the current signing key — included as ‘kid` in JWT headers and the JWKS entry.
-
.signing_public_key ⇒ Object
Public key used to sign new JWTs.
-
.validate_access_token(token, resource: nil) ⇒ Object
Validate access token with optional resource verification (RFC 8707).
-
.validate_refresh_token(refresh_token) ⇒ Object
Validate refresh token.
Class Method Details
.decode_with_known_keys(token) ⇒ Object
Decode against every key we currently trust. For HS256 that is the single shared secret; for RS256/ES256 it is the active public key plus any additional public keys configured for rotation, so tokens signed with the previous key keep validating across a key roll.
46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/mcp/auth/services/token_service.rb', line 46 def decode_with_known_keys(token) last_error = nil verification_keys.each do |key| return JWT.decode(token, key, true, { algorithm: signing_algorithm }).first rescue JWT::DecodeError => e last_error = e end raise last_error if last_error nil end |
.generate_access_token(data, base_url:) ⇒ Object
Generate JWT access token with proper audience binding
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/mcp/auth/services/token_service.rb', line 59 def generate_access_token(data, base_url:) user_data = fetch_user_data(data) # RFC 8707: Use provided resource or default to the configured MCP # endpoint. The default MUST mirror the canonical resource published in # the protected-resource metadata, which is built from mcp_server_path — # otherwise audience validation breaks for any non-default path. audience = normalize_resource_uri(data[:resource].presence || default_audience(base_url)) # Calculate expiration time exp_time = data[:expires_at] ? data[:expires_at].to_i : (Time.current.to_i + token_lifetime) payload = { iss: base_url, aud: audience, sub: data[:user_id].to_s, org: data[:org_id]&.to_s, client_id: data[:client_id], email: user_data[:email], scope: data[:scope], # Only a non-sensitive API key *identifier* is embedded. A bearer # JWT is decodable by anyone holding it (and is stored at rest), so # the matching secret MUST NOT be placed in the token — the resource # server resolves the secret server-side from this id when needed. api_key_id: user_data[:api_key_id], iat: Time.current.to_i, exp: exp_time } jwt_headers = signing_kid ? { kid: signing_kid } : {} token = JWT.encode(payload, signing_key, signing_algorithm, jwt_headers) # Store token in database for revocation support store_access_token(token, data, audience) token rescue StandardError => e Rails.logger.error "[TokenService] Failed to generate access token: #{e.}" raise end |
.generate_id_token(data, base_url:) ⇒ Object
OpenID Connect ID Token. Audience is the client_id (not the resource), per the OIDC core spec. Only the claims permitted by the granted profile/email scopes are included.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/mcp/auth/services/token_service.rb', line 185 def generate_id_token(data, base_url:) return nil if data[:client_id].blank? user_data = fetch_user_data(data) scopes = data[:scope].to_s.split payload = { iss: base_url, sub: data[:user_id].to_s, aud: data[:client_id], iat: Time.current.to_i, exp: Time.current.to_i + token_lifetime } if scopes.include?('email') payload[:email] = user_data[:email] payload[:email_verified] = true end payload[:name] = user_data[:email] if scopes.include?('profile') jwt_headers = signing_kid ? { kid: signing_kid } : {} JWT.encode(payload, signing_key, signing_algorithm, jwt_headers) rescue StandardError => e Rails.logger.error "[TokenService] Failed to generate id_token: #{e.}" nil end |
.generate_refresh_token(data) ⇒ Object
Generate refresh token
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/mcp/auth/services/token_service.rb', line 101 def generate_refresh_token(data) refresh_token = SecureRandom.hex(32) # Use provided expires_at or default expires_at = data[:expires_at] || refresh_token_lifetime.seconds.from_now begin Mcp::Auth::RefreshToken.create!( token: refresh_token, client_id: data[:client_id], scope: data[:scope], user_id: data[:user_id], org_id: data[:org_id], expires_at: expires_at ) Rails.logger.info "[TokenService] Refresh token created for user #{data[:user_id]}" refresh_token rescue ActiveRecord::RecordInvalid => e Rails.logger.error "[TokenService] Failed to create refresh token: #{e.}" nil end end |
.generate_token_response(data, base_url:) ⇒ Object
Generate complete token response
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/mcp/auth/services/token_service.rb', line 157 def generate_token_response(data, base_url:) access_token = generate_access_token(data, base_url: base_url) refresh_token = generate_refresh_token(data) response = { access_token: access_token, token_type: 'Bearer', expires_in: token_lifetime, scope: data[:scope] } response[:refresh_token] = refresh_token if refresh_token # OpenID Connect: only issue an id_token when the `openid` scope was granted. if openid_scope?(data[:scope]) id_token = generate_id_token(data, base_url: base_url) response[:id_token] = id_token if id_token end response rescue StandardError => e Rails.logger.error "[TokenService] Failed to generate token response: #{e.}" raise end |
.reset_signing_keys! ⇒ Object
Clears memoized key material. Call after rotating signing keys at runtime (otherwise the previously loaded keys stay cached for the life of the process).
260 261 262 263 264 |
# File 'lib/mcp/auth/services/token_service.rb', line 260 def reset_signing_keys! @cached_private_key = nil @cached_public_key = nil @jwk = nil end |
.resource_allowed?(resource, canonical_resource) ⇒ Boolean
RFC 8707 §2 / MCP authorization spec: an authorization server MUST only honor resource indicators that name a resource it actually serves. A blank resource defaults to the canonical resource, so it is allowed; otherwise the request is accepted only when the requested resource matches this server’s canonical resource (normalized, never a substring match). This stops a malicious client from minting tokens whose ‘aud` is some other — possibly attacker-controlled — resource.
273 274 275 276 277 |
# File 'lib/mcp/auth/services/token_service.rb', line 273 def resource_allowed?(resource, canonical_resource) return true if resource.blank? audience_matches?(canonical_resource, resource) end |
.revoke_refresh_token(refresh_token) ⇒ Object
Revoke refresh token (RFC 7009)
145 146 147 148 149 150 151 152 153 154 |
# File 'lib/mcp/auth/services/token_service.rb', line 145 def revoke_refresh_token(refresh_token) return false if refresh_token.blank? token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token) return false unless token_record token_record.destroy Rails.logger.info '[TokenService] Refresh token revoked' true end |
.signing_jwk_export ⇒ Object
JWK for the active public key, suitable for the JWKS endpoint. Returns nil for HS256 (HMAC keys are never published).
230 231 232 233 234 235 236 237 238 |
# File 'lib/mcp/auth/services/token_service.rb', line 230 def signing_jwk_export return nil unless asymmetric_signing? exported = jwk.export exported[:kid] = signing_kid exported[:alg] = signing_algorithm exported[:use] = 'sig' exported end |
.signing_jwks_export ⇒ Object
All public JWKs to publish at the JWKS endpoint: the active key plus any additional rotation keys. During a key roll the previous key stays listed so already-issued tokens keep verifying. Empty for HS256.
243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/mcp/auth/services/token_service.rb', line 243 def signing_jwks_export return [] unless asymmetric_signing? keys = [signing_jwk_export] additional_public_keys.each do |pkey| additional_jwk = JWT::JWK.new(pkey) exported = additional_jwk.export exported[:alg] = signing_algorithm exported[:use] = 'sig' keys << exported end keys.compact end |
.signing_kid ⇒ Object
JWK identifier for the current signing key — included as ‘kid` in JWT headers and the JWKS entry. Falls back to JWT::JWK’s auto-derived thumbprint when no explicit kid is configured.
222 223 224 225 226 |
# File 'lib/mcp/auth/services/token_service.rb', line 222 def signing_kid return nil unless asymmetric_signing? Mcp::Auth.configuration&.token_signing_kid.presence || jwk.kid end |
.signing_public_key ⇒ Object
Public key used to sign new JWTs. Configured via Mcp::Auth.configure; built lazily so apps that stay on HS256 don’t have to set anything.
213 214 215 216 217 |
# File 'lib/mcp/auth/services/token_service.rb', line 213 def signing_public_key return nil unless asymmetric_signing? cached_public_key end |
.validate_access_token(token, resource: nil) ⇒ Object
Validate access token with optional resource verification (RFC 8707). Supports HS256, RS256, and ES256 — algorithm comes from configuration.
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'lib/mcp/auth/services/token_service.rb', line 10 def validate_access_token(token, resource: nil) return nil if token.blank? begin payload = decode_with_known_keys(token) return nil unless payload # Check expiration manually to ensure proper handling return nil if payload['exp'] && (payload['exp'] <= Time.current.to_i) # Revocation check (RFC 7009): a JWT remains cryptographically valid # until it expires, so a stored-and-still-present row is what makes # `revoke` actually take effect. Without this, destroyed tokens would # keep validating until natural expiry. return nil unless Mcp::Auth::AccessToken.active.exists?(token: token) # Validate audience if resource provided (RFC 8707 compliance) if resource && payload['aud'].present? && !audience_matches?(payload['aud'], resource) Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}" return nil end payload.symbolize_keys rescue JWT::DecodeError, JWT::ExpiredSignature => e Rails.logger.debug { "[TokenService] Token validation failed: #{e.}" } nil rescue StandardError => e Rails.logger.error "[TokenService] Token validation error: #{e.}" nil end end |
.validate_refresh_token(refresh_token) ⇒ Object
Validate refresh token
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/mcp/auth/services/token_service.rb', line 126 def validate_refresh_token(refresh_token) return nil if refresh_token.blank? token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token) return nil unless token_record # Check if token is expired return nil if token_record.expires_at < Time.current Rails.logger.info "[TokenService] Refresh token validated for user #{token_record.user_id}" { client_id: token_record.client_id, scope: token_record.scope, user_id: token_record.user_id, org_id: token_record.org_id } end |