# SSO Flow — @ropean/sso-client

This document explains what happens end-to-end when a user logs in, stays logged in, logs out, and gets force-logged-out — from browser request to session store, with all token handling in between.

---

## Table of Contents

1. [Architecture Overview](#architecture-overview)
2. [Endpoints Exposed by the Library](#endpoints-exposed-by-the-library)
3. [Flow 1 — Login (Authorization Code + PKCE S256)](#flow-1--login)
4. [Flow 2 — Authenticated Request & Transparent Token Refresh](#flow-2--authenticated-request--transparent-token-refresh)
5. [Flow 3 — Logout (Front-Channel)](#flow-3--logout-front-channel)
6. [Flow 4 — Backchannel Logout (Server-to-Server)](#flow-4--backchannel-logout)
7. [Session Store](#session-store)
8. [Cookie & Secret Design](#cookie--secret-design)
9. [Config Reference](#config-reference)

---

## Architecture Overview

```
Browser
  │
  │  HTTP (cookie-authenticated)
  ▼
Client App  ──── /auth/*  ────►  @ropean/sso-client  (this library)
  │                                    │  │
  │  protected routes                  │  │  HTTPS
  │  sso.authenticate() middleware      │  ▼
  │                               SSO Provider  (e.g. sso.ropean.org)
  │                                    │
  │                               issues JWTs, manages users
  │
  └── /api/*  — resolveAuth() or authenticate() — reads session from store
```

The library is **stateful on the client side**: it keeps a session record (access token, encrypted refresh token, id\_token) in a session store (SQLite / Cloudflare KV / in-memory). The session is keyed by a `sid` that is HMAC-signed and stored in an HttpOnly cookie.

---

## Endpoints Exposed by the Library

Mount the library at `/auth` (e.g. `app.route('/auth', sso.router)` or `sso.handleAt(request, '/auth')`). The following paths become available:

| Method | Path                       | Purpose                                            |
| ------ | -------------------------- | -------------------------------------------------- |
| GET    | `/auth/login`              | Start login; accepts `?return_to=/path`            |
| GET    | `/auth/initiate`           | Third-party-initiated login (OIDC login hint)      |
| GET    | `/auth/callback`           | OAuth callback — exchange code, create session     |
| GET    | `/auth/logout`             | Front-channel logout — revoke tokens, clear cookie |
| POST   | `/auth/backchannel-logout` | Server-to-server forced logout from SSO provider   |
| GET    | `/auth/me`                 | Current user claims (null if unauthenticated)      |
| POST   | `/auth/refresh`            | Manual token refresh (API clients)                 |

IDP endpoints are derived from `OAUTH_ISSUER`:

| IDP endpoint           | Derived path                     |
| ---------------------- | -------------------------------- |
| Authorization endpoint | `{issuer}/authorize`             |
| Token endpoint         | `{issuer}/token`                 |
| Revocation endpoint    | `{issuer}/revoke`                |
| Logout endpoint        | `{issuer}/sso/logout`            |
| JWKS endpoint          | `{issuer}/.well-known/jwks.json` |

---

## Flow 1 — Login

**Authorization Code flow with PKCE S256.** No client secret is ever sent to the browser.

```
Browser              Client App (/auth/login)        SSO Provider
  │                        │                               │
  │─── GET /auth/login ───►│                               │
  │   (?return_to=/dash)   │                               │
  │                        │  1. generate state, nonce     │
  │                        │  2. generate PKCE pair        │
  │                        │     verifier  = 64 random bytes (base64url)
  │                        │     challenge = SHA-256(verifier) (base64url)
  │                        │  3. store {verifier, nonce, returnTo}
  │                        │     in session store, keyed by state
  │                        │     TTL = 10 minutes
  │                        │                               │
  │◄── 302 redirect ───────│                               │
  │    Location: {issuer}/authorize                        │
  │    ?response_type=code                                 │
  │    &client_id=…                                        │
  │    &redirect_uri=…                                     │
  │    &scope=openid profile email offline_access          │
  │    &state={random hex 16}                              │
  │    &nonce={random hex 16}                              │
  │    &code_challenge={SHA-256 of verifier}               │
  │    &code_challenge_method=S256                         │
  │                        │                               │
  │─── GET /authorize ─────────────────────────────────────►│
  │                        │                               │
  │   (user authenticates at SSO UI)                       │
  │                        │                               │
  │◄── 302 /auth/callback ──────────────────────────────────│
  │    ?code={auth_code}&state={same state}                │
  │                        │                               │
  │─── GET /auth/callback ─►│                              │
  │                        │  4. look up PKCE record by state
  │                        │     (consumed = deleted from store)
  │                        │  5. verify state matches      │
  │                        │                               │
  │                        │─── POST {issuer}/token ──────►│
  │                        │    grant_type=authorization_code
  │                        │    code={auth_code}           │
  │                        │    code_verifier={verifier}   │
  │                        │    client_id + client_secret  │
  │                        │                               │
  │                        │◄── {access_token, id_token, refresh_token, expires_in}
  │                        │                               │
  │                        │  6. verify id_token signature (JWKS)
  │                        │     check aud = client_id, nonce matches
  │                        │     extract sub
  │                        │  7. require refresh_token
  │                        │     (offline_access scope must be in request)
  │                        │  8. generate sid (32 random bytes hex)
  │                        │  9. encrypt refresh_token with AES-256-GCM
  │                        │     key derived from SESSION_SECRET
  │                        │ 10. store SessionRecord in session store:
  │                        │     {sid, sub, access_token, refresh_token_enc,
  │                        │      id_token, access_expires_at, refresh_expires_at}
  │                        │ 11. write HttpOnly session cookie
  │                        │     value = HMAC-SHA256(sid) — not the sid itself
  │                        │                               │
  │◄── 302 {returnTo} ─────│                              │
  │    Set-Cookie: sso_sid=…; HttpOnly; SameSite=Lax      │
```

**Key security properties:**

- `code_verifier` never leaves the server — PKCE prevents code interception attacks
- `state` is single-use (consumed on first use) — prevents CSRF and replay
- `nonce` ties the id\_token to this specific auth request — prevents id\_token replay
- The refresh token is AES-256-GCM encrypted at rest in the session store
- The cookie value is HMAC of sid, not the sid itself — prevents forgery without the secret

---

## Flow 2 — Authenticated Request & Transparent Token Refresh

Every request to a protected route passes through `sso.authenticate()` middleware (Hono) or `sso.resolveAuth(request)` (CF Pages Functions / Vercel). Both do the same thing:

```
Browser                    Client App                  SSO Provider
  │                             │                           │
  │─── GET /dashboard ─────────►│                           │
  │    Cookie: sso_sid=…        │                           │
  │                             │  1. decode cookie         │
  │                             │     HMAC-verify → extract sid
  │                             │  2. getSession(sid)       │
  │                             │     → SessionRecord       │
  │                             │  3. check refresh_expires_at
  │                             │     if expired → clear cookie → 401
  │                             │                           │
  │                             │  4. check access_expires_at
  │                             │     if within 2 min of expiry → refresh
  │                             │                           │
  │    ┌── token still valid ───┤                           │
  │    │   skip to step 8       │                           │
  │    │                        │                           │
  │    └── token near expiry ───┤                           │
  │                             │  5. tryLockSession(sid)   │
  │                             │     (prevents thundering herd on edge)
  │                             │     if not acquired → wait for winner
  │                             │                           │
  │                             │  6. decrypt refresh_token  │
  │                             │─── POST {issuer}/token ──►│
  │                             │    grant_type=refresh_token
  │                             │    refresh_token=…        │
  │                             │                           │
  │                             │◄── {access_token, id_token?, refresh_token?, expires_in}
  │                             │                           │
  │                             │  7. rotateSession(sid, …) │
  │                             │     new access_token, encrypted new refresh_token
  │                             │     new id_token (if returned)
  │                             │     update access_expires_at, refresh_expires_at
  │                             │     unlockSession(sid)    │
  │                             │                           │
  │                             │  8. decodeJwt(id_token)   │
  │                             │     (no signature re-verify — already trusted at login)
  │                             │     → UserClaims: sub, name, email, role, …
  │                             │  9. set c.get('user'), c.get('auth')
  │                             │                           │
  │◄── 200 /dashboard ──────────│                           │
  │    Set-Cookie: sso_sid=… (refreshed, if token was rotated)
```

**Thundering herd protection:** On edge runtimes (Cloudflare Workers), multiple concurrent requests can hit a near-expired token simultaneously. The `tryLockSession` / `waitForSession` mechanism ensures only one request performs the refresh; others wait and receive the rotated session record from the winner.

**`invalid_grant` race:** If the SSO server says the refresh token is already used (race across replicas), the library retries `getSession` up to 3 times with 200/400/600 ms backoff, looking for a session that was already rotated by another request.

---

## Flow 3 — Logout

Front-channel logout initiated by the user clicking "Sign out":

```
Browser              Client App (/auth/logout)        SSO Provider
  │                        │                               │
  │─── GET /auth/logout ──►│                               │
  │    Cookie: sso_sid=…   │                               │
  │                        │  1. read sid from cookie      │
  │                        │  2. getSession(sid)           │
  │                        │     → save id_token_hint      │
  │                        │  3. decrypt refresh_token     │
  │                        │─── POST {issuer}/revoke ─────►│
  │                        │    token={refresh_token}      │
  │                        │    (best-effort, errors ignored)
  │                        │                               │
  │                        │  4. deleteSession(sid)        │
  │                        │  5. clear cookie              │
  │                        │     Set-Cookie: sso_sid=; Max-Age=0
  │                        │                               │
  │◄── 302 redirect ───────│                               │
  │    Location: {issuer}/sso/logout                       │
  │    ?id_token_hint={id_token}                           │
  │    &post_logout_redirect_uri={PUBLIC_ORIGIN}/          │
  │    &state={random}                                     │
  │                        │                               │
  │─── GET /sso/logout ────────────────────────────────────►│
  │                        │  (SSO provider clears its own session,
  │                        │   may trigger backchannel-logout to other clients)
  │                        │                               │
  │◄── 302 {post_logout_redirect_uri} ─────────────────────│
```

The `id_token_hint` lets the SSO provider identify which user session to terminate without requiring the user to re-authenticate.

---

## Flow 4 — Backchannel Logout

When a user logs out from the SSO provider directly (or another client triggers global logout), the SSO provider calls each registered client's backchannel-logout URI server-to-server:

```
SSO Provider                     Client App (/auth/backchannel-logout)
     │                                        │
     │─── POST /auth/backchannel-logout ──────►│
     │    Content-Type: application/x-www-form-urlencoded
     │    logout_token={signed JWT}            │
     │                                         │
     │                                         │  1. parse logout_token from body
     │                                         │  2. verifyBackchannelLogoutToken(logout_token)
     │                                         │     → verify signature (JWKS)
     │                                         │     → check aud = client_id
     │                                         │     → extract sub and sid
     │                                         │
     │                                         │  if sid present:
     │                                         │    3a. revokeBySid(sid)
     │                                         │        delete session from store
     │                                         │    3b. markSidRevoked(sid, now + 24h)
     │                                         │        add to denylist (for Bearer tokens
     │                                         │        that may carry sid claim)
     │                                         │
     │                                         │  if only sub present (no sid):
     │                                         │    3c. revokeBySub(sub)
     │                                         │        delete ALL sessions for this user
     │                                         │
     │◄── 204 No Content ─────────────────────│
```

The denylist (`markSidRevoked`) is consulted on every Bearer token auth check (`isSidRevoked`), so even a still-valid JWT with a revoked `sid` claim is rejected.

---

## Session Store

The session store is a pluggable interface. All implementations satisfy:

```typescript
interface SessionStore {
  // PKCE (short-lived, consumed on use)
  createPkce(state, rec)    → void
  consumePkce(state)        → PkceRecord | null   // deletes on read

  // Sessions
  createSession(rec)        → void
  getSession(sid)           → SessionRecord | null
  rotateSession(sid, patch) → SessionRecord | null
  deleteSession(sid)        → void

  // Revocation
  revokeBySid(sid)          → void
  revokeBySub(sub)          → number   // rows deleted
  markSidRevoked(sid, exp)  → void     // denylist entry
  isSidRevoked(sid)         → boolean

  // Maintenance
  purgeExpired(now)         → void
  close()                   → void

  // Optional (distributed lock for token refresh)
  tryLockSession?(sid)      → { acquired, token }
  unlockSession?(sid, tok)  → void
  waitForSession?(sid, ms)  → SessionRecord | null
}
```

| Store                             | Runtime              | Notes                                  |
| --------------------------------- | -------------------- | -------------------------------------- |
| `@ropean/sso-client/store/sqlite` | Node.js, Vercel Node | Persistent; requires `better-sqlite3`  |
| `@ropean/sso-client/store/kv`     | CF Workers, CF Pages | Pass `env.AUTH_KV` KV binding          |
| `@ropean/sso-client/store/memory` | Any (testing)        | Resets on restart; no distributed lock |

**What is stored per session:**

| Field              | Type         | Description                                                   |
| ------------------ | ------------ | ------------------------------------------------------------- |
| `sid`              | string       | 32-byte random hex; the session key                           |
| `sub`              | string       | User subject from the IDP                                     |
| `accessToken`      | string       | Current access token (JWT)                                    |
| `refreshTokenEnc`  | Uint8Array   | AES-256-GCM ciphertext of the refresh token                   |
| `idToken`          | string\|null | OIDC id\_token; carries user claims (name, email, role, etc.) |
| `accessExpiresAt`  | number (ms)  | When access\_token expires                                    |
| `refreshExpiresAt` | number (ms)  | When the session as a whole expires (= cookie lifetime)       |
| `createdAt`        | number (ms)  | Session creation time                                         |
| `rotatedAt`        | number (ms)  | Last token rotation time (used for race detection)            |
| `userAgent`        | string\|null | Browser user-agent at login time                              |
| `ip`               | string\|null | Client IP at login time                                       |

---

## Cookie & Secret Design

**Cookie value:** `HMAC-SHA256(sid, SESSION_SECRET)`, hex-encoded. The `sid` itself is never written to the cookie — even if the cookie is intercepted, it cannot be used to forge a different sid.

**Cookie attributes (defaults):**

| Attribute  | Default        | Notes                                                                              |
| ---------- | -------------- | ---------------------------------------------------------------------------------- |
| `HttpOnly` | always         | Not accessible from JavaScript                                                     |
| `SameSite` | `Lax`          | Override with `COOKIE_SAMESITE=Strict\|None`                                       |
| `Secure`   | `true` in prod | Auto-detected via `NODE_ENV=production`; override with `COOKIE_SECURE=true\|false` |
| `Max-Age`  | 30 days        | Override with `COOKIE_MAX_AGE_SEC`                                                 |
| `Path`     | `/`            | Not configurable                                                                   |
| `Domain`   | (not set)      | Set with `COOKIE_DOMAIN` for cross-subdomain sharing                               |
| Name       | `sso_sid`      | Override with `COOKIE_NAME`                                                        |

**Refresh token encryption:** AES-256-GCM with a key derived from `SESSION_SECRET` using HKDF. The IV (12 bytes) and auth tag are prepended to the ciphertext.

---

## Config Reference

All config is loaded from environment variables. Pass `process.env` (Node.js) or the CF Workers `env` object to `createSso({ env, store })`.

### Required

| Variable              | Description                                                                      |
| --------------------- | -------------------------------------------------------------------------------- |
| `OAUTH_ISSUER`        | OIDC issuer URL — e.g. `https://sso.ropean.org/oauth`                            |
| `OAUTH_CLIENT_ID`     | OAuth client ID registered with the SSO provider                                 |
| `OAUTH_CLIENT_SECRET` | OAuth client secret                                                              |
| `SESSION_SECRET`      | Random string ≥ 32 chars. Used for HMAC (cookie signing) and AES key derivation. |

### Auto-derived (set one, skip the other)

| Variable                         | Default when omitted               |
| -------------------------------- | ---------------------------------- |
| `OAUTH_REDIRECT_URI`             | `{PUBLIC_ORIGIN}/auth/callback`    |
| `OAUTH_POST_LOGOUT_REDIRECT_URI` | `{PUBLIC_ORIGIN}/`                 |
| `PUBLIC_ORIGIN`                  | — (set this to skip the two above) |

### Optional

| Variable              | Default                               | Description                                                       |
| --------------------- | ------------------------------------- | ----------------------------------------------------------------- |
| `OAUTH_SCOPES`        | `openid profile email offline_access` | Space-separated. `offline_access` is required for refresh tokens. |
| `OAUTH_AUDIENCE`      | same as `OAUTH_CLIENT_ID`             | `aud` claim validated in access tokens                            |
| `COOKIE_NAME`         | `sso_sid`                             | Session cookie name                                               |
| `COOKIE_DOMAIN`       | (not set)                             | Set for cross-subdomain sharing                                   |
| `COOKIE_SECURE`       | `true` when `NODE_ENV=production`     | Force `Secure` flag on cookie                                     |
| `COOKIE_SAMESITE`     | `Lax`                                 | `Lax`, `Strict`, or `None`                                        |
| `COOKIE_MAX_AGE_SEC`  | `2592000` (30 days)                   | Session lifetime = refresh token lifetime                         |
| `SSO_REFRESH_SKEW_MS` | `120000` (2 minutes)                  | Renew access token this many ms before expiry                     |
| `SESSION_DB_PATH`     | `./sso.db`                            | SQLite file path (Node.js only)                                   |
| `IS_DEBUG`            | `false`                               | Log backchannel-logout decisions to console                       |
