Class: Mcp::Auth::Services::TokenService

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp/auth/services/token_service.rb

Class Method Summary collapse

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.message}"
  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.message}"
  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.message}"
    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.message}"
  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.

Returns:

  • (Boolean)


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_exportObject

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_exportObject

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_kidObject

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_keyObject

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.message}" }
    nil
  rescue StandardError => e
    Rails.logger.error "[TokenService] Token validation error: #{e.message}"
    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