# 14 - Arda as OAuth 2.0 Authorization Server

**Status: NOT STARTED**

## Summary

Turn arda into an OAuth 2.0 authorization server so external (initially internal-only) apps can let Bigscreen users sign in via arda and then call admin_api on their behalf. The flow: external app redirects to arda → arda authenticates the user (using the existing login flow) and shows a consent screen → arda issues a one-time authorization code → external app exchanges it at `/oauth/token` for an access token + refresh token → external app uses the access token as `Authorization: Bearer` against admin_api and can learn the user's identity via `/oauth/userinfo`.

**Design principle: purely additive.** The existing first-party JWT access/refresh token system, login flow, `allowed_apps.json` service-to-service keys, and `x-access-token` + app-key double-auth on admin_api all stay unchanged. OAuth is a new code path alongside; existing v2 tokens keep working indefinitely.

**Scope constraints:**
- **Internal apps only** for v1 — SuperUser-managed clients via a small dev portal in arda; no self-service developer signup, no review queue, no email verification.
- **Users sign in via the external app** — `/oauth/userinfo` endpoint is in v1 scope so third-party apps learn the authenticated user's identity. Full OIDC with `id_token` minting and `.well-known/openid-configuration` deferred to a later phase.
- **First consumer: factory/fabricator tool** — initial scopes target `fabricator:*`, `orders:*`, plus identity scopes. `accounts:write`, `reports:read`, `network:read`, `admin:all` deferred.

## Current State

No OAuth infrastructure exists. Thorough search of the monorepo confirmed:

- No `oauth2-server`, `@node-oauth/oauth2-server`, `oidc-provider`, or `openid-client` dependencies in any `package.json`.
- The only existing OAuth usage is as a **client** to Brightcove in `api/src/media/Brightcove.ts:26` (`grant_type=client_credentials`).
- No database tables for OAuth clients, grants, authorization codes, or scopes.

### Primitives we already have (and will reuse)

| Primitive | Location | Reuse |
|---|---|---|
| RS256 JWT signing + verification | `auth/Auth.ts:138-220` (`Tokens.generateAccessToken`, `generateRefreshToken`) | Reuse for OAuth v3 tokens with extended `sub` payload |
| Access-token whitelist (Redis) | `auth/AuthDatabase.ts:103-116` (SHA1 hash in set `accessToken:{accountId}`) | Reuse for OAuth access-token revocation |
| Refresh-token whitelist (Firestore) | `auth/AuthDatabase.ts:63-85` (`RefreshTokenWhitelist` collection) | Reuse for OAuth refresh tokens |
| Renewal nonces (Redis) | `auth/AuthDatabase.ts:87-100` (set `tokenRenewalNonceList`) | Parallel pattern for authorization codes (`oauth:code:*`) |
| JWT payload dispatch | `auth/Auth.ts:103-117` (`AccessTokenPayload` `Object.assign(this, payload.sub)`) | Extending `sub` with new fields flows through automatically |
| Admin_api double auth | `apps/admin_api/admin_api.ts:43-56` (`verifyAdminRequest`) | Rewrite to detect OAuth bearer and skip app-key check when token is OAuth |
| AccessPolicy enforcement | `auth/AuthApi.ts:345` (`getAccessPolicyHandler`) | Extend with a parallel `requireScopeAndPolicy` that checks both scope AND policy |
| Arda login flow | `webapps/src/server/api.js:98-132` (`login`), `webapps/src/components/Auth/Login.jsx` | Extend to honor a `returnTo` query param so OAuth redirects round-trip through login |
| Arda Express routing | `webapps/arda/arda.js` (add routes before line 87 catch-all) | Add `/oauth/authorize` (GET/POST) handlers |
| Postgres pool | `lib/PostgresDatabase.ts:251` (`Postgres.getFabricatorClient()`) | Use for new `oauth_clients` / `oauth_grants` / `oauth_audit_log` tables |
| Semantic UI components | `webapps/src/components/**` | Use for developer portal + consent screen |

### Current gaps

1. No concept of third-party clients (redirect URIs, scopes, secrets)
2. No consent delegation or prior-grant memoization
3. No authorization code grant — only direct password/Steam/Oculus login
4. Admin_api requires BOTH app key AND user token — no way for third-party bearer alone
5. No OAuth-standard error responses or well-known metadata endpoints

## Architecture

### Service boundaries

| Service | Port | OAuth role | New endpoints |
|---|---|---|---|
| arda (Express + React) | 3010 | Authorization server (user-facing) | `GET /oauth/authorize`, `POST /oauth/authorize/decision`, `GET /developers/*`, `GET /settings/connected-apps` |
| auth-api (`apps/api/api.ts`) | 3009 | Token issuer | `POST /oauth/token`, `POST /oauth/revoke`, `POST /oauth/introspect`, `GET /oauth/userinfo`, `GET /.well-known/jwks.json`, `GET /.well-known/oauth-authorization-server`, `POST /oauth/codes` (service-to-service) |
| admin_api (`apps/admin_api/admin_api.ts`) | 3999 | Resource server + OAuth client CRUD | `/admin/oauth/clients/*` (SuperUser CRUD); existing routes enforce OAuth scope via new middleware |

**Why this split:**
- `/oauth/authorize` renders HTML and needs a session — arda already owns login UI, cookies, and Semantic UI.
- `/oauth/token` signs JWTs — belongs on auth-api alongside existing `Tokens.generateAccessToken`. Private key stays off arda.
- Client CRUD is admin data — fits admin_api's pattern and is managed through arda's existing `/api/admin/*` proxy.

### Authorization Code + PKCE flow

```
1. External app → https://arda.bigscreencloud.com/oauth/authorize
     ?response_type=code
     &client_id=fabricator_abc
     &redirect_uri=https://tool.example.com/cb
     &scope=fabricator:read%20orders:read
     &state=XYZ
     &code_challenge=<sha256-b64url(verifier)>
     &code_challenge_method=S256

2. Arda validates params:
     - oauth_clients row exists and not disabled
     - redirect_uri exact-match in client.redirectUris
     - requested scopes ⊆ client.allowedScopes
     - state present
     - code_challenge present (required for public clients)
   If cookie missing → 302 /login?returnTo=<encoded>. After login, round-trip back.
   If OAuthGrants row already covers requested scopes → skip consent, go to step 4.

3. Arda renders Pug consent page listing scopes in human-readable form.
   User clicks Allow → POST /oauth/authorize/decision (CSRF-protected).
   Upsert oauth_grants row.

4. Arda server calls auth-api POST /oauth/codes (service-to-service via
   arda's existing BIGSCREEN_API_KEY). Auth-api mints 32-byte code, stores:
     oauth:code:<code> (Redis, TTL 60s) = { clientId, userId, redirectUri,
       scope, codeChallenge, codeChallengeMethod, userSessionId, authTime }
   Arda 302s to redirect_uri?code=<code>&state=XYZ.

5. External app backend → POST https://api.bigscreencloud.com/oauth/token
     grant_type=authorization_code
     &code=<code>
     &redirect_uri=...
     &client_id=...
     &code_verifier=...
   Confidential clients also authenticate via HTTP Basic with client_secret.

6. Auth-api /oauth/token:
     a. GETDEL oauth:code:<code> (atomic one-time-use)
     b. Verify redirect_uri and client_id match stored values
     c. Verify PKCE: sha256(code_verifier) === codeChallenge
     d. If confidential: bcrypt-compare client_secret
     e. Load BigscreenAccount; reject if banned; check user still holds
        at least one policy in the required-policy set for each requested scope
     f. Mint v3 OAuth access token + refresh token
        (reuses Tokens.generateAccessToken with extended sub payload)
     g. AuthDatabase.activateAccessToken + activateRefreshToken
     h. Add token hash to Redis set
        oauth:user_client_tokens:<userId>:<clientId> (enables per-client revocation)
     i. Return { access_token, token_type:"Bearer", expires_in:900,
                 refresh_token, scope }

7. External app → admin_api with Authorization: Bearer <oauth_access_token>
     No x-access-token, no app key required. OAuth bearer alone is sufficient.

8. Admin_api verifyAdminRequest detects grantType=oauth_user,
   loads OAuth context (client + scopes + grant still valid),
   enforces scope via requireScopeAndPolicy,
   enforces AccessPolicy identically to first-party path.
```

### Token format (JWT v3)

Reuse RS256 signing, `JWT_ACCESS_TOKEN_ISSUER`, existing Redis/Firestore whitelists. Only the `sub` payload changes for OAuth tokens:

```ts
// Existing v2 (first-party, unchanged):
sub: { version: "v2", bigscreenAccountId, userSessionId }

// New v3 (OAuth only):
sub: {
    version: "v3",
    bigscreenAccountId,
    userSessionId,
    grantType: "oauth_user",    // discriminator vs first-party
    clientId: "fabricator_abc",
    scope: ["fabricator:read", "orders:read"],
    authTime: 1713100000
}
```

Access token TTL stays at 15 minutes. Refresh token TTL stays at 3 months. **Refresh token rotation is added for OAuth refresh tokens** (each use issues a new refresh, old one deactivated) to close the "leaked refresh token" window; first-party refresh flow is unchanged.

### Scope model: orthogonal to AccessPolicy

**Rule: scope narrows, never expands.** OAuth routes require BOTH (a) token scope covers the action AND (b) user still has the AccessPolicy for the action. A SuperUser authorizing a narrow scope gets narrow access via that token; a non-SuperUser authorizing a broad scope still can't do anything their AccessPolicies don't allow.

**v1 initial scope set (tuned to factory tool use case):**

| Scope | Purpose | Required AccessPolicy (any of) |
|---|---|---|
| `openid` | Enables `/oauth/userinfo` identity lookup | any |
| `profile` | Expose username, email, createdAt on userinfo | any |
| `fabricator:read` | Read `/admin/fabricator/*`, `/admin/inventory/*` GET | FabricatorReadOnly, Fabricator, FabricatorAdmin, Admin |
| `fabricator:write` | Mutate fabricator data | Fabricator, FabricatorAdmin, Admin |
| `orders:read` | Read `/admin/shop/*`, `/admin/big_orders/*` | Fabricator, Inventory, Admin, SuperUser |
| `orders:write` | Mutate orders | Fabricator, Inventory, Admin |
| `accounts:read` | Read user profiles (supporting factory contact info lookup) | Moderator, AccountsReadOnly, Admin |

**Deferred:** `accounts:write`, `reports:read`, `network:read`, `admin:all`. Adding a scope later is a pure data change (`oauth_scopes` table and middleware declarations) — no protocol change required.

Scope → required-policy mapping lives in a new file `auth/OAuthScopes.ts` exported from `@bigscreen/auth`.

## Database Schema

New migration file `apps/db_setup/oauth_db_setup.ts`, mirroring the style of `beyond_db_setup.ts`. Tables live in admin_api's Postgres (fabricator pool, `Postgres.getFabricatorClient()` in `lib/PostgresDatabase.ts:251`).

```sql
CREATE TABLE oauth_clients (
    "uniqueId"         uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    "clientId"         VARCHAR(48)  NOT NULL UNIQUE,
    "clientSecretHash" VARCHAR(128),                    -- bcrypt; NULL for public clients
    "clientType"       VARCHAR(16)  NOT NULL,           -- 'confidential' | 'public'
    "name"             VARCHAR(128) NOT NULL,
    "description"      TEXT,
    "logoUrl"          VARCHAR(512),
    "homepageUrl"      VARCHAR(512),
    "redirectUris"     TEXT[]       NOT NULL,           -- exact-match validated
    "allowedScopes"    TEXT[]       NOT NULL,
    "ownerAccountId"   VARCHAR(64)  NOT NULL,
    "createdAt"        BIGINT       NOT NULL,
    "disabledAt"       BIGINT,
    "disabledReason"   TEXT
);
CREATE INDEX idx_oauth_clients_owner ON oauth_clients("ownerAccountId");

CREATE TABLE oauth_grants (
    "uniqueId"   uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    "userId"     VARCHAR(64) NOT NULL,
    "clientId"   VARCHAR(48) NOT NULL REFERENCES oauth_clients("clientId"),
    "scopes"     TEXT[]      NOT NULL,
    "grantedAt"  BIGINT      NOT NULL,
    "updatedAt"  BIGINT      NOT NULL,
    "revokedAt"  BIGINT,
    UNIQUE ("userId", "clientId")
);
CREATE INDEX idx_oauth_grants_user   ON oauth_grants("userId");
CREATE INDEX idx_oauth_grants_client ON oauth_grants("clientId");

CREATE TABLE oauth_audit_log (
    "uniqueId"       uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    "at"             BIGINT      NOT NULL,
    "eventType"      VARCHAR(48) NOT NULL,  -- client_created, client_disabled, grant_created,
                                             -- grant_revoked, token_issued, token_revoked,
                                             -- auth_denied, redirect_mismatch, pkce_failure
    "actorAccountId" VARCHAR(64),
    "clientId"       VARCHAR(48),
    "userId"         VARCHAR(64),
    "ip"             VARCHAR(64),
    "details"        jsonb
);
CREATE INDEX idx_oauth_audit_at     ON oauth_audit_log("at" DESC);
CREATE INDEX idx_oauth_audit_client ON oauth_audit_log("clientId", "at" DESC);
```

Authorization codes live in **Redis** (not Postgres), matching the existing `tokenRenewalNonceList` pattern. Key `oauth:code:<code>`, TTL 60s, redeemed with atomic `GETDEL`.

## Grant Types

Supported:
- **Authorization Code + PKCE (S256)** — the only flow for minting tokens. Required for public clients, recommended for confidential clients.
- **Refresh Token grant with rotation** — standard renewal.

Explicitly NOT supported:
- **Implicit** (deprecated by OAuth 2.0 Security BCP)
- **Resource Owner Password Credentials** (deprecated, security hole)
- **Client Credentials** (the existing `allowed_apps.json` service-to-service bearer system already serves this trust model — keep separate)
- **Device Authorization** (defer; design doesn't preclude adding later)

## API Endpoints

### arda (port 3010)

| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | `/oauth/authorize` | cookie (redirect to /login if missing) | Validate params, skip-consent if prior grant, render consent page |
| POST | `/oauth/authorize/decision` | cookie + CSRF | Upsert `oauth_grants`, call auth-api to mint code, 302 to redirect_uri |
| GET | `/login?returnTo=...` | none | Existing login extended to honor same-origin `returnTo` |
| GET | `/developers` | cookie + SuperUser | Developer portal home |
| GET | `/developers/apps/:clientId` | cookie + SuperUser | Client editor (name, redirect URIs, scopes, disable, rotate secret, view grants, view audit) |
| GET | `/settings/connected-apps` | cookie | User's own OAuthGrants list with revoke button |

### auth-api (port 3009)

| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | `/oauth/codes` | service bearer (arda's `BIGSCREEN_API_KEY`) | Mint authorization code into Redis |
| POST | `/oauth/token` | client_id (+ client_secret for confidential) | Exchange code or refresh_token for tokens |
| POST | `/oauth/revoke` | client_id + client_secret | RFC 7009 token revocation |
| POST | `/oauth/introspect` | client_id + client_secret | RFC 7662 token introspection |
| GET | `/oauth/userinfo` | OAuth bearer | Return `{ sub, username, email, createdAt }` based on scopes |
| GET | `/.well-known/jwks.json` | none | RSA public key for offline JWT verification |
| GET | `/.well-known/oauth-authorization-server` | none | OAuth 2.0 Authorization Server Metadata (RFC 8414) |

### admin_api (port 3999)

| Method | Endpoint | Policy | Purpose |
|---|---|---|---|
| GET | `/admin/oauth/clients` | SuperUser | List all clients (paginated) |
| POST | `/admin/oauth/clients` | SuperUser | Create client, return one-time client_secret |
| GET | `/admin/oauth/clients/:clientId` | SuperUser | Fetch client |
| PUT | `/admin/oauth/clients/:clientId` | SuperUser | Update name/description/logo/redirect URIs/scopes |
| POST | `/admin/oauth/clients/:clientId/rotate_secret` | SuperUser | Rotate, return new secret once |
| POST | `/admin/oauth/clients/:clientId/disable` | SuperUser | Disable + invalidate all outstanding tokens for this client |
| POST | `/admin/oauth/clients/:clientId/enable` | SuperUser | Re-enable |
| GET | `/admin/oauth/clients/:clientId/grants` | SuperUser | List all user grants for this client |
| GET | `/admin/oauth/clients/:clientId/audit` | SuperUser | Event log for this client |
| GET | `/admin/oauth/my_grants` | any authenticated | Current user's grants |
| DELETE | `/admin/oauth/my_grants/:clientId` | any authenticated | Revoke user's consent + all outstanding tokens for that pair |

## Files to Create

| File | Purpose | Approx LOC |
|---|---|---|
| `auth/OAuthScopes.ts` | Scope enum + scope → required-policy map | 80 |
| `auth/OAuthClientDatabase.ts` | Postgres CRUD for `oauth_clients`, `oauth_grants`, `oauth_audit_log` | 400 |
| `auth/OAuthTokens.ts` | `generateOAuthAccessToken`, `generateOAuthRefreshToken`, `verifyAndEnforceOAuthContext`; Redis code mint + atomic redeem | 300 |
| `auth/OAuthApi.ts` | HTTP handlers: `/oauth/token`, `/revoke`, `/introspect`, `/userinfo`, `/.well-known/*`, `/oauth/codes`; `requireScopeAndPolicy` middleware | 600 |
| `apps/db_setup/oauth_db_setup.ts` | Creates OAuth tables | 120 |
| `webapps/src/server/oauth.js` | Arda server handlers for `/oauth/authorize`, `/oauth/authorize/decision` | 250 |
| `webapps/arda/views/oauth_consent.pug` | Server-rendered consent screen | 60 |
| `webapps/src/components/Developers/DevHome.jsx` | Developer portal landing (list my clients, Create button) | 150 |
| `webapps/src/components/Developers/OAuthClientEditor.jsx` | Create/edit OAuth client form | 300 |
| `webapps/src/components/Developers/OAuthClientSecretModal.jsx` | One-time secret display modal | 50 |
| `webapps/src/components/Developers/OAuthGrantsList.jsx` | Admin view of user grants for a client | 120 |
| `webapps/src/components/Developers/OAuthAuditLog.jsx` | Audit log viewer | 100 |
| `webapps/src/components/Settings/ConnectedApps.jsx` | User's "Connected apps" with per-app revoke | 150 |
| `tests/auth/OAuth.spec.ts` | End-to-end OAuth tests (ts-mocha, matches existing test conventions) | 600 |
| `docs/oauth.md` | Integrator-facing OAuth documentation (how to register, flow examples, scope table) | 400 |
| `docs/FORGEMASTER.md` | Project explanation doc per CLAUDE.md convention | 300 |

## Files to Modify

| File | Change |
|---|---|
| `auth/Auth.ts` | Extend `AccessTokenPayload` at line 103 with optional `grantType`, `clientId`, `scope` (typed; no constructor change needed because `Object.assign(this, payload.sub)` is already used). Extend `requiresValidAccessTokenInternal` at line 488 to detect `grantType === "oauth_user"` and call `OAuthClientDatabase.isClientEnabled` + grant-still-covers-scope check. Add `v3` version constant. |
| `auth/AuthApi.ts` | `getAccessPolicyHandler` at line 345 rejects OAuth tokens with `403 insufficient_scope` (unmigrated routes are OAuth-closed by default, safer than permissive). |
| `auth/AuthDatabase.ts` | Add helpers `trackOAuthToken(userId, clientId, tokenHash)`, `revokeAllTokensForUserClient(userId, clientId)`. Extend `activateAccessToken` at line 103 with optional `clientId` argument. |
| `auth/index.ts` | Export `OAuthScopes`, `OAuthClientDatabase`, `OAuthTokens`, `OAuthApi`. |
| `apps/api/api.ts` | Mount `/oauth/token`, `/revoke`, `/introspect`, `/userinfo`, `/.well-known/*`, `/oauth/codes`. Enable the rate limiter commented out at lines 93-94 (or choose and wire a library). |
| `apps/admin_api/admin_api.ts` | Rewrite `verifyAdminRequest` (lines 43-56) to detect OAuth bearer and skip app-key check. Add `/admin/oauth/clients/*` CRUD routes (SuperUser-gated). Migrate pilot routes in Phase D. |
| `webapps/arda/arda.js` | Add `/oauth/authorize` (GET) + `/oauth/authorize/decision` (POST) BEFORE the `app.get("*")` catchall at line 87. Add `/api/oauth/*` proxy routes for the developer portal. |
| `webapps/src/server/api.js` | `login` (lines 98-132) honors same-origin `returnTo` query param instead of hard-coded redirect to `/`. Add protocol-relative rejection. |
| `webapps/src/components/Auth/Login.jsx` | Honor `returnTo` query param on submit success. |
| `webapps/arda/app/App.jsx` | Add routes: `/developers`, `/developers/apps/:clientId`, `/settings/connected-apps`. |
| `webapps/arda/app/ArdaWrapper.jsx` (lines 81-93) | Add Developers menu item, SuperUser-gated. |
| `docs/testing.md` | Document new OAuth test harness expectations. |
| `.env` sample / `DEV_SETUP.md` | Document new env vars: `OAUTH_ISSUER_URL`, `OAUTH_CODE_TTL_SECONDS`, `OAUTH_CONSENT_COOKIE_SECRET` (for CSRF). |

## Files NOT to Modify (deliberately)

- `auth/Auth.ts:138-167, 186-220` (`generateRefreshToken` / `generateAccessToken`) — stay untouched. OAuth tokens use new factory functions in `OAuthTokens.ts` that internally invoke the same RS256 signing machinery but produce the v3 payload. Keeping first-party and OAuth paths syntactically separate avoids forcing the first-party flow to change.
- `tests/allowed_apps.json` — OAuth is a different trust model from service-to-service app keys. These entries keep working; first-party admin_api callers still need them.
- Any existing `api.get("/admin/...", getAccessPolicyHandler(...))` route — route migration is opt-in per-endpoint in Phase D onwards.

## Security Requirements

All P0; none optional for ship.

- **Redirect URI exact-match only.** No prefix matching, no wildcards. Localhost with any port only in dev mode (gate on `NODE_ENV`).
- **`state` parameter required** on `/oauth/authorize` (even though spec says optional). Without it, CSRF attacks trivially forge callbacks.
- **PKCE required for public clients, strongly recommended for confidential.** S256 only; reject `plain` at `/oauth/token`.
- **Client secret is server-to-server only.** HTTP Basic or form-post at `/oauth/token`; never in URLs, never in authorize-request body.
- **Rate limiting** on `/oauth/token`, `/oauth/authorize`, `/oauth/authorize/decision`. The commented-out limiter at `apps/api/api.ts:93-94` must be resolved before ship (either re-enable the existing code or adopt `express-rate-limit`).
- **Per-client token tracking.** Redis set `oauth:user_client_tokens:<userId>:<clientId>` holds token hashes so revocation is per-client, not user-wide. Existing `accessToken:{accountId}` set is user-wide and not sufficient.
- **Scope escalation forbidden on refresh.** New token's scope MUST be ⊆ refresh token's scope.
- **Refresh token rotation.** Every successful refresh issues a new refresh token and deactivates the old one in Firestore `RefreshTokenWhitelist`.
- **Dynamic CORS on `/oauth/token`.** Allow-list origins derived from each client's registered `redirectUris`.
- **Open-redirect protection on `returnTo`.** Validate it starts with `/` and isn't `//host`. Reject protocol-relative (`//evil.com`).
- **JWKS endpoint** (`/.well-known/jwks.json`) exposes public RSA key for offline verification — zero risk since the key is already public.
- **Audit everything** to `oauth_audit_log` AND the existing `Logger`: client CRUD, secret rotation, grant creation/revocation, token issuance/revocation, auth denial, redirect_uri mismatch, PKCE failures.
- **Log redaction** for `code`, `code_verifier`, `client_secret`, access/refresh tokens. Follow the existing `getSanitizedRequestString` pattern at `auth/AuthApi.ts:24`.
- **Admin disables client** → `oauth_clients.disabledAt` set, all token hashes from `oauth:user_client_tokens:*:<clientId>` removed from Redis, audit event written. Next request with an outstanding token from that client is rejected (the OAuth context loader sees `disabledAt` and 401s).
- **User revokes grant** → same revocation path but scoped to (user, client).
- **OAuth-standard error responses** (RFC 6749 §5.2): `invalid_request`, `invalid_client`, `invalid_grant`, `unauthorized_client`, `unsupported_grant_type`, `invalid_scope`. Don't leak DB internals.
- **`WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`** header on 403 scope-failure responses, per RFC 6750.

## Rollout Phases

Shippable chunks. Each phase lands independently; existing first-party behavior unchanged throughout.

### Phase A — Client registration plumbing

- Postgres migration (`oauth_db_setup.ts`), tables created in production and dev
- `OAuthClientDatabase.ts` with CRUD, secret bcrypt hashing, audit writes
- Admin_api `/admin/oauth/clients/*` CRUD routes, SuperUser policy
- Arda `/developers` portal UI: list, create (one-time secret reveal modal), edit, rotate, disable
- `/oauth/authorize` returns `501 Not Implemented` so clients can be registered but flow is disabled

**Deliverable:** admins can register clients in production. No OAuth flow yet.

### Phase B — `/authorize` + consent + code mint

- Auth-api `/oauth/codes` service endpoint (called only by arda with `BIGSCREEN_API_KEY`)
- Arda `/oauth/authorize` handler (parameter validation, login redirect, prior-grant memoization)
- Arda `/oauth/authorize/decision` handler (CSRF, grant upsert, code mint call, 302)
- Consent Pug template
- `/login?returnTo=` honored in `webapps/src/server/api.js` and `Login.jsx`

**Deliverable:** end-to-end authorize flow produces a code at the client's redirect URI. Token endpoint still disabled.

### Phase C — `/token` + refresh

- Auth-api `/oauth/token` with `authorization_code` and `refresh_token` grants
- PKCE S256 enforcement; `plain` rejected
- Client authentication (Basic and form-post) for confidential clients
- v3 OAuth access + refresh token minting reusing `Tokens.generateAccessToken` + existing activation
- Refresh token rotation
- `/.well-known/jwks.json` and `/.well-known/oauth-authorization-server`

**Deliverable:** clients exchange code for tokens. Admin_api still rejects OAuth tokens.

### Phase D — Admin_api OAuth enforcement + pilot migration

- `verifyAdminRequest` detects OAuth tokens, skips app-key check, populates `req.oauthClient` + `req.oauthScopes`
- `requireScopeAndPolicy` middleware available in `@bigscreen/auth`
- Migrate pilot routes to `requireScopeAndPolicy`:
  - `GET /admin/profile` (openid + profile)
  - `GET /admin/fabricator/*` read endpoints (fabricator:read)
  - `GET /admin/inventory/*` read endpoints (fabricator:read)
  - `GET /admin/shop/orders`, `GET /admin/big_orders/*` (orders:read)
- Unmigrated routes reject OAuth tokens with `403 insufficient_scope`

**Deliverable:** first real OAuth admin_api call succeeds end-to-end. Factory tool can now be built against this.

### Phase E — Userinfo, revocation, audit UI

- Auth-api `GET /oauth/userinfo` — returns `{ sub, username, email, createdAt }` gated on `openid` + `profile` scopes
- Auth-api `POST /oauth/revoke` (RFC 7009)
- Auth-api `POST /oauth/introspect` (RFC 7662)
- Arda `/settings/connected-apps` — user-facing grant list with per-app revoke
- Arda `/developers/apps/:clientId/audit` — audit log viewer
- Admin "Disable client" action also invalidates all outstanding tokens for that client

**Deliverable:** full revocation story. External apps can offer a true "Sign in with Bigscreen" using `/oauth/userinfo`.

### Phase F (deferred) — Full OIDC `id_token` support

Add only if a concrete consumer asks:

- `id_token` minting alongside access token when `scope=openid`
- `/.well-known/openid-configuration`
- Nonce binding in `id_token`
- `id_token_hint` support on `/oauth/authorize`

Skippable for v1; not in initial delivery.

## Verification

Run the existing auth + admin_api test suites at every phase to confirm no regression in first-party flows. New tests per phase:

1. **Phase A** (`tests/auth/OAuth.spec.ts`):
   - Create client → receive one-time secret
   - Secret hash round-trips bcrypt
   - Rotate secret → old secret rejected, new accepted
   - Disable client → client record read-only
   - Non-SuperUser cannot access `/admin/oauth/clients/*`
   - Manual: log in as SuperUser in arda, walk through developer portal

2. **Phase B**:
   - Invalid `client_id` → 400
   - Wrong `redirect_uri` → 400, audit row written
   - Missing `state` → 400
   - Missing `code_challenge` for public client → 400
   - Scope not ⊆ `allowedScopes` → 400
   - Happy path: unauthenticated user → /login?returnTo → login → back to /authorize → consent → redirect to callback with code and state
   - Skip-consent path: re-run, confirm consent not shown, redirect happens immediately
   - `returnTo=//evil.com` → rejected at login
   - CSRF token missing on decision POST → rejected

3. **Phase C**:
   - Happy path: code → tokens
   - Wrong `code_verifier` → 400 `invalid_grant`
   - Reused code → 400 (GETDEL already consumed)
   - Expired code (> 60s) → 400
   - Wrong `redirect_uri` → 400
   - Confidential client without `client_secret` → 401 `invalid_client`
   - `code_challenge_method=plain` rejected
   - Refresh rotation: refresh token → new pair; old refresh rejected on next use; scope subset enforced
   - `/.well-known/jwks.json` returns valid JWKS

4. **Phase D**:
   - Call `/admin/fabricator/scans` with OAuth token that has `fabricator:read` → 200
   - Same token but user lacks FabricatorReadOnly/Fabricator/Admin → 403
   - Same route, OAuth token with only `orders:read` → 403 `insufficient_scope`
   - Unmigrated route with OAuth token → 403 (`getAccessPolicyHandler` rejects)
   - First-party `x-access-token` on migrated route → still works (grantType undefined falls through)

5. **Phase E**:
   - `GET /oauth/userinfo` with valid OAuth token + openid scope → 200 with `sub`
   - Same with `profile` added → 200 with sub + username + email
   - Revoke via `/settings/connected-apps` → subsequent API calls → 401
   - Admin disables client → all outstanding tokens from that client → 401 on next request
   - `POST /oauth/revoke` with valid refresh token → subsequent token refresh → 400

6. **Acceptance test (end of Phase D)**:
   Build a thin CLI or test harness that simulates the factory/fabricator tool end-to-end:
   - Opens browser to `/oauth/authorize` with localhost callback
   - Receives code on localhost
   - Exchanges for tokens
   - Calls `GET /admin/fabricator/scans`
   - Calls `GET /oauth/userinfo` to print the authenticated user
   - All scriptable via `ts-mocha`, included in `tests/auth/OAuth.spec.ts`

## Implementation Notes

- **Postgres writes use the fabricator pool** (`Postgres.getFabricatorClient()`), matching existing admin_api convention at `lib/PostgresDatabase.ts:251`.
- **Indentation: 4 spaces**, per CLAUDE.md.
- **Never read `.env` files directly.** New env vars documented in `.env.sample` / `DEV_SETUP.md` only.
- **Absolute paths always** — no `cd ../..`.
- **Integration tests run from `tests/` with ts-mocha**, e.g. `ts-mocha --bail --exit --timeout=500000 auth/OAuth.spec.ts`.
- **Yarn workspace builds only** — never `cd <subdir> && yarn build`. Use `yarn workspace <name> build`.
- **Logger usage** — use the existing `@bigscreen/lib` Logger + `DiscordLogger`. OAuth events should emit to Discord at warning level or higher for security-critical events (redirect mismatch, PKCE failure, admin client disable).
- **CLAUDE.md requires `docs/FOR[yourname].md`** per project — create `docs/FORGEMASTER.md` (or similar named doc) when implementing, covering: architecture, why we chose the three-service split, why reuse of JWT v3 instead of new token type, security rationale, lessons from bugs encountered.

## Open Questions to Resolve During Implementation

- **Rate limiter:** the limiter commented out at `apps/api/api.ts:93-94` must be re-enabled or replaced. Cannot ship OAuth without rate-limiting the `/oauth/*` endpoints. Pick one before Phase C.
- **OAuth access token TTL:** default is 15 min (matches first-party). If factory tool reveals 15 min causes frequent refresh churn, consider bumping to 30-60 min in Phase D retrospective. Document the choice.
- **Refresh token TTL:** default is 3 months (matches first-party). Consider shorter (14 days) for OAuth refresh tokens since rotation is enforced — a leaked refresh token has smaller blast radius, but a shorter TTL still limits exposure.
- **Audit log retention:** confirm retention policy for `oauth_audit_log` (indefinite vs GDPR-driven expiry). Safe default: indefinite with periodic anonymization of `userId` on audit rows older than a year.
- **Consent screen UX copy:** who writes the human-readable descriptions of each scope? Draft with engineering, review with product before Phase B ships.
- **Should `allowed_apps.json` entry still be required for an OAuth client?** Recommended answer: no. OAuth bearer tokens carry their own `clientId` inside the JWT and don't need a parallel entry in `allowed_apps.json`. Confirmed in `verifyAdminRequest` rewrite above.
