This document describes the OAuth2 authentication and API key management system for the pdbfe stack.
Authentication is split across two Cloudflare Workers and the frontend SPA:
| Component | Role | Storage |
|---|---|---|
| pdbfe-auth | OAuth ceremony, session CRUD, account/key management | SESSIONS KV (rw), USERDB D1 (rw) |
| pdbfe-api | Session + API key verification | SESSIONS KV (ro), USERDB D1 (ro) |
| Frontend (Pages) | Token storage, UI state, /account SPA page | localStorage |
User clicks "Sign in with PeeringDB"
│
▼
pdbfe-auth /auth/login
│ Generates CSRF state nonce → HttpOnly cookie (5min Max-Age)
│ Redirects to auth.peeringdb.com/oauth2/authorize/
▼
PeeringDB authorize page (user logs in)
│
▼
pdbfe-auth /auth/callback?code=...&state=...
│ 1. Validate state nonce (cookie must match URL state parameter)
│ 2. POST code → auth.peeringdb.com/oauth2/token/ → access_token
│ (with Api-Key header to bypass PeeringDB WAF — see below)
│ 3. GET auth.peeringdb.com/profile/v1 → user profile
│ 4. Generate session ID (32 random bytes, hex)
│ 5. KV.put("session:<sid>", {profile}, TTL=24h)
│ 6. Clear oauth_state cookie
│ 7. Redirect to frontend/?sid=<session_id>
▼
Frontend picks up ?sid= from URL query parameters
│ Stores in localStorage, strips param from URL
│ Validates via GET pdbfe-auth /auth/me
│ Renders user name + "Account" + "Sign out" in header
▼
Subsequent API requests include:
Authorization: Bearer <sid>
│
▼
pdbfe-api resolves session:
extractSessionId(request) → sid
resolveSession(env.SESSIONS, sid) → SessionData | null
authenticated = session !== null
PeeringDB's WAF blocks Cloudflare Worker subrequests that lack an
Authorization header. The token exchange sends OAuth client credentials
in the POST body (for Django OAuth Toolkit) and a PEERINGDB_API_KEY
in the Authorization: Api-Key header (to satisfy the WAF).
Session tokens are passed via URL query parameters (?sid=...) rather than
URL fragments (#sid=...) because Cloudflare Access strips fragments during
its redirect chain.
Users can generate pdbfe.-prefixed API keys via the /account page.
These keys are independent of PeeringDB credentials and grant
authenticated = true status on the mirror's API.
Client sends: Authorization: Api-Key pdbfe.<32 hex chars>
│
▼
pdbfe-api:
extractApiKey(request) → "pdbfe.<hex>"
verifyApiKey(env.USERDB, key) → boolean
│ 1. Check in-memory per-isolate cache (5min TTL)
│ 2. If miss: SELECT 1 FROM api_keys WHERE hash = SHA-256(key)
│ 3. Cache result (positive and negative)
│
if valid → authenticated = true
if invalid → fall through to session check or anonymous
- Full key:
pdbfe.<32 hex characters>(e.g.pdbfe.a1b2c3d4...) - Key ID: First 8 hex characters (for identification in delete requests)
- Display prefix: First 4 hex characters (shown in the UI as
pdbfe.a1b2…) - Maximum 5 keys per user
| Key Pattern | Value | TTL | Written By |
|---|---|---|---|
session:<hex64> |
JSON SessionData | 24 hours | pdbfe-auth |
User profiles and API keys are stored in a dedicated D1 database, separate from the PeeringDB mirror data. This ensures user data is never affected by mirror rebuilds and provides ACID guarantees on key operations.
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | PeeringDB user ID |
name |
TEXT | Display name |
email |
TEXT | Email address |
preferences |
TEXT | JSON preferences ({ language?, ... }) |
created_at |
TEXT | ISO 8601 timestamp |
updated_at |
TEXT | ISO 8601 timestamp |
| Column | Type | Description |
|---|---|---|
key_id |
TEXT (PK) | First 8 hex chars of key |
user_id |
INTEGER (PK, FK) | References users.id |
label |
TEXT | User-assigned label |
prefix |
TEXT | First 4 hex chars (for UI display) |
hash |
TEXT (UNIQUE) | SHA-256 hex digest of the full key |
created_at |
TEXT | ISO 8601 timestamp |
The idx_api_keys_hash index on hash serves the API worker's key
verification hot path. The full key is never stored.
| Column | Type | Description |
|---|---|---|
user_id |
INTEGER (PK, FK) | References users.id |
entity_type |
TEXT (PK) | Entity type tag (net, ix, fac, org, carrier, campus) |
entity_id |
INTEGER (PK) | Entity ID in the mirror database |
label |
TEXT | Cached display name |
created_at |
TEXT | ISO 8601 timestamp |
Composite PK (user_id, entity_type, entity_id) prevents duplicates.
idx_user_favorites_list on (user_id, created_at DESC) supports
ordered listing. Maximum 50 favorites per user (application-enforced).
Lookup table defining valid preference keys and values. Used by both
the GET /account/preferences/options (public) and
PUT /account/profile (validation) endpoints.
| Column | Type | Description |
|---|---|---|
pref_key |
TEXT (PK) | Preference key (language, theme, timezone) |
pref_value |
TEXT (PK) | Valid value for that key |
Seeded by migration 0002_preference_options.sql with:
language: en, cs, de, el, es, fr, it, ja, lt, pt, ro, ru, zh-cn, zh-twtheme: auto, dark, lighttimezone: IANA timezone names
{
"id": 3,
"name": "Matt Griswold",
"given_name": "Matt",
"family_name": "Griswold",
"email": "grizz@20c.com",
"verified_user": true,
"verified_email": true,
"networks": [
{ "perms": 15, "asn": 63311, "name": "20C", "id": 20 }
],
"created_at": "2026-04-05T18:00:00.000Z"
}Served by pdbfe-auth under /account/*. All endpoints require a valid
session (Authorization: Bearer <sid>).
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/account/preferences/options |
GET | No | Available preference keys/values (language, theme, timezone) |
/account/profile |
GET | Yes | Return user profile + preferences from USERDB D1 |
/account/profile |
PUT | Yes | Update profile name and/or preferences |
/account/keys |
GET | Yes | List API keys (label + prefix, no full keys) |
/account/keys |
POST | Yes | Generate new API key, return full key once |
/account/keys/:id |
DELETE | Yes | Revoke an API key |
/account/favorites |
GET | Yes | List favorited entities |
/account/favorites |
POST | Yes | Add a favorite (entity_type, entity_id, label) |
/account/favorites |
PUT | Yes | Replace entire favorites list (reorder) |
/account/favorites/:type/:id |
DELETE | Yes | Remove a favorite |
User records are auto-provisioned on first OAuth login if missing.
Preference validation is data-driven: PUT /account/profile checks each
preference key/value pair against preference_options via D1 query.
No hardcoded lists in the worker code.
The OAuth2 flow is split between a generic core factory and a PeeringDB-specific instantiation:
core/oauth.js auth/handlers/oauth.js
─────────────────────────────── ──────────────────────────────────────
createOAuthHandler(config) → const strings: PDB_AUTHORIZE_URL etc.
handleLogin(req, env, origin?) parsePeeringDbProfile(profile)
handleCallback(req, env) resolveReturnOrigin(request, env)
handleLogout(req, env) getOAuthHandler(env) → lazy singleton
handleMe(req, env) handleAuth(request, env) → 5 lines
handleOAuth(req, env, fn?)
createOAuthHandler accepts an OAuthHandlerConfig object:
authorizeUrl,tokenUrl,profileUrl,scopes— provider endpointscookiePrefix— names the CSRF cookies (e.g.'pdbfe_oauth'→pdbfe_oauth_state)tokenHeaders,profileHeaders— extra request headers (WAF bypass, User-Agent)parseProfile(profile)— returns{ valid, sessionData }or{ valid: false, error }getCorsHeaders(request, env)— CORS headers for/auth/meresponses
The auth worker's handleAuth initialises the singleton on first call (when env is available), then reuses it for the isolate lifetime. handleOAuth extracts the pathname from request.url and dispatches accordingly, calling resolveReturnOrigin(request, env) (supplied by the auth worker) to determine the validated return origin for the login redirect.
- CSRF protection: Double Submit Cookie pattern — state nonce is set as an
HttpOnly; Secure; SameSite=Laxcookie during/auth/loginand verified against the URLstateparameter in/auth/callback. No server-side state storage needed. Cookie is cleared after use. - CORS origin matching:
resolveAllowedOrigin()reflects the request'sOriginheader when it matches the productionFRONTEND_ORIGINor any Cloudflare Pages preview subdomain (*.pdbfe-frontend.pages.dev). This allows branch preview deployments to use the auth API without CORS failures. Non-matching origins fall back to the production origin. - Session ID: 32 bytes from
crypto.getRandomValues, making brute-force infeasible. - Query parameters: Session IDs are passed via
?sid=(survives Cloudflare Access redirects). The frontend strips the query param immediately after reading it. - Token storage: Frontend stores session ID in
localStorage. Not accessible to other origins. - OAuth secrets:
OAUTH_CLIENT_ID,OAUTH_CLIENT_SECRET, andPEERINGDB_API_KEYare stored as wrangler secrets, not in source control. - Verified users only: Unverified PeeringDB accounts are rejected at the callback step.
- API key display: Full API keys are shown only once at creation. Only the 4-char prefix is stored.
- ACID key operations: API key creation and deletion use D1
batch()for atomic multi-statement execution, preventing orphaned reverse-index entries ("Ghost Keys").
| Secret | Worker | Purpose |
|---|---|---|
OAUTH_CLIENT_ID |
pdbfe-auth | PeeringDB OAuth application ID |
OAUTH_CLIENT_SECRET |
pdbfe-auth | PeeringDB OAuth application secret |
PEERINGDB_API_KEY |
pdbfe-auth | Used in Authorization header to bypass PeeringDB WAF |
workers/auth/index.js— Router: path prefix matching, delegates to handlersworkers/auth/http.js— Shared CORS, preflight, response helpers, session resolution, user record helpersworkers/auth/handlers/index.js— Barrel re-exports: handleAuth, handlePreferences, handleProfile, handleKeys, handleFavoritesworkers/auth/handlers/oauth.js— PeeringDB-specific config (URLs, WAF headers, profile validation) + thinhandleAuthrouter. Delegates all OAuth2 protocol logic tocore/oauth.js.workers/auth/handlers/profile.js— handlePreferences + handleProfile: preference options, profile GET/PUTworkers/auth/handlers/keys.js— handleKeys: API key list, create, revokeworkers/auth/handlers/favorites.js— handleFavorites: favorites list, add, replace, remove
workers/core/auth.js— Session resolution, API key verification (with in-memory cache), session lifecycleworkers/core/oauth.js— Generic OAuth2 Authorization Code flow factory (createOAuthHandler). Provider-agnostic — responsible for CSRF cookies, token exchange, profile fetch, session creation, and routing. Configured entirely viaOAuthHandlerConfiginjected at instantiation.
workers/api/index.js— Authentication block: tries API-Key, then session
frontend/js/auth.js— Session state management, UI renderingfrontend/js/api.js— AttachesAuthorization: Bearerheader when session existsfrontend/js/pages/account.js— Account page with profile, networks, API key management
workers/wrangler-auth.toml— Auth worker config (SESSIONS KV, USERDB D1, OAuth vars)workers/wrangler.toml— API worker config (SESSIONS KV, USERDB D1 for read)workers/types.d.ts— PdbAuthEnv, PdbApiEnv, SessionData, UserRecord, ApiKeyEntry types