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.
-
.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 |
# 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], api_key_id: user_data[:api_key_id], api_key_secret: user_data[:api_key_secret], 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.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/mcp/auth/services/token_service.rb', line 182 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
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/mcp/auth/services/token_service.rb', line 98 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
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/mcp/auth/services/token_service.rb', line 154 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).
257 258 259 260 261 |
# File 'lib/mcp/auth/services/token_service.rb', line 257 def reset_signing_keys! @cached_private_key = nil @cached_public_key = nil @jwk = nil end |
.revoke_refresh_token(refresh_token) ⇒ Object
Revoke refresh token (RFC 7009)
142 143 144 145 146 147 148 149 150 151 |
# File 'lib/mcp/auth/services/token_service.rb', line 142 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).
227 228 229 230 231 232 233 234 235 |
# File 'lib/mcp/auth/services/token_service.rb', line 227 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.
240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/mcp/auth/services/token_service.rb', line 240 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.
219 220 221 222 223 |
# File 'lib/mcp/auth/services/token_service.rb', line 219 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.
210 211 212 213 214 |
# File 'lib/mcp/auth/services/token_service.rb', line 210 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
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/mcp/auth/services/token_service.rb', line 123 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 |