# OAuth 2.0 Authorization Code Flow

**Date:** 2026-06-01
**Status:** Draft
**Spec:** 177
**Tier:** White Label (issuing OAuth apps); Business+ (connecting third-party OAuth apps)
**Depends on:** `foundation-auth-rbac`, `white-label-api`, `tenant-public-api`
**Referenced by:** `zapier-make-integration`, `white-label-api`

---

## Overview

OAuth 2.0 Authorization Code flow for third-party applications to access the Zync API on behalf of a tenant user. This enables Zapier, Make, external tools, and custom integrations to authenticate without storing user credentials.

Zync acts as the **Authorization Server** (AS) + **Resource Server** (RS). Third-party apps are registered as OAuth clients. The resource server is the existing Zync API (`api.zync.is/v1/`).

---

## OAuth Flow Overview

```
Third-party app        Zync AS            Zync RS
─────────────────      ──────────────     ─────────────────
1. Redirect user ────► /oauth/authorize
                       (consent screen)
2.                ◄─── code in redirect
3. Exchange code ────► POST /oauth/token
4.                ◄─── { access_token, refresh_token }
5. API request ────────────────────────► /api/v1/*
                                         (Bearer token)
6.               ◄─────────────────────── response
```

---

## Authorization Server Endpoints

### `GET /oauth/authorize`

User-facing consent screen. Redirect URL for third-party app:

```
https://app.zync.is/oauth/authorize
  ?client_id=zapier_zync
  &redirect_uri=https://zapier.com/dashboard/auth/oauth/return/App1234/
  &response_type=code
  &scope=read:invoices+write:invoices+read:customers
  &state=random_csrf_token
  &code_challenge=S256_PKCE_challenge    (optional PKCE)
  &code_challenge_method=S256
```

**Consent screen UI** (served at `app.zync.is/oauth/authorize`):

```
┌──────────────────────────────────────────────────────────────┐
│  [Zapier Logo]  Zapier wants to access your Zync account     │
│                                                              │
│  Workspace: Acme Design Studio                               │
│                                                              │
│  Zapier is requesting permission to:                         │
│  ✓ View and create invoices                                  │
│  ✓ View your customers                                       │
│                                                              │
│  You will remain logged in to Zync. You can revoke access    │
│  at any time from Settings > Integrations.                   │
│                                                              │
│  [Cancel]                            [Allow access]         │
└──────────────────────────────────────────────────────────────┘
```

On **Allow**: generate `code`, redirect to `redirect_uri?code={code}&state={state}`.

On **Cancel**: redirect to `redirect_uri?error=access_denied&state={state}`.

### `POST /oauth/token`

Exchange authorization code for tokens (server-to-server, no user interaction):

**Request:**
```
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code={authorization_code}
&redirect_uri={must match original}
&client_id=zapier_zync
&client_secret={secret}      (confidential clients)
&code_verifier={pkce_verifier}  (public clients)
```

**Response:**
```json
{
  "access_token": "zyk_live_a1b2c3d4...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "zyk_rt_...",
  "scope": "read:invoices write:invoices read:customers"
}
```

**Token format:**
- Access tokens are **opaque, DB-backed** (random `zyk_live_…` string; only its SHA-256 hash stored in `oauth_access_tokens`). The resource server hashes the presented bearer token and looks it up, rejecting rows with `revoked_at IS NOT NULL` or expired `expires_at`. They are **not** stateless JWTs — that is required for `/oauth/revoke` to take effect before expiry (a stateless JWT cannot be revoked). The 1-hour TTL keeps the per-request lookup cheap and KV-cacheable (cache keyed by token hash, TTL ≤ token lifetime, evicted on revoke).
- Refresh tokens: opaque, stored hashed in `oauth_refresh_tokens`; rotated on use (see Security → refresh rotation).

### `POST /oauth/token` (refresh)

```
grant_type=refresh_token
&refresh_token={token}
&client_id=zapier_zync
```

Response: new `access_token` + rotated `refresh_token`.

### `POST /oauth/revoke`

Revoke access or refresh token:
```
token={token_value}
&token_type_hint=access_token|refresh_token
```

---

## Data Model

```sql
CREATE TABLE oauth_clients (
  client_id       TEXT PRIMARY KEY,
  client_secret_hash TEXT NOT NULL,         -- SHA-256 of client_secret; only hash stored
  name            TEXT NOT NULL,
  redirect_uris   JSONB NOT NULL,           -- allowed redirect URIs
  scopes          JSONB NOT NULL,           -- allowed scopes
  is_first_party  BOOLEAN NOT NULL DEFAULT false,  -- Zync-owned apps skip consent screen
  logo_url        TEXT,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE oauth_authorization_codes (
  code            TEXT PRIMARY KEY,         -- random 32-char code
  client_id       TEXT NOT NULL REFERENCES oauth_clients(client_id),
  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,
  code_challenge  TEXT,                     -- PKCE S256 challenge (base64url)
  expires_at      TIMESTAMPTZ NOT NULL,     -- 10 minutes
  used_at         TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE oauth_access_tokens (
  token_hash      TEXT PRIMARY KEY,         -- SHA-256 of token
  client_id       TEXT NOT NULL,
  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,
  expires_at      TIMESTAMPTZ NOT NULL,     -- 1 hour
  revoked_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE oauth_refresh_tokens (
  token_hash      TEXT PRIMARY KEY,
  client_id       TEXT NOT NULL,
  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,           -- token-family id; constant across rotations of one grant
  rotated_to      TEXT,                    -- token_hash of the successor (set when this token is rotated/consumed)
  expires_at      TIMESTAMPTZ NOT NULL,     -- 60 days
  revoked_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_oauth_rt_family ON oauth_refresh_tokens(family_id);

-- Active OAuth app connections (for Settings > Integrations display)
CREATE TABLE oauth_connections (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id   TEXT NOT NULL REFERENCES oauth_clients(client_id),
  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,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  last_used_at TIMESTAMPTZ,
  UNIQUE (client_id, tenant_id, user_id)
);
```

---

## Scope Definitions

| Scope | Access |
|-------|--------|
| `read:invoices` | GET /api/v1/invoices, /api/v1/invoices/:id |
| `write:invoices` | POST/PATCH/DELETE /api/v1/invoices/:id |
| `read:customers` | GET /api/v1/customers |
| `write:customers` | POST/PATCH /api/v1/customers |
| `read:leads` | GET /api/v1/leads |
| `write:leads` | POST/PATCH /api/v1/leads |
| `read:time` | GET /api/v1/time |
| `write:time` | POST/PATCH /api/v1/time |
| `read:tasks` | GET /api/v1/tasks |
| `write:tasks` | POST/PATCH /api/v1/tasks |
| `read:projects` | GET /api/v1/projects |
| `read:expenses` | GET /api/v1/expenses |

Scopes are enforced by middleware in the resource server (same middleware as API key scopes in spec 39).

---

## API

```
GET  /oauth/authorize               → consent screen (GET = render UI)
POST /oauth/authorize               → process user consent (Allow / Cancel)
POST /oauth/token                   → exchange code or refresh token
POST /oauth/revoke                  → revoke token

GET  /api/oauth/connections         → list active OAuth app connections for user
DELETE /api/oauth/connections/:clientId → revoke all tokens for a client
       (same as "Disconnect" in Settings > Integrations)
```

Admin-only (for registering new OAuth clients):
```
GET  /api/admin/oauth/clients       → list OAuth clients
POST /api/admin/oauth/clients       → register new client
PATCH /api/admin/oauth/clients/:id  → update client (add redirect URI, change scopes)
```

---

## Security

- **PKCE required** for public clients (no client_secret); recommended for confidential clients
- **State parameter** required; validated by the *client* to prevent CSRF on the redirect — this protects the third-party app, **not** Zync's consent form (see next item)
- **Consent-form CSRF**: `POST /oauth/authorize` (Allow/Cancel) is an authenticated, state-changing request on `app.zync.is` and carries its **own** per-session anti-CSRF token (hidden field bound to the user session), independent of the OAuth `state` param. Reject the POST on token mismatch.
- **Code TTL**: 10 minutes, single-use
- **Redirect URI must match** exactly one of `oauth_clients.redirect_uris` — **exact string match** (scheme + host + path + port), no prefix/substring matching and no open-redirect via `redirect_uri` on the error path either
- **Refresh-token rotation + reuse detection**: each `/oauth/token` refresh issues a new refresh token in the same `family_id` and sets `rotated_to` on the old one. Presenting an already-rotated or revoked refresh token (replay) is treated as compromise: **revoke the entire token family** (all refresh + access tokens sharing `family_id`) and flag the `oauth_connection`. Rotation alone is insufficient without this.
- **Consent screen bypass** for `is_first_party = true` clients (Zync's own mobile app, admin tools)
- **Token introspection**: `GET /api/auth/me` returns OAuth token context when called with OAuth Bearer token

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Authorization Code flow | Not Implicit or Client Credentials | Authorization Code is the recommended OIDC/OAuth 2.1 flow for user-delegated access; Implicit is deprecated; Client Credentials is for machine-to-machine without user context |
| PKCE support | Required for public clients | Prevents authorization code interception attacks; RFC 7636; required by OAuth 2.1 for all clients |
| Opaque DB-backed access tokens | Not stateless JWT | Revocability is required (`/oauth/revoke`, Disconnect, family-revoke on reuse); a stateless JWT cannot be revoked pre-expiry. 1h TTL + KV cache on token hash keeps lookups cheap |
| Refresh rotation with family revoke | Not rotation alone | Rotation without reuse detection still lets a stolen refresh token be replayed once; family revoke on replay closes it (RFC 6819 §5.2.2.3) |
| Scope as string | Not JSONB array | Simple space-separated string matches OAuth RFC; easier to compare and validate |
| White Label tier for issuing apps | Not all tiers | Registering custom OAuth clients is a developer/platform feature relevant to white-label scenarios and API partners; standard Business+ users connect existing apps (Zapier/Make) without needing to register new clients |
