feat(auth): OAuth device grant backend + consent page (AI-050a)#344
Merged
Conversation
RFC 8628 Device Authorization Grant so the headless MCP CLI can obtain a per-user TextStack JWT (replaces the AI-048a static shared-token interim). - DeviceAuthorization entity + migration: device_code stored HASHED (SHA256-hex, unique index), short user_code (Crockford base32, no ambiguous chars), nullable UserId until approval, status/expiry, FK OnDelete(SetNull). - 3 endpoints under /auth/device: code (mint), token (CLI polls, RFC 8628 errors authorization_pending/expired_token/access_denied), approve (authed web user consents). Reuses the SAME GenerateAccessToken + CreateRefreshTokenAsync as login — full per-user JWT, single-use, 10-min TTL, rate-limited (code 5/min, token 12/min, approve 10/min). TimeProvider injected for testable expiry. - Consent page /device (outside lang routes): logged-in user enters/ confirms the user_code, sees an explicit 'acts as YOU' anti-phishing warning + scope, Approve/Deny. Security (adversarial review, 0 P1): device_code only stored hashed + never logged; redeem only ever mints for the consenting approver; approve requires an authed session; unknown device_code → expired_token (no enumeration oracle). QA P2 fixes: approve/deny scoped to the live pending row (user_code recurs across history — stale terminal rows now invisible); Deny button wired to /auth/device/deny. DeviceFlowTokenProvider (CLI side) + token cache = AI-050b. 526 unit green (20 DeviceCodes helper tests) + DB-gated integration tests for the flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AI-050a — OAuth 2.0 Device Authorization Grant (RFC 8628), backend + consent page (Phase 8)
So the headless MCP CLI (Claude Desktop spawns it over stdio — no browser) can obtain a per-user TextStack JWT, replacing the AI-048a interim static shared-token. This is the backend + web consent gate; the CLI-side
DeviceFlowTokenProvider+ token cache is AI-050b.Flow
POST /auth/device/code→{ device_code, user_code, verification_uri, verification_uri_complete, expires_in:600, interval:5 }./deviceand enter theuser_code; meanwhile pollsPOST /auth/device/token(RFC 8628 errorsauthorization_pending/expired_token/access_denied).POST /auth/device/approve(authed) binds the device flow to their identity./auth/login).Backend
DeviceAuthorizationentity + migration (verified on Postgres):device_codestored hashed (SHA256-hex, unique index) — never plaintext; shortuser_code(Crockford base32, no ambiguous chars); nullableUserIduntil approval; status/expiry; FKOnDelete(SetNull)./auth/device(+ optionaldeny). Token issuance reuses the exactGenerateAccessToken+CreateRefreshTokenAsyncas login — full per-user JWT, single-use, 10-min TTL. Rate-limited (code5/min,token12/min,approve10/min per IP).TimeProviderinjected for testable expiry.App:BaseUrl(same key as transactional email).Consent page
/device(the only human gate)Mounted outside the
/:langroutes. Logged-in user enters/confirms theuser_code(prefilled fromverification_uri_complete), sees their identity + scope + an explicit "only approve if YOU started this from your own MCP client" anti-phishing warning, then Approve/Deny.Security (adversarial review — 0 P1)
device_codeonly ever stored hashed, never logged (grepped the whole flow); lookup hashes the incoming code.approverequires an authed session (401 anonymous);code/tokenare correctly public (the CLI has no session yet) and leak nothing pre-approval.device_code→expired_token(no "not found" enumeration oracle). Single-use + lazy-expiry enforced on approve and redeem.user_codeindex lets codes recur across history — stale terminal rows are now invisible, preventing a fresh flow from mis-resolving to an old row); the Deny button now actually calls/auth/device/deny.mcpaudience claim yet); classic device-flow phishing is mitigated by the consent wording + short TTL.Tests — 526 unit (20 hermetic
DeviceCodeshelper tests: normalize round-trips, hashing distinctness, no-regroup-miss) + DB-gated integration (RFC fields, pending/approved/expired, auth-required-approve, hashed storage, single-use, fresh-vs-stale-row scoping).Verify
dotnet test tests/TextStack.UnitTests→ 526 pass ·dotnet build/dotnet format --verify-no-changes→ cleanpnpm -C apps/web exec tsc --noEmit+build→ clean🤖 Generated with Claude Code