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
58 59 60 |
# File 'app/models/standard_id/authorization_code.rb', line 58 def self.default_ttl 10.minutes end |
.hash_for(plaintext_code) ⇒ Object
54 55 56 |
# File 'app/models/standard_id/authorization_code.rb', line 54 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 |
# 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. if code_challenge.present? unless code_challenge_method.to_s.downcase == "s256" 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
50 51 52 |
# File 'app/models/standard_id/authorization_code.rb', line 50 def self.lookup(plaintext_code) find_by(code_hash: hash_for(plaintext_code)) end |
Instance Method Details
#expired? ⇒ Boolean
66 67 68 |
# File 'app/models/standard_id/authorization_code.rb', line 66 def expired? expires_at <= Time.current end |
#mark_as_used! ⇒ Object
91 92 93 94 95 96 97 |
# File 'app/models/standard_id/authorization_code.rb', line 91 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
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'app/models/standard_id/authorization_code.rb', line 70 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
62 63 64 |
# File 'app/models/standard_id/authorization_code.rb', line 62 def valid_for_client?(client_id) self.client_id == client_id && consumed_at.nil? && !expired? end |