# OAuth 2.0 Provider

Bigscreen cloud exposes an OAuth 2.0 authorization server so third-party services (and internal non-arda tooling) can act on an admin's behalf without being issued a legacy app key. The provider implements Authorization Code + PKCE with refresh-token rotation and reuse detection, and enforces scopes on top of the existing access-policy model.

Shipped under plan 14. The authoritative design spec is [plans/14-oauth-provider/SPEC_REVISED.md](../../plans/14-oauth-provider/SPEC_REVISED.md); this page is the reference doc for people writing or operating code against the provider.

## Where the provider lives

The provider is not a single service — it is split across the three apps that already make up the control plane:

```mermaid
flowchart LR
    CLIENT["Third-party<br/>OAuth client"]
    USER["Admin (browser)"]

    subgraph Provider["OAuth provider (spread across services)"]
        API["apps/api<br/>/oauth/token<br/>/.well-known/*"]
        ARDA["webapps/arda<br/>/oauth/authorize<br/>consent UI"]
        ADMIN["apps/admin_api<br/>/admin/oauth/*<br/>handleOAuthBearer"]
    end

    subgraph Storage
        PG[("Postgres<br/>oauth_clients<br/>oauth_grants<br/>oauth_audit_log")]
        REDIS[("Redis<br/>oauth:code:*<br/>token whitelist")]
    end

    USER -->|browser redirect| ARDA
    ARDA -->|mint code + grant| ADMIN
    CLIENT -->|exchange code| API
    CLIENT -->|Bearer JWT| ADMIN

    ARDA --> ADMIN
    ADMIN --> PG
    ARDA --> REDIS
    API --> REDIS
    API --> PG
```

| Responsibility | Lives in |
|---------------|----------|
| Client CRUD, grants, audit log | `apps/admin_api` via [`OAuthClientsApi`](../../api/src/OAuthClientsApi.ts) |
| Consent UI + authorization-code mint | [`webapps/arda`](../../webapps/arda) — server-side handlers at [`webapps/src/server/oauth.js`](../../webapps/src/server/oauth.js) |
| Token exchange + JWKS + RFC 8414 discovery | `apps/api` via [`OAuthApi`](../../api/src/OAuthApi.ts) |
| Core token minting, PKCE, refresh-chain rotation | [`@bigscreen/auth`](../libraries/auth.md) — `OAuthTokens`, `OAuthCodes`, `OAuthScopes`, `OAuthClientDatabase` |

## Endpoints

### Public (on `apps/api`, port 3009)

These bypass `requiresAuthorizedRequest` because external clients cannot be in `allowed_apps.json`. Registered at [apps/api/api.ts:161-163](../../apps/api/api.ts).

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/oauth/token` | `authorization_code` or `refresh_token` grant. Accepts `application/x-www-form-urlencoded` (OAuth standard) or `application/json`. Client auth via HTTP Basic or `client_id`/`client_secret` in body. |
| `GET` | `/.well-known/jwks.json` | RSA public key for verifying access + refresh tokens offline (RFC 7517). |
| `GET` | `/.well-known/oauth-authorization-server` | RFC 8414 authorization-server metadata — advertises `authorize`, `token`, `jwks_uri`, supported grants/scopes/PKCE methods. |

All three are gated on `OAUTH_ENABLED=true`. When disabled they return HTTP 501.

### Consent (on `webapps/arda`, port 3010)

Rendered by arda because the user must already be logged into arda's admin session cookie before granting consent. Handlers at [webapps/src/server/oauth.js](../../webapps/src/server/oauth.js).

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/oauth/authorize` | Validates `client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `scope`, `state`. Redirects to `/login` if unauthenticated. Renders `oauth_consent.pug`. |
| `POST` | `/oauth/authorize/decision` | CSRF-checked allow/deny. On allow, proxies to `admin_api` to upsert the grant + mint the code, then 302s to `redirect_uri?code=…&state=…`. |

Gated on `OAUTH_AUTHORIZE_ENABLED=true`.

### Admin (on `apps/admin_api`, IP-restricted, port 3999)

Under `/admin/oauth/*`. Registered at [admin_api.ts:454-492](../../apps/admin_api/admin_api.ts).

**Called by arda during the authorization flow** (any authenticated admin account):

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/admin/oauth/lookup_client` | Public-safe client metadata by `client_id` — name, logo, allowed redirect URIs. |
| `GET` | `/admin/oauth/my_grant` | Caller's existing grant for a client (scopes already consented to). |
| `POST` | `/admin/oauth/my_grant` | Upsert the caller's grant when they press "Allow". |
| `POST` | `/admin/oauth/codes` | Mint an authorization code bound to `(client, user, redirect_uri, scope, code_challenge)`. |
| `POST` | `/admin/oauth/audit_event` | Append an audit event from the arda consent UI. |

**Client administration** (SuperUser unless noted — `oauthAdmin` gate includes `Admin`, `oauthSuperUser` is strict):

| Method | Path | Gate | Purpose |
|--------|------|------|---------|
| `GET` | `/admin/oauth/clients/scopes` | oauthAdmin | Enum of supported scopes + required policies. |
| `GET` | `/admin/oauth/clients/audit` | oauthSuperUser | Cross-client audit log. |
| `GET` | `/admin/oauth/clients` | oauthAdmin | List clients (paginated; `includeDeleted=true` for SuperUser). |
| `POST` | `/admin/oauth/clients` | oauthAdmin | Create a client. Returns plaintext secret **once**. |
| `GET` | `/admin/oauth/clients/:uniqueId` | oauthAdmin | Fetch a single client. |
| `PUT` | `/admin/oauth/clients/:uniqueId` | oauthAdmin | Update mutable fields (redirects, scopes, IPs, metadata). Requires step-up. |
| `DELETE` | `/admin/oauth/clients/:uniqueId` | oauthAdmin | Soft-delete. |
| `POST` | `/admin/oauth/clients/:uniqueId/hard_delete` | oauthSuperUser | Irrevocably delete. |
| `POST` | `/admin/oauth/clients/:uniqueId/rotate_secret` | oauthAdmin | New plaintext secret returned once; old secret revoked. Step-up required. |
| `POST` | `/admin/oauth/clients/:uniqueId/disable` | oauthAdmin | Disable the client; all existing tokens stop working at next request. |
| `POST` | `/admin/oauth/clients/:uniqueId/enable` | oauthAdmin | Re-enable. |
| `POST` | `/admin/oauth/clients/:uniqueId/step-up/challenge` | oauthAdmin | Email the caller a 6-digit step-up code. |
| `POST` | `/admin/oauth/clients/:uniqueId/step-up/verify` | oauthAdmin | Verify the code; unlocks mutations for a short window. |
| `GET` | `/admin/oauth/clients/:uniqueId/grants` | oauthAdmin | Active user grants for a client. |
| `GET` | `/admin/oauth/clients/:uniqueId/audit` | oauthAdmin | Per-client audit log. |

Ownership-or-SuperUser is enforced inside every mutation handler: a regular `Admin` can only mutate clients they own; SuperUser bypasses.

## Authorization Code Flow

```mermaid
sequenceDiagram
    autonumber
    actor U as Admin (browser)
    participant C as Third-party client
    participant ARDA as webapps/arda
    participant ADMIN as apps/admin_api
    participant API as apps/api
    participant R as Redis
    participant PG as Postgres

    C->>U: redirect to /oauth/authorize<br/>?client_id=&redirect_uri=&code_challenge=&scope=&state=
    U->>ARDA: GET /oauth/authorize
    ARDA->>ADMIN: GET /admin/oauth/lookup_client
    ADMIN->>PG: SELECT oauth_clients
    PG-->>ADMIN: client row
    ADMIN-->>ARDA: safe metadata
    ARDA-->>U: render oauth_consent.pug

    U->>ARDA: POST /oauth/authorize/decision (allow + csrf)
    ARDA->>ADMIN: POST /admin/oauth/my_grant<br/>(user, client, scopes)
    ADMIN->>PG: UPSERT oauth_grants
    ARDA->>ADMIN: POST /admin/oauth/codes<br/>(challenge, redirect, scope)
    ADMIN->>R: SET oauth:code:<code> NX EX 60
    ADMIN-->>ARDA: { code }
    ARDA-->>U: 302 redirect_uri?code=...&state=...

    U->>C: browser lands on redirect_uri
    C->>API: POST /oauth/token<br/>(code, verifier, client_id, secret)
    API->>R: GETDEL oauth:code:<code>
    R-->>API: AuthorizationCodePayload (once)
    API->>API: verify PKCE (S256)
    API->>PG: load account, check scope policies
    API->>API: mint RS256 access + refresh (chain)
    API-->>C: { access_token, refresh_token, scope, expires_in }
```

The code is bound to the exact `(clientId, userId, redirectUri, scope, codeChallenge)` and is single-use — a second redeem of the same code returns `invalid_grant`. See [`auth/OAuthCodes.ts`](../../auth/OAuthCodes.ts) and [`auth/OAuthTokens.ts`](../../auth/OAuthTokens.ts).

## Refresh Rotation + Reuse Detection

Refresh tokens are one-time-use. Each exchange rotates to a new refresh token and bumps a `generation` counter within a persistent `chainId`. If a caller replays a refresh token that was already rotated, the whole chain is burned:

```mermaid
stateDiagram-v2
    [*] --> Gen1: authorization_code exchange
    Gen1 --> Gen2: refresh_token (rotate)
    Gen2 --> Gen3: refresh_token (rotate)
    Gen3 --> ChainRevoked: replay of Gen2 detected
    ChainRevoked --> [*]
    note right of ChainRevoked
      revokeOAuthRefreshChain(chainId)
      + revokeAllOAuthTokensForUserClient(user, client)
      + audit: refresh_reuse_detected
    end note
```

Scope on the new pair must be a subset of the refresh token's scope — narrowing is allowed, widening is `invalid_scope`.

## Scopes

Scopes are defined at [`auth/OAuthScopes.ts`](../../auth/OAuthScopes.ts). A token that carries scope `S` is only accepted for a request if **both** the scope grants access to that area AND the user still holds an `AccessPolicy` from `ScopeRequiredPolicies[S]`. Scopes narrow, never widen.

| Scope | Grantable to users holding | Notes |
|-------|---------------------------|-------|
| `openid`, `profile` | anyone | identity-only, no policy required |
| `admin:all` | `SuperUser` | universal umbrella — typical SuperUser clients request this alongside an area scope |
| `accounts:read` | `Moderator`, `AccountsReadOnly`, `Admin` | |
| `accounts:write` | `Admin` | |
| `fabricator:read` / `fabricator:write` | Fabricator family + `Admin` | |
| `orders:read` / `orders:write` | Fabricator, Inventory, `Admin` | |
| `inventory:read` / `inventory:write` | Fabricator family, Inventory, `Admin` | |
| `shipping:read` / `shipping:write` | Fabricator, Inventory, `Admin` | |
| `media:read` / `media:write` | `Admin` | |
| `moderation:read` / `moderation:write` | `Moderator`, `CommunityModerator`, `Admin` | |
| `network:read` / `network:write` | `Admin` | |
| `kb:read` / `kb:write` | `KBUser`, `KBAdmin`, `Admin` | |
| `events:read` / `events:write` | `Admin` | |
| `reports:read` | `Moderator`, `Reporting`, `Admin` | |

Admin-api routes enforce scope + policy via `AuthApi.requireScopeAndPolicy({ scopes, policies })` — e.g. a route that takes `scopes: ["accounts:read", "admin:all"], policies: [Admin]` accepts an OAuth bearer whose token has either `accounts:read` or `admin:all`, provided the user still holds the `Admin` policy. The same middleware also accepts the legacy app-key + access-token path.

## How admin_api accepts an OAuth bearer

`verifyAdminRequest` at [admin_api.ts:128-164](../../apps/admin_api/admin_api.ts) inspects the bearer shape:

```mermaid
flowchart TB
    REQ[Authorization: Bearer &lt;token&gt;] --> SHAPE{token looks<br/>like "xxx.yyy.zzz"?}
    SHAPE -- no<br/>(64-char app key) --> LEGACY[adminAuthHandler<br/>authorizeHttpRequest<br/>verifyAccessToken]
    SHAPE -- yes<br/>(JWT) --> FLAG{OAUTH_ADMIN_API_ENABLED?}
    FLAG -- no --> F401[401 invalid_token<br/>OAuth disabled]
    FLAG -- yes --> OB[handleOAuthBearer]
    OB --> V1[verifyAccessToken signature]
    V1 --> V2{payload.isOAuth?}
    V2 -- no --> E1[401 not an OAuth token]
    V2 -- yes --> V3{token hash still<br/>in active Redis set?}
    V3 -- no --> E2[401 token revoked]
    V3 -- yes --> V4{client present<br/>and not disabled?}
    V4 -- no --> E3[401 client disabled]
    V4 -- yes --> V5{active grant for<br/>user + client?}
    V5 -- no --> E4[401 grant revoked]
    V5 -- yes --> V6{grant.scopes ⊇<br/>token.scope?}
    V6 -- no --> E5[401 scope coverage]
    V6 -- yes --> POP[populate req.verifiedAccessToken<br/>req.currentAccount<br/>req.authorizedApp = oauth:&lt;clientId&gt;]
```

The token remains a first-class admin-api request from this point on — `requireScopeAndPolicy` reads the populated `verifiedAccessToken.scope` and `currentAccount.AccessPolicies` to decide.

## Database Schema

Tables are created by [`apps/db_setup/oauth_db_setup.ts`](../../apps/db_setup/oauth_db_setup.ts) on the Fabricator Postgres pool.

```mermaid
erDiagram
    oauth_clients ||--o{ oauth_grants : "has"
    oauth_clients {
        uuid     uniqueId PK
        varchar  clientId  UK
        varchar  clientSecretHash
        varchar  clientType "confidential (v1)"
        varchar  name UK
        text     description
        text_array redirectUris
        text_array allowedScopes
        text_array serverIps
        text_array securityGroupRuleIds
        varchar  ownerAccountId
        bigint   createdAt
        bigint   disabledAt
        bigint   deletedAt
    }
    oauth_grants {
        uuid     uniqueId PK
        uuid     oauthGrantId UK
        varchar  userId
        varchar  clientId FK
        text_array scopes
        bigint   grantedAt
        bigint   updatedAt
        bigint   revokedAt
    }
    oauth_audit_log {
        uuid     uniqueId PK
        bigint   at
        varchar  eventType
        varchar  actorAccountId
        varchar  clientId
        varchar  userId
        varchar  ip
        jsonb    details
    }
```

`oauth_audit_log` is append-only — the admin_api Postgres role is granted `SELECT, INSERT` only (no `UPDATE`/`DELETE`). The grant is an ops deployment step, not something `oauth_db_setup.ts` does itself.

Event types live on `OAuthClientDatabase.AuditEventType`: `client_created`, `client_updated`, `client_secret_rotated`, `client_disabled`, `client_enabled`, `client_soft_deleted`, `client_hard_deleted`, `grant_created`, `grant_updated`, `grant_revoked`, `code_issued`, `token_issued`, `token_refreshed`, `token_revoked`, `refresh_reuse_detected`, `auth_denied`, `redirect_mismatch`, `pkce_failure`, `ip_registration_mismatch`, `scope_denied`, `csrf_failure`, `step_up_requested`, `step_up_passed`, `step_up_failed`.

## Feature Flags

The provider is behind per-service env flags so it can be rolled out independently:

| Env var | Effect | Where |
|---------|--------|-------|
| `OAUTH_ENABLED` | `/oauth/token` and `/.well-known/*` on `apps/api` become live (otherwise 501). | [apps/api/api.ts:160-169](../../apps/api/api.ts) |
| `OAUTH_ADMIN_API_ENABLED` | `apps/admin_api` accepts JWT-shaped bearers as OAuth access tokens. Disabled = all JWT bearers rejected, legacy app-keys continue unchanged. | [apps/admin_api/admin_api.ts:131-141](../../apps/admin_api/admin_api.ts) |
| `OAUTH_AUTHORIZE_ENABLED` | `webapps/arda` serves the consent UI. Disabled = 501 from `/oauth/authorize`. | [webapps/arda/arda.js:92-101](../../webapps/arda/arda.js) |

All three must be on for a full end-to-end OAuth flow; a misaligned setup fails closed with informative errors.

## Relevant env vars

| Var | Purpose |
|-----|---------|
| `JWT_PRIVATE_KEY_PATH` / `JWT_PUBLIC_KEY_PATH` | RS256 keypair for access + refresh tokens. `apps/api` loads these for JWKS + signing. |
| `OAUTH_ISSUER_URL` | `iss` claim and RFC 8414 `issuer`. Falls back to `JWT_ACCESS_TOKEN_ISSUER` then request host. |
| `OAUTH_AUTHORIZE_URL` | Override for the advertised authorization endpoint (defaults to `${BIGSCREEN_WEBAPP_URL}/oauth/authorize`). |
| `OAUTH_TOKEN_URL` / `OAUTH_JWKS_URL` | Overrides for the advertised token / JWKS URLs. Usually left unset. |
| `OAUTH_REVOCATION_URL` / `OAUTH_INTROSPECTION_URL` | Advertised in RFC 8414 metadata if set. v1 does not ship implementations. |
| `BIGSCREEN_WEBAPP_URL` | Base URL for the arda consent UI. Used to synthesise `OAUTH_AUTHORIZE_URL`. |
| `OAUTH_CODE_TTL_SECONDS` | Authorization-code lifetime in Redis. Default 60, clamped to 1–600. |

## Further reading

- Design spec → [plans/14-oauth-provider/SPEC_REVISED.md](../../plans/14-oauth-provider/SPEC_REVISED.md)
- Token format + scope semantics → [libraries/auth.md](../libraries/auth.md)
- Admin endpoint surface → [services/admin-api.md](./admin-api.md)
- Public endpoint surface → [services/api.md](./api.md)
- Authorization-code flow sequence → [data-flows.md](../data-flows.md)
- Arda's consent UI + developer pages → [webapps/arda.md](../webapps/arda.md)
