# OAuth 2.0 Authorization Code Flow — Implementation Plan

**Spec:** docs/specs/2026-06-01-oauth-authorization-code.md  ·  **Slug:** oauth-authorization-code  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, tenant-public-api, white-label-api

## Goal
Make Zync an OAuth 2.0 Authorization Server (AS) and Resource Server (RS) so third-party apps (Zapier, Make, custom integrations) can access the Zync API on behalf of a tenant user without storing the user's credentials. Implements the Authorization Code flow with PKCE, a hosted consent screen, opaque DB-backed access tokens (revocable), refresh-token rotation with token-family reuse detection, a Settings > Integrations connections view, and admin client registration. The existing `api.zync.is/v1/**` API becomes the resource server, accepting OAuth Bearer tokens in addition to API keys.

## Architecture
Two cooperating surfaces, no new worker:

- **Authorization Server (app.zync.is, Hono API package):** Hosts `GET/POST /oauth/authorize` (consent UI + processing — requires a logged-in user session via the locked `authMiddleware` / `SessionPayload`), `POST /oauth/token` (code exchange + refresh, server-to-server), `POST /oauth/revoke`, the user-facing `GET /api/oauth/connections` + `DELETE /api/oauth/connections/:clientId`, and admin `GET/POST/PATCH /api/admin/oauth/clients` (locked `requireAdminSession`). Mints opaque tokens via locked `generateOpaqueToken`, stores only SHA-256 via locked `hashToken`, compares secrets/codes/CSRF tokens via locked `timingSafeEqual` (honors the locked `no-string-equality-for-tokens` lint rule), validates `redirect_uri` via locked `safeRedirect`, and brute-force-protects `/oauth/token` with the locked `RATE_LIMITER_AUTH` binding.
- **Resource Server (api.zync.is/v1, `packages/public-api`):** Its existing auth middleware (from tenant-public-api spec 39) is extended to a **unified dual-token resolver**: hash the Bearer token → look up `tenant_api_keys` → on miss, look up `oauth_access_tokens` (rejecting `revoked_at IS NOT NULL` or expired `expires_at`). Both paths resolve `{ tenantId, userId, scope }`. Scope is enforced by the same locked `hasScope` helper and the same `ApiScope` vocabulary used for API keys (`resource:action`), KV-cached on token hash with TTL ≤ token lifetime.

New tables (`oauth_clients`, `oauth_authorization_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`, `oauth_connections`) are genuinely new. They are **distinct** from the locked upstream `oauth_accounts` table, which is social-login (Google/etc.) identity from foundation-auth-rbac — do not collide with it.

Upstream consumed (exact names): tables `tenants(id)`, `users(id)`, `tenant_api_keys`; exports `authMiddleware`, `SessionPayload`, `requireAdminSession`, `signSession`/`verifySession` (session context), `generateOpaqueToken`, `hashToken`, `timingSafeEqual`, `safeRedirect`, `hasScope`, `ApiScope`, `ApiError`, `RATE_LIMITER_AUTH`, `RATELIMIT_KV`, `KV`, `createDb`/`DB`, `requirePermission`, `requireTier`/`meetsMinimumTier`, package `packages/public-api`, `@zync/auth`, `@zync/db`, `@zync/types`.

## Tech Stack
- **`@zync/db`** (Drizzle schema + migration) — five new tables.
- **`@zync/auth`** — OAuth AS service layer (token minting, code issuance, refresh rotation + family revoke, client + scope validation, PKCE verification, consent CSRF token). Extends `ApiScope` and `hasScope` to cover OAuth resources.
- **`apps/zync-api` (app.zync.is Hono routes)** — `/oauth/*` AS endpoints, `/api/oauth/connections*`, `/api/admin/oauth/clients*`.
- **`apps/zync-app` (Vite+React)** — consent screen route `/oauth/authorize`, Settings > Integrations "Connected apps" panel.
- **`packages/public-api`** — unified dual-token auth middleware (RS).
- **Cloudflare bindings:** `RATELIMIT_KV` (per-token cache + `/oauth/token` rate window), `RATE_LIMITER_AUTH` (brute-force on token endpoint), Hyperdrive→Neon Postgres.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.1 | 1 | `@zync/db` schema + migration | No (foundation for all) |
| 11.2 | 2, 3 | `@zync/auth` scope vocab; `@zync/auth` OAuth service layer | 2 then 3 (3 needs schema + scopes) |
| 11.3 | 4, 5, 6 | AS routes: authorize, token, revoke (apps/zync-api) | After 3; parallel among themselves |
| 11.4 | 7, 8 | connections routes; admin client routes (apps/zync-api) | Parallel after 3 |
| 11.5 | 9 | RS dual-token middleware (`packages/public-api`) | After 3 |
| 11.6 | 10, 11 | consent screen UI; Settings connections panel (apps/zync-app) | Parallel after 6/7 |
| 11.7 | 12 | seed first-party clients + wrangler bindings | After 1 |

## Tasks

### Task 1: Database schema & migration for OAuth tables
**Blocks:** 2, 3, 9, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/oauth.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Create: `packages/db/migrations/<timestamp>_oauth_authorization_code.sql`
**Steps:**
- [ ] Add the five tables below as Drizzle pgTable definitions and a raw SQL migration. Apply the dialect conversion: every PK is `id UUID`, the natural identifiers (`client_id`, `code`, `token_hash`) become `TEXT NOT NULL UNIQUE` (the UNIQUE index preserves O(1) hash/code lookup), and every FK is UUID→UUID.
- [ ] Demote spec's `client_id TEXT PRIMARY KEY` to a surrogate `id UUID PK` + `client_id TEXT UNIQUE` public wire identifier; OAuth layer looks up `WHERE client_id = $1`.
- [ ] Replace every `client_id TEXT REFERENCES oauth_clients(client_id)` with `oauth_client_id UUID REFERENCES oauth_clients(id)` (no TEXT→TEXT FK anywhere).
- [ ] Replace spec's `rotated_to TEXT` with `rotated_to_id UUID REFERENCES oauth_refresh_tokens(id)`.
- [ ] Add `code_challenge_method TEXT CHECK (code_challenge_method IN ('S256'))` to `oauth_authorization_codes` (carried from the authorize request, needed at token exchange).
- [ ] Set `created_at TIMESTAMPTZ NOT NULL DEFAULT now()` on all five tables.
- [ ] Create index on `oauth_refresh_tokens(family_id)`; UNIQUE indexes back the token/code lookups.
**Schema / Interfaces:**
```sql
CREATE TABLE oauth_clients (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id          TEXT NOT NULL UNIQUE,          -- public wire identifier, e.g. 'zapier_zync'
  client_secret_hash TEXT NOT NULL,                 -- SHA-256 of client_secret; only hash stored
  name               TEXT NOT NULL,
  redirect_uris      JSONB NOT NULL,                -- array of allowed redirect URIs (exact-match)
  scopes             JSONB NOT NULL,                -- array of allowed scope strings (resource:action)
  is_first_party     BOOLEAN NOT NULL DEFAULT false,-- first-party clients skip consent screen
  logo_url           TEXT,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE oauth_authorization_codes (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code                  TEXT NOT NULL UNIQUE,        -- random 32-char opaque code
  oauth_client_id       UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
  tenant_id             UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id               UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  redirect_uri          TEXT NOT NULL,
  scope                 TEXT NOT NULL,               -- space-separated granted scopes
  code_challenge        TEXT,                        -- PKCE S256 challenge (base64url)
  code_challenge_method TEXT CHECK (code_challenge_method IN ('S256')),
  expires_at            TIMESTAMPTZ NOT NULL,        -- 10 minutes from issue
  used_at               TIMESTAMPTZ,                 -- single-use marker
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE oauth_access_tokens (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash      TEXT NOT NULL UNIQUE,              -- SHA-256 of opaque access token
  oauth_client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family_id       UUID NOT NULL,                     -- shared with the refresh-token family
  scope           TEXT NOT NULL,
  expires_at      TIMESTAMPTZ NOT NULL,              -- 1 hour from issue
  revoked_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE oauth_refresh_tokens (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash      TEXT NOT NULL UNIQUE,              -- SHA-256 of opaque refresh token
  oauth_client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  scope           TEXT NOT NULL,
  family_id       UUID NOT NULL,                     -- constant across rotations of one grant
  rotated_to_id   UUID REFERENCES oauth_refresh_tokens(id), -- successor (set when rotated/consumed)
  expires_at      TIMESTAMPTZ NOT NULL,              -- 60 days from issue
  revoked_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_oauth_rt_family ON oauth_refresh_tokens(family_id);
CREATE INDEX idx_oauth_at_family ON oauth_access_tokens(family_id);

CREATE TABLE oauth_connections (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  oauth_client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  scope           TEXT NOT NULL,
  flagged_at      TIMESTAMPTZ,                       -- set on refresh-token reuse detection (compromise)
  last_used_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (oauth_client_id, tenant_id, user_id)
);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db drizzle-kit generate` produces no diff after migration is applied (schema matches migration).
- [ ] All FKs are UUID→UUID; no TEXT→TEXT FK; no INTEGER booleans; `redirect_uris`/`scopes` are JSONB.
- [ ] Migration applies cleanly against Neon and indexes `idx_oauth_rt_family`, `idx_oauth_at_family` exist.

### Task 2: Extend scope vocabulary to OAuth resources
**Blocks:** 3, 9  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/api-scopes.ts` (or wherever `ApiScope` is declared in `@zync/types`)
- Modify: `packages/public-api/src/scope.ts` (locked `hasScope` definition)
- Create: `packages/auth/src/oauth/scopes.ts`
**Steps:**
- [ ] **Scope normalization (intentional deviation from spec literal strings):** the OAuth spec writes scopes as `read:invoices` / `write:customers`; the locked `ApiScope` + `hasScope` use `resource:action` (`invoices:read`, `customers:write`). OAuth scopes are normalized to the locked `resource:action` format so the *same* `hasScope` middleware enforces both API-key and OAuth scopes. Document this in a code comment.
- [ ] Extend `ApiScope` union with the new OAuth resources the spec adds: `leads:read`, `leads:write`, `time:read`, `time:write`, `projects:read`, `expenses:read` (existing `customers:*`, `invoices:*`, `tasks:*`, `events:read` already present).
- [ ] Export `OAUTH_SCOPE_DEFINITIONS`: ordered list of `{ scope: ApiScope, label: string }` for the consent screen (label = human description per the spec's Scope Definitions table, e.g. `invoices:write` → "View and create invoices").
- [ ] Export `parseScopeString(s: string): ApiScope[]` and `normalizeRequestedScope(raw: string): ApiScope[]` that splits on `+`/space and maps any legacy `action:resource` input to canonical `resource:action`, rejecting unknown scopes.
- [ ] Confirm `hasScope` treats write scope as implying read for the same resource (already locked behavior) — reuse unchanged.
**Schema / Interfaces:**
```ts
export type ApiScope =
  | 'customers:read' | 'customers:write'
  | 'invoices:read'  | 'invoices:write'
  | 'tasks:read'     | 'tasks:write'
  | 'events:read'
  | 'leads:read'     | 'leads:write'
  | 'time:read'      | 'time:write'
  | 'projects:read'
  | 'expenses:read';

export interface OAuthScopeDefinition { scope: ApiScope; label: string; }
export const OAUTH_SCOPE_DEFINITIONS: readonly OAuthScopeDefinition[];
export function parseScopeString(scope: string): ApiScope[];
export function normalizeRequestedScope(raw: string): ApiScope[]; // throws on unknown scope
```
**Acceptance:**
- [ ] `hasScope(['invoices:write'], 'invoices:read')` returns true (write implies read) and is unchanged for API keys.
- [ ] `normalizeRequestedScope('read:invoices+write:customers')` returns `['invoices:read','customers:write']`.
- [ ] An unknown scope string throws / is rejected with `invalid_scope`.

### Task 3: OAuth Authorization Server service layer (`@zync/auth`)
**Blocks:** 4, 5, 6, 7, 8, 9  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/auth/src/oauth/clients.ts`
- Create: `packages/auth/src/oauth/codes.ts`
- Create: `packages/auth/src/oauth/tokens.ts`
- Create: `packages/auth/src/oauth/pkce.ts`
- Create: `packages/auth/src/oauth/consent-csrf.ts`
- Modify: `packages/auth/src/index.ts` (export public surface)
**Steps:**
- [ ] **Client lookup/validation (`clients.ts`):** `getOAuthClient(db, clientId)`; `validateRedirectUri(client, uri)` performs **exact string match** (scheme+host+path+port) against `client.redirect_uris` — no prefix/substring matching; returns boolean. `verifyClientSecret(client, secret)` SHA-256-hashes `secret` and compares to `client_secret_hash` via locked `timingSafeEqual`. `assertScopesAllowed(client, requested)` ensures every requested scope ⊆ `client.scopes`.
- [ ] **PKCE (`pkce.ts`):** `verifyPkce(codeChallenge, codeVerifier)` computes `base64url(SHA-256(codeVerifier))` and compares to stored `code_challenge` via `timingSafeEqual`. Method is always `S256`. **PKCE required for public clients** (no `client_secret` presented); recommended/accepted for confidential.
- [ ] **Authorization codes (`codes.ts`):** `issueAuthorizationCode({ db, clientId, tenantId, userId, redirectUri, scope, codeChallenge, codeChallengeMethod })` — mint a random 32-char code via locked `generateOpaqueToken`, store row with `expires_at = now()+10min`. `consumeAuthorizationCode({ db, code })` — atomic: select FOR UPDATE, reject if `used_at IS NOT NULL` or `expires_at < now()`, set `used_at = now()`, return the row. Single-use enforced in one transaction.
- [ ] **Tokens (`tokens.ts`):** `mintTokenPair({ db, oauthClientId, tenantId, userId, scope, familyId? })` — generate opaque `zyk_live_…` access token + `zyk_rt_…` refresh token via `generateOpaqueToken`, store SHA-256 hashes via locked `hashToken`; `family_id` constant within a grant (new UUID if not provided). Returns `{ accessToken, refreshToken, expiresIn: 3600, scope, familyId }`. Access TTL 1h, refresh TTL 60 days.
- [ ] **Refresh rotation + reuse detection:** `rotateRefreshToken({ db, presentedRefreshToken, clientId })` — hash → look up row. If row not found OR `revoked_at IS NOT NULL` OR `rotated_to_id IS NOT NULL` (already-rotated replay): treat as compromise → `revokeFamily(db, familyId)` (revoke all access + refresh tokens sharing `family_id`), set `oauth_connections.flagged_at = now()`, throw `invalid_grant` (reuse detected). Otherwise: mint a new pair in the **same** `family_id`, set old row's `rotated_to_id` to the new refresh token id, return new pair.
- [ ] **Revocation:** `revokeAccessToken(db, tokenHash)`, `revokeRefreshToken(db, tokenHash)` (sets `revoked_at`); `revokeFamily(db, familyId)` (bulk); `revokeClientConnection(db, { oauthClientId, tenantId, userId })` (revoke all tokens for the client+user, delete `oauth_connections` row).
- [ ] **Token introspection:** `resolveOAuthAccessToken(db, presentedToken)` — hash → look up `oauth_access_tokens` joined to client; reject `revoked_at IS NOT NULL` or `expires_at < now()`; return `{ oauthClientId, clientId, tenantId, userId, scope } | null`. Used by the RS middleware (Task 9) and `GET /api/auth/me`.
- [ ] **Connection upsert:** `upsertConnection({ db, oauthClientId, tenantId, userId, scope })` — insert or update on the `UNIQUE(oauth_client_id, tenant_id, user_id)` conflict, bump `last_used_at`/`scope`.
- [ ] **Consent CSRF (`consent-csrf.ts`):** `issueConsentToken(session)` mints a per-session anti-CSRF token (HMAC of session id, independent of the OAuth `state` param) embedded as a hidden field on the GET consent page; `verifyConsentToken(session, presented)` compares via `timingSafeEqual`.
**Schema / Interfaces:**
```ts
export interface OAuthClient {
  id: string; clientId: string; name: string;
  redirectUris: string[]; scopes: ApiScope[];
  isFirstParty: boolean; logoUrl: string | null;
}
export interface MintedTokenPair {
  accessToken: string; refreshToken: string;
  expiresIn: number; scope: string; familyId: string;
}
export interface OAuthTokenContext {
  oauthClientId: string; clientId: string;
  tenantId: string; userId: string; scope: string;
}
export function getOAuthClient(db: DB, clientId: string): Promise<OAuthClient | null>;
export function validateRedirectUri(client: OAuthClient, uri: string): boolean; // exact match
export function verifyClientSecret(client: OAuthClient, secret: string): Promise<boolean>; // timingSafeEqual
export function assertScopesAllowed(client: OAuthClient, requested: ApiScope[]): void; // throws invalid_scope
export function verifyPkce(codeChallenge: string, codeVerifier: string): boolean;
export function issueAuthorizationCode(args: { db: DB; clientId: string; tenantId: string; userId: string; redirectUri: string; scope: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; }): Promise<string>;
export function consumeAuthorizationCode(args: { db: DB; code: string }): Promise<AuthCodeRow>; // throws invalid_grant if used/expired
export function mintTokenPair(args: { db: DB; oauthClientId: string; tenantId: string; userId: string; scope: string; familyId?: string }): Promise<MintedTokenPair>;
export function rotateRefreshToken(args: { db: DB; presentedRefreshToken: string; clientId: string }): Promise<MintedTokenPair>; // family-revoke on reuse
export function revokeFamily(db: DB, familyId: string): Promise<void>;
export function revokeAccessToken(db: DB, tokenHash: string): Promise<void>;
export function revokeRefreshToken(db: DB, tokenHash: string): Promise<void>;
export function revokeClientConnection(db: DB, args: { oauthClientId: string; tenantId: string; userId: string }): Promise<void>;
export function resolveOAuthAccessToken(db: DB, presentedToken: string): Promise<OAuthTokenContext | null>;
export function upsertConnection(args: { db: DB; oauthClientId: string; tenantId: string; userId: string; scope: string }): Promise<void>;
export function issueConsentToken(session: SessionPayload): string;
export function verifyConsentToken(session: SessionPayload, presented: string): boolean;
```
**Acceptance:**
- [ ] An already-rotated refresh token triggers `revokeFamily` and sets `oauth_connections.flagged_at`; all sibling access tokens become unusable.
- [ ] `consumeAuthorizationCode` cannot succeed twice for the same code (single-use, atomic).
- [ ] `validateRedirectUri` rejects a redirect that is a prefix/substring but not an exact match.
- [ ] `verifyClientSecret`, `verifyPkce`, `verifyConsentToken` all use `timingSafeEqual` (no `===` on secret material — passes `no-string-equality-for-tokens` lint).

### Task 4: `GET/POST /oauth/authorize` — consent endpoint (AS)
**Blocks:** 10  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/oauth/authorize.ts`
- Modify: `apps/zync-api/src/routes/oauth/index.ts` (mount)
- Modify: `apps/zync-api/src/app.ts` (register oauth router under app.zync.is)
**Steps:**
- [ ] **`GET /oauth/authorize`** (requires user session via locked `authMiddleware`; if unauthenticated, redirect to login with return-to = the authorize URL). Validate query: `client_id`, `redirect_uri`, `response_type=code` (reject others with `unsupported_response_type`), `scope`, `state`, optional `code_challenge` + `code_challenge_method=S256`.
- [ ] Look up client; if missing → render a safe error page (do **not** redirect to an unvalidated `redirect_uri`). Validate `redirect_uri` via `validateRedirectUri` (exact match) **before** any redirect — open-redirect protection on the error path too (use locked `safeRedirect`).
- [ ] `normalizeRequestedScope(scope)` then `assertScopesAllowed`. On disallowed scope: redirect to `redirect_uri?error=invalid_scope&state=…` (only after redirect_uri validated).
- [ ] If `client.is_first_party` → skip consent UI; immediately issue code + redirect (consent bypass per spec).
- [ ] Otherwise render the consent screen state (workspace name from session tenant, app name + `logo_url`, scope labels from `OAUTH_SCOPE_DEFINITIONS`). Embed a hidden per-session consent-CSRF token via `issueConsentToken`. The actual page is rendered by the Vite app (Task 10); this route returns the data + CSRF token the app needs (or server-renders if the consent route is API-hosted — follow apps/zync-app routing; data contract is the same).
- [ ] **`POST /oauth/authorize`** (authenticated, state-changing): verify consent-CSRF token via `verifyConsentToken` (reject mismatch with 403 before any side effect). Re-validate `client_id`/`redirect_uri`/`scope`. On **Allow**: `issueAuthorizationCode(...)`, redirect `redirect_uri?code={code}&state={state}`. On **Cancel**: redirect `redirect_uri?error=access_denied&state={state}`.
- [ ] PKCE: if client is public (no secret registered behavior) require `code_challenge` present, else `400 invalid_request` ("PKCE required").
**Acceptance:**
- [ ] Unauthenticated GET redirects to login and returns to the authorize URL after login.
- [ ] A mismatched/absent consent-CSRF token on POST is rejected 403 with no code issued.
- [ ] An invalid `client_id` or non-exact `redirect_uri` never produces a redirect to the attacker-supplied URI.
- [ ] First-party client skips the consent screen and issues a code directly.

### Task 5: `POST /oauth/token` — code exchange & refresh (AS)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/oauth/token.ts`
- Modify: `apps/zync-api/src/routes/oauth/index.ts`
**Steps:**
- [ ] Parse `application/x-www-form-urlencoded` body. Apply locked `RATE_LIMITER_AUTH` brute-force limiter keyed by `client_id` + IP before processing.
- [ ] Branch on `grant_type`:
  - [ ] **`authorization_code`:** require `code`, `redirect_uri`, `client_id`; `client_secret` (confidential) or `code_verifier` (public/PKCE). Look up client. If `client_secret` present → `verifyClientSecret`; else require `code_verifier`. `consumeAuthorizationCode` (single-use, not expired). Assert `redirect_uri` **exactly equals** the code's stored `redirect_uri`. If code has `code_challenge` → `verifyPkce(code.code_challenge, code_verifier)`. `upsertConnection`, then `mintTokenPair` (new `family_id`).
  - [ ] **`refresh_token`:** require `refresh_token`, `client_id`. `rotateRefreshToken(...)` (rotation + reuse detection / family revoke). On reuse → `400 { error: "invalid_grant" }`.
  - [ ] Any other grant → `400 { error: "unsupported_grant_type" }`.
- [ ] Response body (snake_case): `{ access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }`. `Cache-Control: no-store`, `Pragma: no-cache`.
- [ ] Error envelope per OAuth: `{ error, error_description? }` with appropriate 400/401 (`invalid_request`, `invalid_client`, `invalid_grant`, `invalid_scope`, `unauthorized_client`).
**Schema / Interfaces:**
```ts
interface TokenResponse {
  access_token: string; token_type: 'Bearer'; expires_in: number;
  refresh_token: string; scope: string;
}
interface OAuthErrorResponse { error: string; error_description?: string; }
```
**Acceptance:**
- [ ] Exchanging a valid code returns a token pair; reusing the same code returns `invalid_grant`.
- [ ] `redirect_uri` not matching the code's stored value returns `invalid_grant`.
- [ ] Public-client exchange without a valid `code_verifier` is rejected; with valid PKCE it succeeds.
- [ ] Refresh returns a rotated refresh token in the same family; replay of the old one revokes the family.
- [ ] Brute-force on `/oauth/token` is rate-limited via `RATE_LIMITER_AUTH`.

### Task 6: `POST /oauth/revoke` — token revocation (AS)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/oauth/revoke.ts`
- Modify: `apps/zync-api/src/routes/oauth/index.ts`
**Steps:**
- [ ] Parse form body `token`, optional `token_type_hint` (`access_token` | `refresh_token`).
- [ ] Hash the presented token via locked `hashToken`. Try the hinted table first; on miss try the other. Set `revoked_at = now()` on the matching row (and for a refresh token, optionally revoke its family per spec intent — at minimum the presented token).
- [ ] Per RFC 7009: always return `200` even if the token is unknown (no token enumeration). Evict the KV cache entry keyed by token hash so revocation takes effect before RS cache TTL.
**Acceptance:**
- [ ] Revoking an access token causes subsequent RS requests with it to fail within the cache TTL bound (cache evicted immediately).
- [ ] Unknown token still returns `200` (no enumeration signal).

### Task 7: User connections endpoints (`/api/oauth/connections`)
**Blocks:** 11  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/oauth/connections.ts`
- Modify: `apps/zync-api/src/routes/oauth/index.ts`
**Steps:**
- [ ] **`GET /api/oauth/connections`** (locked `authMiddleware`): list `oauth_connections` for the current `{ tenantId, userId }` joined to `oauth_clients` → return `{ clientId, name, logoUrl, scope, lastUsedAt, flaggedAt, createdAt }[]`. Scope-decode for display labels via `OAUTH_SCOPE_DEFINITIONS`.
- [ ] **`DELETE /api/oauth/connections/:clientId`** (authenticated): resolve client by `clientId`; `revokeClientConnection(db, { oauthClientId, tenantId, userId })` — revokes all access + refresh tokens for that client+user and removes the connection row. Evict affected KV cache entries.
**Schema / Interfaces:**
```ts
interface OAuthConnectionView {
  clientId: string; name: string; logoUrl: string | null;
  scope: string; lastUsedAt: string | null; flaggedAt: string | null; createdAt: string;
}
```
**Acceptance:**
- [ ] A user sees only their own tenant+user connections.
- [ ] Disconnect revokes all tokens for that client (subsequent API calls with them fail) and removes the row.

### Task 8: Admin OAuth client registration (`/api/admin/oauth/clients`)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/admin/oauth-clients.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts`
**Steps:**
- [ ] All routes guarded by locked `requireAdminSession`.
- [ ] **`GET /api/admin/oauth/clients`** → list clients (never return `client_secret_hash`).
- [ ] **`POST /api/admin/oauth/clients`** → validate body (Zod): `name`, `redirectUris: string[]` (each absolute HTTPS URL), `scopes: ApiScope[]`, `isFirstParty?`, `logoUrl?`. Generate `client_id` (slug + random) and a one-time `client_secret` via `generateOpaqueToken`; store only `client_secret_hash = SHA-256(secret)`. Return the plaintext `client_secret` **once** in the response.
- [ ] **`PATCH /api/admin/oauth/clients/:id`** → update `name`, add/replace `redirectUris`, change `scopes`, toggle `isFirstParty`, `logoUrl`. Optional secret rotation: regenerate secret, return once.
- [ ] Validate every redirect URI is a well-formed absolute URL (no wildcards) before persisting.
**Schema / Interfaces:**
```ts
interface CreateOAuthClientBody {
  name: string; redirectUris: string[]; scopes: ApiScope[];
  isFirstParty?: boolean; logoUrl?: string;
}
interface CreateOAuthClientResponse {
  clientId: string; clientSecret: string; // shown once
  name: string; redirectUris: string[]; scopes: ApiScope[]; isFirstParty: boolean;
}
```
**Acceptance:**
- [ ] Non-admin sessions get 401/403 from every route.
- [ ] `client_secret` returned exactly once on create; never retrievable afterward; only the hash is stored.
- [ ] Malformed/wildcard redirect URI is rejected at create/patch.

### Task 9: Resource-server dual-token auth middleware (`packages/public-api`)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `packages/public-api/src/middleware/auth.ts` (the spec-39 API-key auth middleware)
- Modify: `packages/public-api/src/middleware/scope.ts` (uses locked `hasScope`)
**Steps:**
- [ ] Extend the existing API-key auth middleware into a **unified resolver** (do not create a parallel middleware): extract Bearer token → SHA-256 hash → look up `tenant_api_keys WHERE key_hash = $h AND revoked_at IS NULL`. On **miss**, call `resolveOAuthAccessToken(db, token)` (Task 3) which checks `oauth_access_tokens` rejecting `revoked_at IS NOT NULL` / expired.
- [ ] On API-key match: acting user = `tenant_api_keys.created_by`; scope source = key `scopes` array. On OAuth match: acting user = `oauth_access_tokens.user_id`; scope source = token `scope` string (split to `ApiScope[]`). Both attach `{ tenantId, userId, scope, authKind: 'api_key' | 'oauth' }` to context.
- [ ] KV-cache the OAuth resolution keyed by token hash in `RATELIMIT_KV`/`KV` with TTL ≤ remaining token lifetime (≤ 1h); evicted on revoke (Tasks 6, 7). API-key tier gate + per-key rate limiting from spec 39 are unchanged.
- [ ] Scope enforcement uses the **same** locked `hasScope` for both auth kinds (write implies read). No second vocabulary.
- [ ] `bump`/fire-and-forget `last_used_at`: for OAuth, update `oauth_connections.last_used_at` via `ctx.waitUntil()` (mirrors the API-key fire-and-forget pattern); for API keys, unchanged.
- [ ] **`GET /api/auth/me` introspection:** when called with an OAuth Bearer token, include OAuth token context (`clientId`, granted `scope`, `authKind: 'oauth'`) in the response (token-introspection requirement).
**Schema / Interfaces:**
```ts
interface ResolvedAuth {
  tenantId: string;
  userId: string;             // api_key → created_by; oauth → token.user_id
  scope: ApiScope[];
  authKind: 'api_key' | 'oauth';
  clientId?: string;          // present when authKind === 'oauth'
}
```
**Acceptance:**
- [ ] A valid OAuth access token authenticates `GET /v1/customers` when its scope includes `customers:read` (or `customers:write`).
- [ ] An expired or revoked OAuth token is rejected with the spec-39 `invalid_api_key`/`401` envelope.
- [ ] API-key auth behavior is unchanged (tier gate, per-key rate limit, `created_by` acting user).
- [ ] `GET /api/auth/me` with an OAuth token returns its `clientId` + granted scopes.

### Task 10: Consent screen UI (`/oauth/authorize`)
**Blocks:** —  ·  **Blocked by:** 4, 6
**Files:**
- Create: `apps/zync-app/src/routes/oauth/authorize.tsx`
- Create: `apps/zync-app/src/components/oauth/ConsentScreen.tsx`
**Steps:**
- [ ] Render the consent screen consuming the GET `/oauth/authorize` data: app `logo_url` + name ("{App} wants to access your Zync account"), workspace (tenant) name, requested scope rows from `OAUTH_SCOPE_DEFINITIONS` labels, the "You will remain logged in… revoke from Settings > Integrations" note, and **Cancel** / **Allow access** buttons.
- [ ] Include the hidden consent-CSRF token field; Allow/Cancel submit `POST /oauth/authorize` with the OAuth params preserved (`client_id`, `redirect_uri`, `scope`, `state`, `code_challenge*`).
- [ ] **A11y:** scope list as a semantic `<ul>` with each permission an `<li>`; buttons are real `<button>`; consent form has an accessible name (`aria-labelledby` the heading); focus lands on the heading on mount; Allow/Cancel reachable and operable by keyboard.
- [ ] **i18n/RTL:** all copy via locked `translations` / `LocaleProvider`; layout respects `useDirection` for Hebrew RTL (logical properties, no hardcoded left/right).
- [ ] **prefers-reduced-motion:** any transition on the dialog respects reduced-motion (no essential motion).
- [ ] Styling via locked design-system primitives (`Card`, `Button`, `Stack`) — no hardcoded colors/spacing/radius (honors locked lint rules).
**Acceptance:**
- [ ] Consent screen lists exactly the requested scopes with human labels and the workspace name.
- [ ] Allow redirects to `redirect_uri?code=…&state=…`; Cancel redirects with `error=access_denied`.
- [ ] Keyboard-only user can read scopes and activate Allow/Cancel; RTL renders correctly in Hebrew.

### Task 11: Settings > Integrations — Connected apps panel
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/routes/settings/integrations/connected-apps.tsx`
- Create: `apps/zync-app/src/components/oauth/ConnectedAppsList.tsx`
- Create: `apps/zync-app/src/hooks/useOAuthConnections.ts`
**Steps:**
- [ ] `useOAuthConnections` react-query hook → `GET /api/oauth/connections`; mutation → `DELETE /api/oauth/connections/:clientId` with optimistic removal + invalidation.
- [ ] List each connected app: logo, name, granted scope labels, `lastUsedAt`, and a **Disconnect** action (confirm dialog). Show a security warning badge when `flaggedAt` is set (token reuse detected → compromise).
- [ ] Empty state via locked `EmptyState` ("No connected apps yet").
- [ ] **A11y/i18n/RTL:** table/list semantics, translated copy via `translations`, RTL-aware layout, reduced-motion on the confirm dialog.
- [ ] Use locked design-system primitives only (no hardcoded colors/spacing).
**Acceptance:**
- [ ] Connected apps render with scope labels and last-used time; Disconnect revokes and removes the row from the list.
- [ ] A `flaggedAt` connection shows a visible security warning.

### Task 12: Seed first-party clients & wire bindings
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/seed/oauth-clients.ts`
- Modify: `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] Seed Zync's own first-party OAuth clients (`is_first_party = true`) used by the mobile app / admin tools, with their registered `redirect_uris` and full scope set; store only `client_secret_hash`.
- [ ] Confirm `RATELIMIT_KV` (token-hash cache + `/oauth/token` window) and `RATE_LIMITER_AUTH` bindings are present in `apps/zync-api/wrangler.toml` (reuse existing bindings — do not create new ones).
- [ ] No new secrets required.
**Acceptance:**
- [ ] Seed is idempotent (re-running does not duplicate clients; upsert on `client_id`).
- [ ] First-party seeded client skips consent and completes the flow end-to-end.
