OAuth routes
Source: src/auth/oauth-routes.ts
/auth/:platform/start (authenticated) and /auth/:platform/callback (unauthenticated). Phase 2 only enables platform = 'google'; Phase 3 lifts the 501 for meta and tiktok.
/auth/:platform/start
- Validates
platform∈ {google, meta, tiktok}; refuses anything else with 400. - Refuses
meta/tiktokwith 501 (Phase 3). - Requires
req.tenantId(the tenant middleware extends auth coverage to/auth/*/start). - Creates a single-use PKCE state row tied to the tenant via
oauth-state.service. - Audits
oauth.flow_started/success. - 302-redirects to
config.googleAuthEndpointwith all of:client_id,redirect_uri,response_type=code,scope,access_type=offline,prompt=consent,state,code_challenge,code_challenge_method=S256.
The access_type=offline + prompt=consent combination is what makes Google return a refresh_token on the initial exchange — without prompt=consent, a user who has already consented gets only an access_token back.
/auth/:platform/callback
- Validates
platform, query (code,state, optionalerror). - If Google sent
error=..., auditsoauth.flow_failedand returns 400oauth_denied. - Consumes the state row via
consumeOAuthState(state). Single-use: the row is DELETEd on read; a second hit with the same state throws and the route returns 400invalid_state. - Asserts the URL’s
:platformmatches the platform recorded on the state row. Mismatch → auditoauth.flow_failedwithreason: state_platform_mismatch, return 400. - Looks up the platform adapter via
registry.getAdapter(platform)and callsadapter.exchangeAuthCode(stateRow.tenantId, code, stateRow.codeVerifier). - On exchange failure: audit
oauth.flow_failedwith the error message, return 502exchange_failed. - On success: audit
oauth.flow_completed, return 200{ status: 'connected', platform }.
/callback is intentionally unauthenticated. The browser is coming back from Google with no way to attach an API key; the state row holds the tenantId. Don’t put auth here — it would break the flow.
Audit map
| When | Event | Outcome |
|---|---|---|
| /start succeeds | oauth.flow_started | success |
Google sent ?error=... to /callback | oauth.flow_failed (reason: the Google error code) | failure |
| /callback missing code or state | oauth.flow_failed (reason: missing_code_or_state) | failure |
| State is unknown / expired / replayed | oauth.flow_failed (reason: invalid_state) | failure |
| State platform ≠ URL platform | oauth.flow_failed (reason: state_platform_mismatch) | failure |
Adapter exchangeAuthCode throws | oauth.flow_failed (reason: error message) | failure |
| Full callback succeeds | oauth.flow_completed | success |
Rate limiting
The Phase 1 /auth/* route has a 5-request / 15-minute / per-IP cap from the rate-limiter plugin. That covers both /start and /callback.
Tests
tests/integration/oauth.test.ts builds a throwaway Fastify with tenantAuthPlugin + oauthRoutes and exercises:
- /start: 401 without X-Api-Key, 302 with all PKCE params, 501 for unsupported platforms.
- /callback: exchange + encrypted tokens persisted + audit rows + correct expiry timestamp (TZ-correct via the TIMESTAMP parser fix).
- /callback: state replay fails on the second hit.
- /callback: platform mismatch fails and writes a
state_platform_mismatchaudit row.
Mock boundary is globalThis.fetch — all DB writes go through real Postgres.
Cross-references
oauth-state— PKCE state storage + cleanup.tenant-middleware— what enforces auth on/start.google-adapter— what runsexchangeAuthCode.credentials-service— what encrypts the tokens before they hit the DB.