From 8c370d0829602f4bd89dedffc47ea5091f49176d Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sun, 19 Jul 2026 15:55:02 +1000 Subject: [PATCH] Extract shared token-minting helper for auth-code and device grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt --- app/controllers/oidc_controller.rb | 163 +++++++++++++---------------- 1 file changed, 71 insertions(+), 92 deletions(-) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 9929282..bb4e081 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -669,55 +669,21 @@ class OidcController < ApplicationController 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 # detected as reuse — see the redeemed? check at the top of this block. The # cleanup job reaps redeemed codes after they expire. device_code.update!(redeemed_at: Time.current) - 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: granted_scope - } + # Device flow never carries an OIDC claims request, so there are no claims + # to filter into the id_token. + mint_and_render_tokens( + application: application, + user: user, + grant: device_code, + grant_association: {oidc_device_code: device_code}, + consent: consent, + claims_requests: {} + ) end end @@ -826,28 +792,8 @@ class OidcController < ApplicationController return end - # Generate access token record (opaque token with BCrypt hashing) - access_token_record = OidcAccessToken.create!( - 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 + # Find user consent for this application before minting, so a missing + # consent record can't leave orphaned tokens committed in this transaction. consent = OidcUserConsent.find_by(user: user, application: application) unless consent @@ -856,35 +802,16 @@ class OidcController < ApplicationController return end - # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr - # auth_time and acr come from the authorization code (captured at /authorize time) - # scopes determine which claims are included (per OIDC Core spec) - # claims_requests parameter filters which claims are included - id_token = OidcJwtService.generate_id_token( - user, - application, + # auth_time, acr, and nonce come from the authorization code (captured at + # /authorize time); the claims request filters which id_token claims appear. + mint_and_render_tokens( + application: application, + user: user, + grant: auth_code, + grant_association: {oidc_authorization_code: auth_code}, 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 ) - - # 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 rescue ActiveRecord::RecordNotFound 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 # redirect_uri that already carries a query string gets "&error=..." rather than # 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) query = {error: error} query[:error_description] = description if description