Class: StandardId::AuthorizationCode
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- StandardId::AuthorizationCode
- Defined in:
- app/models/standard_id/authorization_code.rb
Class Method Summary collapse
- .default_ttl ⇒ Object
- .hash_for(plaintext_code) ⇒ Object
- .issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {}) ⇒ Object
- .lookup(plaintext_code) ⇒ Object
Instance Method Summary collapse
- #expired? ⇒ Boolean
- #mark_as_used! ⇒ Object
- #pkce_valid?(code_verifier) ⇒ Boolean
- #valid_for_client?(client_id) ⇒ Boolean
Class Method Details
.default_ttl ⇒ Object
71 72 73 |
# File 'app/models/standard_id/authorization_code.rb', line 71 def self.default_ttl 10.minutes end |
.hash_for(plaintext_code) ⇒ Object
67 68 69 |
# File 'app/models/standard_id/authorization_code.rb', line 67 def self.hash_for(plaintext_code) Digest::SHA256.hexdigest("#{plaintext_code}:#{Rails.configuration.secret_key_base}") end |
.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {}) ⇒ Object
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'app/models/standard_id/authorization_code.rb', line 19 def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {}) # Fail fast: reject unsupported PKCE methods at issuance rather than # storing a code that will always fail at redemption time. When the # client record has PKCE required, defer to the client's configured # `code_challenge_methods`. In all other cases (client lookup missing, # or client opted out of PKCE but still sent a challenge) fall back # to an S256-only belt-and-suspenders check so we never silently # accept a weaker method. if code_challenge.present? client = StandardId::ClientApplication.find_by(client_id: client_id) method_supported = if client&.require_pkce? client.supports_pkce_method?(code_challenge_method) else code_challenge_method.to_s.downcase == "s256" end unless method_supported raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed" end end # Hash the code_challenge for defense-in-depth (RAR-58). # The stored value is SHA256(S256_challenge), where S256_challenge is # base64url(SHA256(verifier)). This is intentionally a double-hash: # S256 derives the challenge from the verifier, and we hash again for storage. hashed_challenge = code_challenge.present? ? Digest::SHA256.hexdigest(code_challenge) : nil create!( account: account, code_hash: hash_for(plaintext_code), client_id: client_id, redirect_uri: redirect_uri, scope: scope, audience: audience, code_challenge: hashed_challenge, code_challenge_method: code_challenge_method, nonce: nonce, issued_at: Time.current, expires_at: Time.current + default_ttl, metadata: || {} ) end |
.lookup(plaintext_code) ⇒ Object
63 64 65 |
# File 'app/models/standard_id/authorization_code.rb', line 63 def self.lookup(plaintext_code) find_by(code_hash: hash_for(plaintext_code)) end |
Instance Method Details
#expired? ⇒ Boolean
79 80 81 |
# File 'app/models/standard_id/authorization_code.rb', line 79 def expired? expires_at <= Time.current end |
#mark_as_used! ⇒ Object
104 105 106 107 108 109 110 |
# File 'app/models/standard_id/authorization_code.rb', line 104 def mark_as_used! with_lock do raise StandardId::InvalidGrantError, "Authorization code already used" if consumed_at.present? raise StandardId::InvalidGrantError, "Authorization code expired" if expired? update!(consumed_at: Time.current) end end |
#pkce_valid?(code_verifier) ⇒ Boolean
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'app/models/standard_id/authorization_code.rb', line 83 def pkce_valid?(code_verifier) return true if code_challenge.blank? return false if code_verifier.blank? # Only S256 is supported (OAuth 2.1). The "plain" method is rejected # because it transmits the verifier in cleartext, defeating PKCE's purpose. return false unless (code_challenge_method || "").downcase == "s256" s256_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=") # New format: stored value is SHA256(S256_challenge) hashed_expected = Digest::SHA256.hexdigest(s256_challenge) return true if ActiveSupport::SecurityUtils.secure_compare(hashed_expected, code_challenge) # Legacy fallback: codes issued before RAR-58 store the raw S256 challenge. # This handles in-flight codes during deployment (max 10-minute TTL). # Safe to remove after one deployment cycle. ActiveSupport::SecurityUtils.secure_compare(s256_challenge, code_challenge) end |
#valid_for_client?(client_id) ⇒ Boolean
75 76 77 |
# File 'app/models/standard_id/authorization_code.rb', line 75 def valid_for_client?(client_id) self.client_id == client_id && consumed_at.nil? && !expired? end |