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
# 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.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.



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



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_exportObject

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_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.



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_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.



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_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.



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.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



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