Skip to content

feat(auth): QR device-authorization sign-in for shared control-room computer (ADR 0008)#1495

Open
jakebromberg wants to merge 4 commits into
mainfrom
feature/qr-device-authorization
Open

feat(auth): QR device-authorization sign-in for shared control-room computer (ADR 0008)#1495
jakebromberg wants to merge 4 commits into
mainfrom
feature/qr-device-authorization

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

Summary

  • Registers better-auth's device-authorization plugin (RFC 8628) so the iOS app can authorize a 12h browser session at dj.wxyc.org without typing a password on the shared control-room keyboard.
  • Adds migration 0106 + deviceCode table; gates /device/approve to roles ≥ dj; clamps device-auth sessions to 12h via an after-hook (password sign-in unaffected); rate-limits /device/code + /device/approve + /device/deny (intentionally NOT /device/token — the plugin enforces pollingInterval itself).
  • Updates ADR 0008 to match the plugin's actual route names (/device/approve + /device/deny; the ADR's informal /device/verify doesn't exist).

Closes #1494.

Surface

Endpoint Auth Rate-limit Notes
POST /auth/device/code none 10/15min/IP Anonymous; brute-force on user_code namespace.
POST /auth/device/token none none Plugin enforces pollingInterval server-side via lastPolledAtslow_down JSON. An HTTP 429 here would shadow it.
GET /auth/device?user_code=… session none Plugin's pre-claim hook.
POST /auth/device/approve session, role ≥ dj 10/15min/IP Role gate in hooks.before rejects member with access_denied before the row flips.
POST /auth/device/deny session 10/15min/IP Plugin's own userId match check is sufficient defense; no role gate.

Where the hooks live

shared/authentication/src/device-authorization.ts (new) holds two pure helpers — applyDeviceApproveRoleGate(userId, selectMemberRole) and applyDeviceTokenSessionTtl(token, body, now, updateSessionExpiry) — that take their DB capability as a thunk so the unit tests don't need to stand up createAuthMiddleware. auth.definition.ts's middleware wrappers read ctx.context.session.user.id / ctx.context.newSession.session.token / ctx.context.returned and pass them through.

The 12h TTL is a hardcoded DEVICE_SESSION_TTL_MS constant — product decision, not configuration. Ops can drop it in an incident by editing the constant.

Out of scope (called out in #1494)

  • dj-site browser-side QR rendering + polling loop — WXYC/dj-site.
  • iOS-side scanner + LAContext.evaluatePolicyWXYC/wxyc-dj-tool-ios.
  • OpenAPI surface for the three new endpoints — separate WXYC/wxyc-shared PR against api.yaml.

Test plan

  • npm run typecheck — clean across all workspaces.
  • npm run lint — no errors on touched files (only pre-existing warnings elsewhere).
  • npm run format:check — clean.
  • node scripts/validate-migrations.mjs — 104 entries, 0 warnings.
  • npm run test:unit -- --testPathPatterns=device-authorization — 10/10 (4 role-gate cases × dj / musicDirector / stationManager / member / no-membership / non-WXYCRole; 4 session-TTL cases for the constant, body mutation, target token, and expiry math).
  • npm run test:unit -- --testPathPatterns="authentication|auth\\.middleware" — 119/119, no regressions in adjacent auth suites.
  • npm run ci:testmock — Docker integration suite (covers tests/integration/device-authorization.spec.js: happy path with expires_in ≈ 43200, member-denied path with code remaining pending, deny path with subsequent access_denied).
  • Manual smoke against the dev box once dj-site has a browser-side QR renderer.

@github-actions

Copy link
Copy Markdown

Schema constraint shape report

data-shape report errored (exit 0): node:internal/modules/runmain:107 triggerUncaughtException( ^ Error ERRMODULENOTFOUND: Cannot find package 'postgres' imported from /home/runner/work/Backend-Service/Backend-Service/scripts/schema-shape-report.mjs Did you mean to import "postgres/cjs/src/index.js"? at Object.getPackageJ; manual check required

@jakebromberg

Copy link
Copy Markdown
Member Author

Followup for the wxyc-shared OpenAPI mirror: WXYC/wxyc-shared#195.

…om computer (ADR 0008)

Stand up better-auth's `device-authorization` plugin (RFC 8628) so the iOS app can authorize a 12h browser session at dj.wxyc.org without typing a password on the shared keyboard. Browser POSTs /auth/device/code → renders the QR + plaintext user_code → polls /auth/device/token. The DJ scans, the iOS app POSTs /auth/device/approve carrying its session, the browser's next poll returns the session.

Two hooks live in shared/authentication/src/device-authorization.ts so they're directly unit-testable: `applyDeviceApproveRoleGate` rejects `member`-role (or no-membership) approvers with `access_denied` before the plugin flips the device-code row, and `applyDeviceTokenSessionTtl` clamps the just-created session's expiresAt and rewrites the response body's `expires_in` to 12h. /device/deny stays ungated — the plugin's userId match already prevents cross-user denial; the role gate would duplicate logic without security gain.

/auth/device/code, /auth/device/approve, /auth/device/deny ride the existing authMutationRateLimit (10/15min per-IP); /auth/device/token is intentionally excluded so it doesn't shadow the plugin's RFC 8628 slow_down JSON.

Migration 0106 adds the `auth_device_code` table verbatim from the plugin's schema contract — id PK, device_code/user_code unique, user_id nullable FK to auth_user with ON DELETE cascade. Verification URI is https://dj.wxyc.org/device-auth so a universal-link fallback stays open later; iOS reads user_code directly from the QR payload.
…ream zod parse doesn't throw

`deviceAuthorizationOptionsSchema` declares the `schema` field as
`z.custom(() => true)` with NO `.optional()` (see
node_modules/better-auth/dist/plugins/device-authorization/index.mjs:28).
At TypeScript level the field types as `unknown` so omitting it
compiles; at runtime `parse()` throws a `ZodError: expected nonoptional,
received undefined` and the auth-service crashes at module load. Local
typecheck and unit tests miss it because the unit tests mock
`better-auth/api` (they never instantiate the real plugin) and tsc
honors the looser type.

Pass `schema: {}` so `mergeSchema(schema, options?.schema)` folds in
the plugin's default field map verbatim. Verified by booting the
built dist locally — module loads clean instead of crashing on the
ZodError that took out CI Integration-Tests.
… before approve/deny in integration test

Two coupled bugs the unit suite couldn't see; both surfaced when CI's Integration-Tests run against the real Docker stack.

1. The before-hook read `ctx.context.session?.user` but the session is NOT pre-populated on /device/approve — the plugin's handler resolves it itself via `getSessionFromCtx` (see node_modules/better-auth/dist/plugins/device-authorization/routes.mjs deviceApprove). My early-return on missing session no-op'd the role gate every time, so a member's request reached the plugin handler and got 400 device_code_not_claimed instead of my intended 403 access_denied. Resolve the session inside the hook with `getSessionFromCtx(ctx)` so the role lookup runs against the same session the handler will see.

2. The integration spec called /device/approve and /device/deny directly without claiming via GET /device first. The plugin enforces `deviceCodeRecord.userId` set before either route accepts the row (routes.mjs:422 + 493), so all three specs were 400 instead of 200/403. Added a `claimDeviceCode` helper that calls GET /device?user_code=… with the session cookie and wired it into all three flows.

Also updated the member-denied test's narrator comment: the member's claim DOES set userId on the row, so the DJ's re-claim is a no-op afterwards (the member effectively burns the user_code for that QR run; the DJ scans a fresh one). Acceptable v1 behavior — control-room context, not adversarial.
@jakebromberg jakebromberg force-pushed the feature/qr-device-authorization branch from 28712c0 to 21af77f Compare June 29, 2026 01:14
…h doesn't trip pollingInterval

The happy-path spec called /device/token twice — once before approve to assert authorization_pending, once after to assert the 12h session. The plugin's deviceToken handler stamps `lastPolledAt` on every call and returns `slow_down` (400) on any poll within `interval` (5s, RFC 8628 — routes.mjs:201). The second call landed ~1s after the first, so CI saw 400 instead of 200.

Lift the pre-approval poll into its own test against a fresh device_code row. Each test now makes exactly one /device/token call, so `lastPolledAt` never gates its own assertion. `afterEach` truncates auth_device_code between specs so cross-test polling rate-limiting can't apply either.
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.

QR device-authorization sign-in for the shared control-room computer (ADR 0008)

1 participant