Class: Hitch::AccessToken

Inherits:
ApplicationRecord show all
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

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#raw_authorization_codeObject

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
  @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.create_authorization!(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.authorization_code_lifetime_seconds.seconds.from_now,
    scopes: scopes
  )
  record.raw_authorization_code = 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.exchange_authorization_code!(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 = create_authorization!(
      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 = exchange_authorization_code!(
      raw_code: record.raw_authorization_code,
      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

Returns:

  • (Boolean)


60
61
62
# File 'app/models/hitch/access_token.rb', line 60

def accessible?
  token_digest.present? && !expired? && !revoked?
end

#expired?Boolean

Returns:

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

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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