Class: Hitch::DeviceGrant
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Hitch::DeviceGrant
- Defined in:
- app/models/hitch/device_grant.rb
Overview
RFC 8628 device authorization grant. Lifecycle:
pending — codes minted, awaiting a person on /activate
↓ approve! (binds the signed-in principal) | deny!
approved | denied
↓ exchange_device_code! — the polling client's next request
consumed — an access token minted through the real exchange path
Every transition is one conditional UPDATE whose WHERE clause is the guard, the same discipline as the authorization-code exchange: the database picks exactly one winner and no half-decided state — approved but unowned, consumed twice — is ever representable.
Constant Summary collapse
- USER_CODE_ALPHABET =
Crockford base32: RFC 8628 §6.1 wants no easily-confused characters, and this alphabet drops I, L, O, U while its decoding treats the survivors' look-alikes as equal. Eight characters is 40 bits, which together with the per-principal verification quota clears the §5.1 brute-force bar with room to spare.
"0123456789ABCDEFGHJKMNPQRSTVWXYZ"- USER_CODE_LENGTH =
8- TOKEN_ENDPOINT_AUTH_METHODS =
%w[none client_secret_basic].freeze
- OAuthError =
The token endpoint already rescues AccessToken::OAuthError; a second error type would only mean a second rescue.
Hitch::AccessToken::OAuthError
Instance Attribute Summary collapse
-
#raw_device_code ⇒ Object
Raw codes surface once, in the mint response; only digests are at rest.
-
#raw_user_code ⇒ Object
Raw codes surface once, in the mint response; only digests are at rest.
Class Method Summary collapse
-
.approve!(user_code:, principal:, client_name: nil) ⇒ Object
The decision, its owner, and the user code's erasure land in one statement: a grant is never approved-but-unowned, a second decision finds nothing to decide, and only still-pending grants keep a guessable code alive.
-
.cleanup_expired! ⇒ Object
Rows are held a day past expiry so the §3.5 answer a polling client hears — expired_token, or access_denied for a denied grant — does not depend on when the host's cleanup job happens to run.
- .deny!(user_code:) ⇒ Object
-
.display_user_code(input) ⇒ Object
XXXX-XXXX, the form mint responses and screens show.
-
.exchange_device_code!(raw_device_code:, client_id:, resource_uri:, token_endpoint_auth_method:) ⇒ Object
The poll (RFC 8628 §3.4).
- .find_pending_by_user_code(input) ⇒ Object
-
.mint!(client_id:, scopes:, resource_uri:, token_endpoint_auth_method: nil) ⇒ Object
Scopes are clamped here, at mint — every caller's, not just the endpoint's — so what a grant holds is always grantable and the exchange can honor it verbatim.
-
.normalize_user_code(input) ⇒ Object
Canonical form: the eight alphabet characters.
Instance Attribute Details
#raw_device_code ⇒ Object
Raw codes surface once, in the mint response; only digests are at rest.
35 36 37 |
# File 'app/models/hitch/device_grant.rb', line 35 def raw_device_code @raw_device_code end |
#raw_user_code ⇒ Object
Raw codes surface once, in the mint response; only digests are at rest.
35 36 37 |
# File 'app/models/hitch/device_grant.rb', line 35 def raw_user_code @raw_user_code end |
Class Method Details
.approve!(user_code:, principal:, client_name: nil) ⇒ Object
The decision, its owner, and the user code's erasure land in one statement: a grant is never approved-but-unowned, a second decision finds nothing to decide, and only still-pending grants keep a guessable code alive. Returns false when the code already met a decision, expired, or never existed.
111 112 113 114 115 116 117 118 119 120 |
# File 'app/models/hitch/device_grant.rb', line 111 def self.approve!(user_code:, principal:, client_name: nil) raise ArgumentError, "principal must be persisted" unless principal&.persisted? decide!( user_code, :approved_at, principal_type: principal.class.polymorphic_name, principal_id: principal.id, client_name: client_name ) end |
.cleanup_expired! ⇒ Object
Rows are held a day past expiry so the §3.5 answer a polling client hears — expired_token, or access_denied for a denied grant — does not depend on when the host's cleanup job happens to run. Past the floor the row goes: any token issued through it lives on in hitch_access_tokens, which carries the durable audit trail. Same host-scheduled contract as AccessToken.cleanup_expired!.
195 196 197 |
# File 'app/models/hitch/device_grant.rb', line 195 def self.cleanup_expired! where("expires_at < ?", 1.day.ago).delete_all end |
.deny!(user_code:) ⇒ Object
122 123 124 |
# File 'app/models/hitch/device_grant.rb', line 122 def self.deny!(user_code:) decide!(user_code, :denied_at) end |
.display_user_code(input) ⇒ Object
XXXX-XXXX, the form mint responses and screens show. Accepts sloppy input; normalization is a no-op on canonical codes.
95 96 97 |
# File 'app/models/hitch/device_grant.rb', line 95 def self.display_user_code(input) normalize_user_code(input).scan(/.{1,4}/).join("-") end |
.exchange_device_code!(raw_device_code:, client_id:, resource_uri:, token_endpoint_auth_method:) ⇒ Object
The poll (RFC 8628 §3.4). Raises the §3.5 responses as OAuthError so the token endpoint's existing rescue renders them; nil is the generic invalid_grant, deliberately covering "never existed", "already consumed", and "lost the race" alike.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'app/models/hitch/device_grant.rb', line 130 def self.exchange_device_code!(raw_device_code:, client_id:, resource_uri:, token_endpoint_auth_method:) unless Hitch.configuration. raise OAuthError.new("unsupported_grant_type", "Device authorization is not enabled") end record = find_by(device_code_digest: Digest::SHA256.hexdigest(raw_device_code.to_s)) return nil unless record unless record.client_id == client_id raise OAuthError.new("invalid_grant", "Device code was not issued to this client") end unless record.token_endpoint_auth_method == token_endpoint_auth_method raise ClientAuthentication::Invalid.new( "invalid_client", "Client authentication failed", http_status: :unauthorized ) end unless record.resource_uri == resource_uri raise OAuthError.new("invalid_target", "resource does not match the authorized resource") end # Before expiry, deliberately: a person's "no" answers access_denied # however old the grant is. expired_token invites the client to start # the flow over (§3.5), which is not what the person said. if record.denied_at raise OAuthError.new("access_denied", "The request was denied") end now = Time.current if record.expires_at <= now raise OAuthError.new("expired_token", "Device code expired") end if record.approved_at # The approver may since have been deleted by the host. Consent does # not outlive its owner; the grant is dead, not the server. return nil if record.principal.nil? return consume!(record, now: now) end # §3.5: one poll per interval claims the window; the rest hear # slow_down. A rejected poll matches zero rows and writes nothing, # so it never advances the window — a client polling too fast is # throttled to the interval, not locked out. A little grace, scaled # so it can never halve the window: a conformant fixed-rate poller's # network jitter must not cost it the permanent +5s a slow_down # demands (§3.5) — the window bounds load, not guessing. interval = Hitch.configuration. window_opens = now - interval + [ 0.5, interval / 10.0 ].min claimed = where(id: record) .where("last_polled_at IS NULL OR last_polled_at <= ?", window_opens) .update_all(last_polled_at: now, updated_at: now) if claimed == 1 raise OAuthError.new("authorization_pending", "The authorization request is still pending") end raise OAuthError.new("slow_down", "Polling faster than the interval") end |
.find_pending_by_user_code(input) ⇒ Object
99 100 101 102 103 104 |
# File 'app/models/hitch/device_grant.rb', line 99 def self.find_pending_by_user_code(input) normalized = normalize_user_code(input) return nil unless normalized.length == USER_CODE_LENGTH pending.find_by(user_code_digest: Digest::SHA256.hexdigest(normalized)) end |
.mint!(client_id:, scopes:, resource_uri:, token_endpoint_auth_method: nil) ⇒ Object
Scopes are clamped here, at mint — every caller's, not just the endpoint's — so what a grant holds is always grantable and the exchange can honor it verbatim.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'app/models/hitch/device_grant.rb', line 48 def self.mint!(client_id:, scopes:, resource_uri:, token_endpoint_auth_method: nil) granted = Hitch.configuration.clamp_scopes(scopes) token_endpoint_auth_method ||= authentication_method_for(client_id) attempts = 0 begin # Both codes regenerate on retry, so whichever unique constraint # tripped, the next attempt is genuinely fresh. raw_device_code = SecureRandom.urlsafe_base64(32) raw_user_code = generate_user_code # requires_new: the retry below must survive a collision inside a # host's open transaction — on PostgreSQL a failed INSERT poisons # the enclosing transaction without a savepoint, turning the retry # into the very 500 it exists to prevent. record = transaction(requires_new: true) do create!( client_id: client_id, device_code_digest: Digest::SHA256.hexdigest(raw_device_code), user_code_digest: Digest::SHA256.hexdigest(raw_user_code), scopes: granted, resource_uri: resource_uri, token_endpoint_auth_method: token_endpoint_auth_method, expires_at: Hitch.configuration.device_code_lifetime_seconds.seconds.from_now ) end rescue ActiveRecord::RecordNotUnique # Forty-bit user codes will eventually land on a live pending one. # Retrying keeps the collision invisible; giving up would turn an # unauthenticated 500 into a collision oracle. attempts += 1 retry if attempts < 3 raise end record.raw_device_code = raw_device_code record.raw_user_code = raw_user_code record end |
.normalize_user_code(input) ⇒ Object
Canonical form: the eight alphabet characters. Strips the punctuation display adds, uppercases, and maps the look-alikes Crockford base32 deliberately treats as equal (§6.1 — the user's input is normalized, never rejected for a dash or a lowercase letter).
89 90 91 |
# File 'app/models/hitch/device_grant.rb', line 89 def self.normalize_user_code(input) input.to_s.upcase.gsub(/[^0-9A-Z]/, "").tr("OIL", "011") end |