Skip to content

feat(auth): OAuth device grant backend + consent page (AI-050a)#344

Merged
mrviduus merged 1 commit into
mainfrom
ai-050a-device-grant
Jun 16, 2026
Merged

feat(auth): OAuth device grant backend + consent page (AI-050a)#344
mrviduus merged 1 commit into
mainfrom
ai-050a-device-grant

Conversation

@mrviduus

Copy link
Copy Markdown
Owner

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

  1. CLI → POST /auth/device/code{ device_code, user_code, verification_uri, verification_uri_complete, expires_in:600, interval:5 }.
  2. CLI tells the user (on stderr) to visit /device and enter the user_code; meanwhile polls POST /auth/device/token (RFC 8628 errors authorization_pending / expired_token / access_denied).
  3. The logged-in user approves on the consent pagePOST /auth/device/approve (authed) binds the device flow to their identity.
  4. The next poll returns a normal TextStack JWT (access + refresh, same shape as /auth/login).

Backend

  • DeviceAuthorization entity + migration (verified on Postgres): device_code stored hashed (SHA256-hex, unique index) — never plaintext; short user_code (Crockford base32, no ambiguous chars); nullable UserId until approval; status/expiry; FK OnDelete(SetNull).
  • 3 endpoints under /auth/device (+ optional deny). Token issuance reuses the exact GenerateAccessToken + CreateRefreshTokenAsync as login — full per-user JWT, single-use, 10-min TTL. Rate-limited (code 5/min, token 12/min, approve 10/min per IP). TimeProvider injected for testable expiry.
  • Public base from App:BaseUrl (same key as transactional email).

Consent page /device (the only human gate)

Mounted outside the /:lang routes. Logged-in user enters/confirms the user_code (prefilled from verification_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_code only ever stored hashed, never logged (grepped the whole flow); lookup hashes the incoming code.
  • Redeem only ever mints for the consenting approver — no path to a token for a different user; no admin/extra claims.
  • approve requires an authed session (401 anonymous); code/token are correctly public (the CLI has no session yet) and leak nothing pre-approval.
  • Unknown device_codeexpired_token (no "not found" enumeration oracle). Single-use + lazy-expiry enforced on approve and redeem.
  • QA P2 fixes: approve/deny scoped to the live pending row (the filtered user_code index 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.
  • Residual (documented, accepted for a single-user power tool): the CLI gets a full-scope user JWT (no mcp audience claim yet); classic device-flow phishing is mitigated by the consent wording + short TTL.

Tests — 526 unit (20 hermetic DeviceCodes helper 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 → clean
  • pnpm -C apps/web exec tsc --noEmit + build → clean

🤖 Generated with Claude Code

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>
@mrviduus mrviduus merged commit b06e561 into main Jun 16, 2026
5 checks passed
@mrviduus mrviduus deleted the ai-050a-device-grant branch June 16, 2026 16:38
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