Class: StandardId::AuthorizationCode

Inherits:
ApplicationRecord show all
Defined in:
app/models/standard_id/authorization_code.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.default_ttlObject



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: ,
    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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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