Skip to content

PPT-2536: heal scope-less refresh chains + bridge legacy Doorkeeper refresh tokens - #10

Merged
camreeves merged 2 commits into
masterfrom
PPT-2536-refresh-hardening
Jul 28, 2026
Merged

PPT-2536: heal scope-less refresh chains + bridge legacy Doorkeeper refresh tokens#10
camreeves merged 2 commits into
masterfrom
PPT-2536-refresh-hardening

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

Problem

Weekend pre-UCLA testing (Steve) hit 403s on refreshed tokens on placeos-dev — despite PR #8's scope-across-refresh fix — and dev was reverted to Ruby auth on 2026-07-25.

Forensics

Dev's oauth_tokens table shows refresh grants still minting empty-scope access tokens after PR #8 was live (Jul 24: Workplace client; Jul 25: Backoffice — each at the same second as a refresh-rotation revocation row, sub intact). Two refresh classes were uncovered:

Class A — pre-fix auth.cr refresh tokens. Tokens minted before the scope-embedding fix carry user_id but no scope claim. Recovery yields "" → every API call 403s on rest-api's can_read → and each refresh re-embeds the empty scope, so the client is permanently stuck until a full re-login. This is what killed the weekend testing.

Class B — legacy Ruby (Doorkeeper) refresh tokens. Opaque strings, stored SHA256-hashed (hash_token_secrets) in oauth_access_tokens — 81 fresh rows since the revert. validate_code! requires Authly.jwt_decode to succeed, so every one of them is rejected → every mid-session client is forced through an interactive re-login at cutover. At UCLA scale that's the whole install base.

Fix

  • Heal (A): a refresh grant with a resource owner but no recoverable scope falls back to public — Ruby parity (default_scopes :public; every PlaceOS client's grant is public). The re-minted refresh token embeds the healed scope, permanently repairing the chain. Client-only grants keep "" — no privilege invented for machine tokens.
  • Bridge (B): LegacyRefresh — on JWT decode failure, look up the unrevoked Doorkeeper row by digest, require the redeeming client to own it, mint native tokens for its resource owner + scopes, revoke the row (Doorkeeper rotation semantics). The replacement refresh token is a native JWT — the chain migrates off Doorkeeper after one refresh. Missing table / DB errors degrade to "not a legacy token".
  • Guards: generate_id_token no longer assumes an auth code exists (refresh + openid scope would have 500'd); legacy revocation bypasses the JWT token manager (which decodes).

Specs (refresh_hardening_spec.cr)

Pre-fix-token heal → ["public"] + chain permanently repaired · no scope invented for ownerless grants · Doorkeeper redeem (user, scopes, aud/u claims, row revoked, chain migrates to JWT) · scope-less row heals to public · revoked row rejected · cross-client redemption rejected without side effects · unknown opaque token rejected · public client (SPA) refreshes without a client secret.

Red-check verified: neutering the heal fails exactly the Class A specs; neutering the bridge fails exactly the Doorkeeper specs. 22 examples green across the refresh battery + claims + PKCE.

🤖 Generated with Claude Code

camreeves and others added 2 commits July 28, 2026 10:14
… refresh tokens

Steve's pre-UCLA testing (2026-07-25) hit 403s on refreshed tokens even
with the scope-across-refresh fix in place, and dev was reverted to the
Ruby auth. Forensics on dev's oauth_tokens table found refresh grants
still minting empty-scope access tokens (Workplace + Backoffice clients),
exposing two uncovered refresh classes:

A. auth.cr refresh tokens minted BEFORE the scope-embedding fix carry
   `user_id` but no `scope` claim. Scope recovery found nothing -> "" ->
   every API call 403s on rest-api's can_read, and each refresh re-embeds
   the empty scope, so the client is stuck until a full re-login.
   Fix: when a refresh grant has a resource owner but no recoverable
   scope, fall back to `public` (Ruby parity: Doorkeeper default_scopes
   is :public and every PlaceOS client's grant is `public`). The
   re-minted refresh token embeds the healed scope, permanently
   repairing the chain. Client-only grants keep "" - no privilege is
   invented for machine tokens.

B. Refresh tokens issued by the legacy RUBY service are opaque Doorkeeper
   strings (stored SHA256-hashed in oauth_access_tokens via
   hash_token_secrets), not JWTs - validate_code! rejected them outright,
   forcing every mid-session client through an interactive re-login at
   cutover.
   Fix: LegacyRefresh bridge - on JWT decode failure, look up the
   unrevoked Doorkeeper row by digest, require the redeeming client to
   own it, mint native tokens for its resource owner + scopes, and
   revoke the row (Doorkeeper rotation semantics). The replacement
   refresh token is an auth.cr JWT, so the chain migrates off Doorkeeper
   after one refresh. Also guards generate_id_token (refresh has no auth
   code) and routes legacy revocation off the JWT token manager.

Specs (refresh_hardening_spec): pre-fix-token heal + permanent chain
repair, no-scope-invention for ownerless grants, Doorkeeper redeem
(user/scopes/revocation/chain migration), scope-less row heal, revoked
row rejected, cross-client redemption rejected without side effects,
unknown opaque token rejected, and public-client (SPA) refresh without a
client secret. Red-check verified: neutering either fix fails exactly
the corresponding specs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A row-returning `db.query*` with varargs-splatted arguments crashes the
Crystal compiler ("BUG: ... Tuple#each MacroFor ... should have been
expanded", tuple.cr:367). Reach is toolchain dependent: on CI's compiler
only the --release/--static image build failed, so the first CI run for
this branch went green on tests and red on Build; on Crystal 1.16.3 the
spec build ICEs as well. `db.exec` with splatted args is unaffected.

Bind the parameter via the `args:` Array overload instead, and read the
columns off the ResultSet rather than materialising a nilable tuple.
No behavioural change: the query still returns at most one row (LIMIT 1)
and nil when there is no unrevoked match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves
camreeves merged commit df41aa3 into master Jul 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant