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



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

Returns:

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

Returns:

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

Returns:

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