Skip to Content
Deneva MCPComponentsOauth Routes

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

  1. Validates platform ∈ {google, meta, tiktok}; refuses anything else with 400.
  2. Refuses meta / tiktok with 501 (Phase 3).
  3. Requires req.tenantId (the tenant middleware extends auth coverage to /auth/*/start).
  4. Creates a single-use PKCE state row tied to the tenant via oauth-state.service.
  5. Audits oauth.flow_started / success.
  6. 302-redirects to config.googleAuthEndpoint with 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

  1. Validates platform, query (code, state, optional error).
  2. If Google sent error=..., audits oauth.flow_failed and returns 400 oauth_denied.
  3. 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 400 invalid_state.
  4. Asserts the URL’s :platform matches the platform recorded on the state row. Mismatch → audit oauth.flow_failed with reason: state_platform_mismatch, return 400.
  5. Looks up the platform adapter via registry.getAdapter(platform) and calls adapter.exchangeAuthCode(stateRow.tenantId, code, stateRow.codeVerifier).
  6. On exchange failure: audit oauth.flow_failed with the error message, return 502 exchange_failed.
  7. 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

WhenEventOutcome
/start succeedsoauth.flow_startedsuccess
Google sent ?error=... to /callbackoauth.flow_failed (reason: the Google error code)failure
/callback missing code or stateoauth.flow_failed (reason: missing_code_or_state)failure
State is unknown / expired / replayedoauth.flow_failed (reason: invalid_state)failure
State platform ≠ URL platformoauth.flow_failed (reason: state_platform_mismatch)failure
Adapter exchangeAuthCode throwsoauth.flow_failed (reason: error message)failure
Full callback succeedsoauth.flow_completedsuccess

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_mismatch audit row.

Mock boundary is globalThis.fetch — all DB writes go through real Postgres.

Cross-references