Extract shared token-minting helper for auth-code and device grants
handle_authorization_code_grant and handle_device_code_grant each inlined the same ~50-line access-token / refresh-token / id-token minting and RFC 6749 §5.1 response block. Factor it into mint_and_render_tokens, parameterized by the redeemed grant (auth code or device code), its belongs_to association, and the claims request. Also moves the auth-code consent lookup ahead of minting so a missing consent record can no longer leave orphaned tokens committed in the transaction (a return inside the locked transaction does not roll back). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8b146db54e
commit
8c370d0829
@@ -669,55 +669,21 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
granted_scope = device_code.scope
|
|
||||||
|
|
||||||
access_token_record = OidcAccessToken.create!(
|
|
||||||
application: application,
|
|
||||||
user: user,
|
|
||||||
scope: granted_scope,
|
|
||||||
oidc_device_code: device_code,
|
|
||||||
resource: device_code.resource
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_token_record = OidcRefreshToken.create!(
|
|
||||||
application: application,
|
|
||||||
user: user,
|
|
||||||
oidc_access_token: access_token_record,
|
|
||||||
oidc_device_code: device_code,
|
|
||||||
scope: granted_scope,
|
|
||||||
auth_time: device_code.auth_time,
|
|
||||||
acr: device_code.acr,
|
|
||||||
resource: device_code.resource
|
|
||||||
)
|
|
||||||
|
|
||||||
id_token = OidcJwtService.generate_id_token(
|
|
||||||
user,
|
|
||||||
application,
|
|
||||||
consent: consent,
|
|
||||||
nonce: device_code.nonce,
|
|
||||||
access_token: access_token_record.plaintext_token,
|
|
||||||
auth_time: device_code.auth_time,
|
|
||||||
acr: device_code.acr,
|
|
||||||
scopes: granted_scope,
|
|
||||||
claims_requests: {}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Single-use: mark the code redeemed (don't destroy it) so a replay is
|
# Single-use: mark the code redeemed (don't destroy it) so a replay is
|
||||||
# detected as reuse — see the redeemed? check at the top of this block. The
|
# detected as reuse — see the redeemed? check at the top of this block. The
|
||||||
# cleanup job reaps redeemed codes after they expire.
|
# cleanup job reaps redeemed codes after they expire.
|
||||||
device_code.update!(redeemed_at: Time.current)
|
device_code.update!(redeemed_at: Time.current)
|
||||||
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
# Device flow never carries an OIDC claims request, so there are no claims
|
||||||
response.headers["Pragma"] = "no-cache"
|
# to filter into the id_token.
|
||||||
|
mint_and_render_tokens(
|
||||||
render json: {
|
application: application,
|
||||||
access_token: access_token_record.plaintext_token,
|
user: user,
|
||||||
token_type: "Bearer",
|
grant: device_code,
|
||||||
expires_in: application.access_token_ttl || 3600,
|
grant_association: {oidc_device_code: device_code},
|
||||||
id_token: id_token,
|
consent: consent,
|
||||||
refresh_token: refresh_token_record.token,
|
claims_requests: {}
|
||||||
scope: granted_scope
|
)
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -826,28 +792,8 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate access token record (opaque token with BCrypt hashing)
|
# Find user consent for this application before minting, so a missing
|
||||||
access_token_record = OidcAccessToken.create!(
|
# consent record can't leave orphaned tokens committed in this transaction.
|
||||||
application: application,
|
|
||||||
user: user,
|
|
||||||
scope: auth_code.scope,
|
|
||||||
oidc_authorization_code: auth_code,
|
|
||||||
resource: auth_code.resource
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate refresh token (opaque, with hashing)
|
|
||||||
refresh_token_record = OidcRefreshToken.create!(
|
|
||||||
application: application,
|
|
||||||
user: user,
|
|
||||||
oidc_access_token: access_token_record,
|
|
||||||
oidc_authorization_code: auth_code,
|
|
||||||
scope: auth_code.scope,
|
|
||||||
auth_time: auth_code.auth_time,
|
|
||||||
acr: auth_code.acr,
|
|
||||||
resource: auth_code.resource
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find user consent for this application
|
|
||||||
consent = OidcUserConsent.find_by(user: user, application: application)
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||||
|
|
||||||
unless consent
|
unless consent
|
||||||
@@ -856,35 +802,16 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
# auth_time, acr, and nonce come from the authorization code (captured at
|
||||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
# /authorize time); the claims request filters which id_token claims appear.
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
mint_and_render_tokens(
|
||||||
# claims_requests parameter filters which claims are included
|
application: application,
|
||||||
id_token = OidcJwtService.generate_id_token(
|
user: user,
|
||||||
user,
|
grant: auth_code,
|
||||||
application,
|
grant_association: {oidc_authorization_code: auth_code},
|
||||||
consent: consent,
|
consent: consent,
|
||||||
nonce: auth_code.nonce,
|
|
||||||
access_token: access_token_record.plaintext_token,
|
|
||||||
auth_time: auth_code.auth_time,
|
|
||||||
acr: auth_code.acr,
|
|
||||||
scopes: auth_code.scope,
|
|
||||||
claims_requests: auth_code.parsed_claims_requests
|
claims_requests: auth_code.parsed_claims_requests
|
||||||
)
|
)
|
||||||
|
|
||||||
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
response.headers["Pragma"] = "no-cache"
|
|
||||||
|
|
||||||
# Return tokens
|
|
||||||
render json: {
|
|
||||||
access_token: access_token_record.plaintext_token, # Opaque token
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: application.access_token_ttl || 3600,
|
|
||||||
id_token: id_token, # JWT
|
|
||||||
refresh_token: refresh_token_record.token, # Opaque token
|
|
||||||
scope: auth_code.scope
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: {error: "invalid_grant"}, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
@@ -1355,6 +1282,58 @@ class OidcController < ApplicationController
|
|||||||
# report errors this same way ~a dozen times. Composes the query safely so a
|
# report errors this same way ~a dozen times. Composes the query safely so a
|
||||||
# redirect_uri that already carries a query string gets "&error=..." rather than
|
# redirect_uri that already carries a query string gets "&error=..." rather than
|
||||||
# a second "?", and CGI-escapes the description and state.
|
# a second "?", and CGI-escapes the description and state.
|
||||||
|
# Mints the access + refresh + id-token triple and renders the RFC 6749 §5.1
|
||||||
|
# token response. Shared by the authorization-code and device-code grants: both
|
||||||
|
# redeem a `grant` (the auth code / device code) exposing scope/resource/nonce/
|
||||||
|
# auth_time/acr, and both tie the tokens back to it via `grant_association` (the
|
||||||
|
# belongs_to used for replay revocation). The caller marks the grant consumed
|
||||||
|
# before calling this, and both callers run inside the grant's locked transaction.
|
||||||
|
def mint_and_render_tokens(application:, user:, grant:, grant_association:, consent:, claims_requests:)
|
||||||
|
access_token_record = OidcAccessToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
scope: grant.scope,
|
||||||
|
resource: grant.resource,
|
||||||
|
**grant_association
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token_record = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: access_token_record,
|
||||||
|
scope: grant.scope,
|
||||||
|
auth_time: grant.auth_time,
|
||||||
|
acr: grant.acr,
|
||||||
|
resource: grant.resource,
|
||||||
|
**grant_association
|
||||||
|
)
|
||||||
|
|
||||||
|
id_token = OidcJwtService.generate_id_token(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
consent: consent,
|
||||||
|
nonce: grant.nonce,
|
||||||
|
access_token: access_token_record.plaintext_token,
|
||||||
|
auth_time: grant.auth_time,
|
||||||
|
acr: grant.acr,
|
||||||
|
scopes: grant.scope,
|
||||||
|
claims_requests: claims_requests
|
||||||
|
)
|
||||||
|
|
||||||
|
# RFC 6749 §5.1: the token response MUST NOT be cached.
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
access_token: access_token_record.plaintext_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: application.access_token_ttl || 3600,
|
||||||
|
id_token: id_token,
|
||||||
|
refresh_token: refresh_token_record.token,
|
||||||
|
scope: grant.scope
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def redirect_authorize_error(redirect_uri, error, description: nil, state: nil)
|
def redirect_authorize_error(redirect_uri, error, description: nil, state: nil)
|
||||||
query = {error: error}
|
query = {error: error}
|
||||||
query[:error_description] = description if description
|
query[:error_description] = description if description
|
||||||
|
|||||||
Reference in New Issue
Block a user