Regenerate the user_code when a freshly generated one collides with an existing
code (up to USER_CODE_MAX_ATTEMPTS), so a client never gets a uniqueness error
just because two codes happened to match; the DB unique index stays the final
guard against a concurrent-insert race.
Replace the model test that asserted Rails' uniqueness validator message
("has already been taken") — per CLAUDE.md we don't test framework behavior —
with one that exercises the app-specific property: generation retries past a
collision and yields a unique code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
More device-flow security-review fixes.
Replay-revocation on device_code redemption (was: destroy on use):
- Redemption now marks the code redeemed_at and links the issued access/refresh
tokens back to it (new oidc_device_code_id FKs, on_delete: :nullify) instead of
destroying the row. A replayed redeemed code is detected as reuse — revoking
every descended token and returning a distinguishable "already been used"
invalid_grant rather than the generic "Invalid device_code" — mirroring the
authorization-code reuse semantics (RFC 6749 §4.1.2). The device_code FK is
carried forward across refresh rotation so revocation reaches the whole chain.
Redeemed codes are reaped by the existing expiry cleanup sweep.
user_code CSPRNG (RFC 8628 §6.1):
- The 8-char user_code is a credential (type it + Approve mints tokens), so draw
it from SecureRandom instead of Ruby's global Mersenne Twister (Array#sample).
Rate-limit the /device verification endpoint (RFC 8628 §5.1):
- Add the app's standard 10/min limit on show + verify so a signed-in user can't
brute-force the short code space to deny or hijack a pending authorization.
Also carried in this commit (other agent's working-tree change):
- Give each OidcController rate_limit a distinct name: so frequent device polling
on the token endpoint no longer shares one counter with (and 429s) unrelated
token/refresh/revoke/introspect calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
Two more fixes from the device-flow security review:
slow_down interval grew without bound (OidcController):
- Each too-fast poll bumped the persisted interval by 5s with no ceiling, so a
client polling slightly fast — or an attacker spamming a known device_code —
could balloon it (5→10→15→…) past the 10-minute expiry and starve a legitimate
client of its token. Clamp the bump to OidcDeviceCode::MAX_INTERVAL (30s); a
client polling at the advertised interval is never throttled.
Expired device codes accumulated forever (OidcTokenCleanupJob):
- Anonymous callers can create device codes via /oauth/device_authorization, and
expired/denied/abandoned rows were never reaped. Sweep rows past expiry (with a
1-hour grace to stay clear of in-flight redemption); redeemed codes are already
destroyed at token issuance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
Adds three OAuth surfaces to the OIDC provider so CLIs, terminal agents, and
MCP connectors can authenticate as real users instead of using static API keys.
Device Authorization Grant (RFC 8628):
- OidcDeviceCode model (HMAC device_code, short plaintext user_code, nullable
user until approval, slow_down polling state), mirroring OidcAuthorizationCode
- POST /oauth/device_authorization issues the code pair + verification URIs
- device_code grant on /oauth/token returns authorization_pending / slow_down /
access_denied / expired_token, then the standard token triple; single-use
- Authenticated /device approval page, gated by Application#user_allowed?
Token Introspection (RFC 7662):
- POST /oauth/introspect: confidential-caller-authenticated; returns active,
scope, and the user's groups so resource servers can authorize on membership
Dynamic Client Registration (RFC 7591):
- POST /oauth/register creates public/confidential clients (PKCE required)
- Runtime toggle via a new Setting store + admin switch on the Applications page;
off by default, env var CLINCH_DCR_ENABLED as bootstrap fallback
- New clients are default-deny (no allowed_groups) until an admin grants access
- RFC 8414 metadata alias at /.well-known/oauth-authorization-server;
registration_endpoint advertised only while the window is open
Discovery advertises all three grants/endpoints. Seeds add a clinch-cli public
client and a c2a2-introspection confidential client. ADRs in docs/decisions
record the opaque-vs-JWT, device-flow, and DCR-security decisions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq