Class: Hitch::AccessToken
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Hitch::AccessToken
- Defined in:
- app/models/hitch/access_token.rb
Overview
OAuth 2.1 access token + authorization code record. Lifecycle:
pending — code minted, awaiting POST /oauth/token exchange
↓ exchange_authorization_code! — all bindings verified, then atomically consumed
active — token_digest set; usable until expires_at or revoked_at
↓ revoke! / expiry
inactive
Polymorphic principal: the host controller supplies the signed-in record, and each row records which model type owns the token via principal_type + principal_id (standard Rails polymorphic).
RFC 8707: resource_uri is the audience this token was issued for. The MCP server validates at token use time that the request's resource matches token.resource_uri.
Defined Under Namespace
Classes: OAuthError
Constant Summary collapse
- MAX_LIFETIME_SECONDS =
Ten years. Past it a Postgres timestamp overflows, and a SQLite five-digit year sorts before today's — which would hand an operator a token that silently never resolves.
3650 * 86_400
Instance Attribute Summary collapse
-
#raw_authorization_code ⇒ Object
Raw authorization code is returned to the client via the OAuth redirect once at issuance; the DB only ever holds the SHA256 digest.
Class Method Summary collapse
-
.cleanup_expired!(revoked_retention_days: 30) ⇒ Object
Operational cleanup.
- .create_authorization!(principal:, client_id:, client_name:, code_challenge:, code_challenge_method:, scopes: "mcp", redirect_uri: nil, resource_uri: nil) ⇒ Object
- .exchange_authorization_code!(raw_code:, code_verifier:, client_id:, resource_uri:, redirect_uri: nil) ⇒ Object
-
.exchange_refresh_token!(raw_refresh_token:, client_id:, resource_uri:, scopes: nil) ⇒ Object
RFC 6749 §6 / OAuth 2.1 §4.3.
- .find_by_refresh_token(raw_refresh_token) ⇒ Object
- .find_by_token(raw_token) ⇒ Object
-
.issue!(principal:, client_id:, client_name: nil, scopes: nil, expires_in: nil) ⇒ Object
Mints a usable access token outside the browser flow, for a headless agent or a cron job that cannot complete a consent redirect.
-
.mint_through_exchange!(principal:, client_id:, client_name:, resource_uri:, scopes:) ⇒ Object
The one non-browser token-mint path, shared by issue! and the device grant's consumption: generate and spend a synthetic PKCE pair through the real authorization-code exchange, so the row that lands is indistinguishable from a browser-issued one and the same code path is proven by every other test in the suite.
-
.revoke_family!(family_id) ⇒ Object
A consumed refresh token presented again by its own client, past the grace window, is a replay of a credential its owner already spent.
Instance Method Summary collapse
- #accessible? ⇒ Boolean
- #expired? ⇒ Boolean
-
#family_expired? ⇒ Boolean
The ceiling the whole lineage descends from.
-
#honest_retry?(now = Time.current) ⇒ Boolean
A consumed token presented again inside the grace window: the client asking for a reply it never received.
-
#narrowed_scopes(requested) ⇒ Object
RFC 6749 §6: a refresh may narrow the granted scopes and may never widen them.
- #revoke! ⇒ Object
- #revoked? ⇒ Boolean
-
#scope?(scope) ⇒ Boolean
Space-delimited scope check per OAuth 2.1 §3.3.
-
#valid_for_resource?(requested_resource_uri) ⇒ Boolean
RFC 8707 audience validation.
Instance Attribute Details
#raw_authorization_code ⇒ Object
Raw authorization code is returned to the client via the OAuth redirect once at issuance; the DB only ever holds the SHA256 digest. This attr_accessor lets create_authorization! surface the raw code to the controller without persisting it.
43 44 45 |
# File 'app/models/hitch/access_token.rb', line 43 def @raw_authorization_code end |
Class Method Details
.cleanup_expired!(revoked_retention_days: 30) ⇒ Object
Operational cleanup. Three classes of rows accumulate that nothing ever reads again:
1) Pending auth codes whose code_expires_at < now — orphaned by
OAuth flows the client abandoned (closed the browser, etc.).
No token was issued; the row is unreachable.
2) Revoked tokens older than `revoked_retention_days`. The
record is kept for a window so audit logs/billing/etc. can
look up the principal_id; beyond that, drop.
3) Expired tokens (expires_at < now) older than
`revoked_retention_days` — same audit-window argument.
Class 3 has two floors, because expires_at is the ACCESS token's
clock — an hour — and says nothing about the refresh token beside it.
- A row still holding a usable refresh token is not dead, however
long ago its access token lapsed. Collecting it would delete a
credential the client is about to present.
- A consumed row is the evidence reuse detection reads. On the
schedule alone it went while its family was still being refreshed,
and a replayed stolen token then found nothing and degraded to an
ordinary invalid_grant — the alarm gone, silently, with no test
failing. Evidence is held for the same audit window as everything
else here.
Both floors defer collection rather than cancelling it: once the refresh token has expired and the evidence is older than the window, the row goes. That bounds a long-lived family to one window's worth of rows however long it keeps rotating. The residual is stated in the README — a replay of a token consumed longer ago than the window is refused, but no longer raises the alarm.
Returns the number of rows deleted. Idempotent.
Hosts schedule this via whatever background job framework they use (Solid Queue / GoodJob / Sidekiq / cron+rake — gem-agnostic). Example:
class CleanupMCPTokensJob < ApplicationJob
def perform
Hitch::AccessToken.cleanup_expired!
end
end
403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'app/models/hitch/access_token.rb', line 403 def self.cleanup_expired!(revoked_retention_days: 30) cutoff = revoked_retention_days.days.ago count = 0 count += where(token_digest: nil).where("code_expires_at < ?", Time.current).delete_all count += where.not(revoked_at: nil).where("revoked_at < ?", cutoff).delete_all count += where.not(expires_at: nil) .where("expires_at < ?", cutoff) .where("refresh_expires_at IS NULL OR refresh_expires_at < ?", Time.current) .where("refresh_consumed_at IS NULL OR refresh_consumed_at < ?", cutoff) .delete_all count end |
.create_authorization!(principal:, client_id:, client_name:, code_challenge:, code_challenge_method:, scopes: "mcp", redirect_uri: nil, resource_uri: nil) ⇒ Object
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'app/models/hitch/access_token.rb', line 103 def self.(principal:, client_id:, client_name:, code_challenge:, code_challenge_method:, scopes: "mcp", redirect_uri: nil, resource_uri: nil) raw_code = SecureRandom.urlsafe_base64(32) record = create!( principal: principal, client_id: client_id, client_name: client_name, redirect_uri: redirect_uri, resource_uri: resource_uri, authorization_code_digest: Digest::SHA256.hexdigest(raw_code), code_challenge: code_challenge, code_challenge_method: code_challenge_method, code_expires_at: Hitch.configuration..seconds.from_now, scopes: scopes ) record. = raw_code record end |
.exchange_authorization_code!(raw_code:, code_verifier:, client_id:, resource_uri:, redirect_uri: nil) ⇒ Object
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'app/models/hitch/access_token.rb', line 205 def self.(raw_code:, code_verifier:, client_id:, resource_uri:, redirect_uri: nil) unless Hitch::Pkce.valid_verifier?(code_verifier) raise OAuthError.new("invalid_grant", "PKCE verifier is malformed") end code_digest = Digest::SHA256.hexdigest(raw_code.to_s) record = pending.find_by(authorization_code_digest: code_digest) return nil unless record unless record.client_id == client_id raise OAuthError.new("invalid_grant", "Authorization code was not issued to this client") end # RFC 6749 §4.1.3: a redirect_uri sent to the token endpoint MUST be # identical to the one the code was issued to. Omitting it is legal # (OAuth 2.1 drops the parameter; PKCE carries the binding). if redirect_uri.present? && record.redirect_uri != redirect_uri raise OAuthError.new("invalid_grant", "redirect_uri does not match the authorization request") end unless record.resource_uri == resource_uri raise OAuthError.new("invalid_target", "resource does not match the authorized resource") end record.send(:verify_pkce!, code_verifier) raw_token = SecureRandom.urlsafe_base64(32) now = Time.current refresh = mint_refresh_attributes(now: now, family_id: nil, family_expires_at: nil) updated = where( id: record.id, authorization_code_digest: code_digest, token_digest: nil ).where("code_expires_at > ?", now).update_all( { token_digest: Digest::SHA256.hexdigest(raw_token), authorization_code_digest: nil, code_expires_at: nil, expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds, updated_at: now }.merge(refresh.fetch(:columns)) ) return nil unless updated == 1 { raw_token: raw_token, raw_refresh_token: refresh[:raw_refresh_token], scope: record.scopes } end |
.exchange_refresh_token!(raw_refresh_token:, client_id:, resource_uri:, scopes: nil) ⇒ Object
RFC 6749 §6 / OAuth 2.1 §4.3. Consumes the presented refresh token and issues a successor pair, or refuses.
Rotation is the same conditional state transition the authorization code already uses: consumption is the guard, so two concurrent refreshes race on one UPDATE and exactly one wins. The loser did not steal anything — it lands in the replay path below, where a just-consumed token is an honest retry.
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'app/models/hitch/access_token.rb', line 258 def self.exchange_refresh_token!(raw_refresh_token:, client_id:, resource_uri:, scopes: nil) unless Hitch.configuration.refresh_tokens_enabled raise OAuthError.new("unsupported_grant_type", "Refresh tokens are not enabled") end digest = Digest::SHA256.hexdigest(raw_refresh_token.to_s) record = find_by(refresh_token_digest: digest) return nil unless record # Before consumed-state, deliberately. A different client presenting # this token is a mismatched grant, not a theft alarm — revoking a # family on it would let anyone who learns a token log its owner out. unless record.client_id == client_id raise OAuthError.new("invalid_grant", "Refresh token was not issued to this client") end unless record.resource_uri == resource_uri raise OAuthError.new("invalid_target", "resource does not match the authorized resource") end return nil if record.revoked? || record.family_expired? now = Time.current # Reuse detection runs before anything the request can get wrong, so a # replay cannot dodge the alarm by also asking for a bad scope. if record.refresh_consumed_at unless record.honest_retry?(now) revoke_family!(record.family_id) raise OAuthError.new("invalid_grant", "Refresh token has already been used") end # Inside the window a repeat presentation is the client asking again # for a reply it never got: a fresh pair off the same parent, not a # revoked family. return record.send(:issue_successor!, granted: record.narrowed_scopes(scopes), now: now) end return nil if record.refresh_expires_at.nil? || record.refresh_expires_at < now granted = record.narrowed_scopes(scopes) consumed = where(id: record.id, refresh_consumed_at: nil) .update_all(refresh_consumed_at: now, updated_at: now) # Lost the race to a concurrent refresh microseconds ago. That is the # honest-retry case arriving by a different door, not a replay. return nil unless consumed == 1 record.send(:issue_successor!, granted: granted, now: now) end |
.find_by_refresh_token(raw_refresh_token) ⇒ Object
316 317 318 319 320 |
# File 'app/models/hitch/access_token.rb', line 316 def self.find_by_refresh_token(raw_refresh_token) return nil if raw_refresh_token.blank? find_by(refresh_token_digest: Digest::SHA256.hexdigest(raw_refresh_token)) end |
.find_by_token(raw_token) ⇒ Object
354 355 356 357 358 |
# File 'app/models/hitch/access_token.rb', line 354 def self.find_by_token(raw_token) return nil if raw_token.blank? active.find_by(token_digest: Digest::SHA256.hexdigest(raw_token)) end |
.issue!(principal:, client_id:, client_name: nil, scopes: nil, expires_in: nil) ⇒ Object
Mints a usable access token outside the browser flow, for a headless agent or a cron job that cannot complete a consent redirect. The operator at a console with database access is both the resource owner and the client, so there is no third party for a consent screen to protect anyone from. The token is returned once and only its digest is stored, exactly as in the OAuth flow.
127 128 129 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 |
# File 'app/models/hitch/access_token.rb', line 127 def self.issue!(principal:, client_id:, client_name: nil, scopes: nil, expires_in: nil) # uniq like the browser flow's `asked & supported`, which cannot repeat # a scope past the persisted scope-set boundary. granted = Array(scopes).map(&:to_s).uniq.presence || [ Hitch.configuration.supported_scopes.first ] unsupported = granted - Hitch.configuration.supported_scopes unless unsupported.empty? raise ArgumentError, "scopes are not present in Hitch.configuration.supported_scopes: #{unsupported.join(', ')}" end # Seconds, as a number or a Duration. A String is a caller mistake, not # something to guess at: the rake task parses ENV before it gets here, # and Integer("0700") would quietly mean 448. numeric = expires_in.is_a?(Numeric) || expires_in.is_a?(ActiveSupport::Duration) seconds = expires_in.to_i if numeric && expires_in.to_f.finite? unless expires_in.nil? || (seconds&.positive? && seconds <= MAX_LIFETIME_SECONDS) raise ArgumentError, "expires_in must be a positive number of seconds, at most #{MAX_LIFETIME_SECONDS}" end # requires_new, not a plain transaction: a bare `transaction` JOINS a # caller's open one, so a host calling issue! inside its own # transaction and rescuing kept the very row this exists to roll back. transaction(requires_new: true) do record, result = mint_through_exchange!( principal: principal, client_id: client_id, client_name: client_name.presence || client_id, resource_uri: Hitch.configuration.resource_uri, scopes: granted.join(" ") ) # The exchange dates every token by the configured lifetime, which is # sized for a browser session. A headless agent asks for its own. record.reload.update!(expires_at: seconds.seconds.from_now) if seconds result.fetch(:raw_token) end end |
.mint_through_exchange!(principal:, client_id:, client_name:, resource_uri:, scopes:) ⇒ Object
The one non-browser token-mint path, shared by issue! and the device grant's consumption: generate and spend a synthetic PKCE pair through the real authorization-code exchange, so the row that lands is indistinguishable from a browser-issued one and the same code path is proven by every other test in the suite. Atomic on its own — a savepoint under any caller's transaction — so no half-minted row can outlive a failed exchange whoever calls it.
Public so DeviceGrant can call its real seam, not part of the public API (docs/public_api is the contract) — hosts mint through issue!.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'app/models/hitch/access_token.rb', line 175 def self.mint_through_exchange!(principal:, client_id:, client_name:, resource_uri:, scopes:) unless client_id.is_a?(String) && !client_id.empty? # The endpoint refuses a token with a blank client_id, so issuing one # would hand back a credential that can never work. raise ArgumentError, "client_id must be a nonempty String" end verifier = SecureRandom.urlsafe_base64(64) transaction(requires_new: true) do record = ( principal: principal, client_id: client_id, client_name: client_name, code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false), code_challenge_method: "S256", resource_uri: resource_uri, scopes: scopes ) result = ( raw_code: record., code_verifier: verifier, client_id: client_id, resource_uri: resource_uri ) raise "Hitch could not issue an access token" if result.nil? [ record, result ] end end |
.revoke_family!(family_id) ⇒ Object
A consumed refresh token presented again by its own client, past the grace window, is a replay of a credential its owner already spent. The family is the blast radius: every row descended from that one authorization, revoked in a single statement.
308 309 310 311 312 313 314 |
# File 'app/models/hitch/access_token.rb', line 308 def self.revoke_family!(family_id) return 0 if family_id.blank? where(family_id: family_id, revoked_at: nil).update_all( revoked_at: Time.current, updated_at: Time.current ) end |
Instance Method Details
#accessible? ⇒ Boolean
60 61 62 |
# File 'app/models/hitch/access_token.rb', line 60 def accessible? token_digest.present? && !expired? && !revoked? end |
#expired? ⇒ Boolean
52 53 54 |
# File 'app/models/hitch/access_token.rb', line 52 def expired? expires_at.present? && expires_at < Time.current end |
#family_expired? ⇒ Boolean
The ceiling the whole lineage descends from. Never extended by rotation, so a chain someone is quietly refreshing forever still stops.
66 67 68 |
# File 'app/models/hitch/access_token.rb', line 66 def family_expired? family_expires_at.present? && family_expires_at < Time.current end |
#honest_retry?(now = Time.current) ⇒ Boolean
A consumed token presented again inside the grace window: the client asking for a reply it never received. Outside it, the same request is a replay of a spent credential and kills the family.
73 74 75 76 77 |
# File 'app/models/hitch/access_token.rb', line 73 def honest_retry?(now = Time.current) return false if refresh_consumed_at.nil? refresh_consumed_at + Hitch.configuration.refresh_token_replay_grace_seconds.seconds >= now end |
#narrowed_scopes(requested) ⇒ Object
RFC 6749 §6: a refresh may narrow the granted scopes and may never widen them. Asking for nothing keeps what was granted.
81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'app/models/hitch/access_token.rb', line 81 def narrowed_scopes(requested) granted = scopes.to_s.split(/\s+/) asked = Array(requested).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?).uniq return scopes.to_s if asked.empty? widened = asked - granted unless widened.empty? raise OAuthError.new("invalid_scope", "Refresh may narrow scopes but not widen them") end asked.join(" ") end |
#revoke! ⇒ Object
350 351 352 |
# File 'app/models/hitch/access_token.rb', line 350 def revoke! update!(revoked_at: Time.current) end |
#revoked? ⇒ Boolean
56 57 58 |
# File 'app/models/hitch/access_token.rb', line 56 def revoked? revoked_at.present? end |
#scope?(scope) ⇒ Boolean
Space-delimited scope check per OAuth 2.1 §3.3. Hosts call this to
gate operations behind a specific scope the client requested at
consent — e.g. token.scope?("write") before mutating ops.
97 98 99 100 101 |
# File 'app/models/hitch/access_token.rb', line 97 def scope?(scope) return false if scopes.blank? || scope.blank? scopes.split(/\s+/).include?(scope.to_s) end |
#valid_for_resource?(requested_resource_uri) ⇒ Boolean
RFC 8707 audience validation. Returns false if the token was issued for a different resource than the one currently asking. Per the 2026-07-28 MCP authorization spec: "MCP servers MUST validate that access tokens were issued specifically for them as the intended audience." Spec URL: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
422 423 424 425 426 427 428 429 |
# File 'app/models/hitch/access_token.rb', line 422 def valid_for_resource?(requested_resource_uri) allow_loopback = Rails.env.local? stored = ResourceUri.canonicalize!(resource_uri, allow_loopback_http: allow_loopback) requested = ResourceUri.canonicalize!(requested_resource_uri, allow_loopback_http: allow_loopback) stored == requested rescue ResourceUri::Invalid false end |