Documentation/titan/OAuth callback
In this section

OAuth callback

Handling an OAuth 2.0 authorisation-code flow correctly has a long checklist: state validation, PKCE, token exchange, refresh storage, and bootstrapping the user's session. This recipe shows the canonical shape using titan-auth, titan-cache (for the short-lived state), and the rest of the standard stack.

Shape

  • PKCE. The initiating page generates code_verifier + code_challenge (SHA-256). The callback validates against the stored verifier.

  • State parameter. Random, opaque, short-TTL, tied to one session. Replaying a state from another browser fails.

  • Token exchange. Authorisation code → access + refresh tokens — server-side only, never in the browser.

  • Signed session bootstrap. Issue a signed URL with a JWT pointing back to your app's session endpoint.

Architecture

sequenceDiagram
  participant U as User
  participant App as Your app
  participant P as OAuth provider

  U->>App: GET /auth/start
  App->>App: Generate state + PKCE verifier<br/>cache(state → verifier, TTL 10m)
  App-->>U: Redirect to provider with state + code_challenge
  U->>P: Authenticate
  P-->>U: Redirect to /auth/callback?code=&state=
  U->>App: GET /auth/callback?code=...&state=...
  App->>App: Lookup state<br/>retrieve verifier<br/>pop from cache
  App->>P: POST /token { code, code_verifier, redirect_uri }
  P-->>App: { access_token, refresh_token, id_token }
  App->>App: Verify id_token<br/>create local session
  App->>App: Sign a session-bootstrap JWT
  App-->>U: Redirect to /session/bootstrap?token=...

Setup

import { Module } from '@omnitron-dev/titan';
import { ConfigModule } from '@omnitron-dev/titan/module/config';
import { LoggerModule } from '@omnitron-dev/titan/module/logger';

import { TitanAuthModule } from '@omnitron-dev/titan-auth';
import { TitanCacheModule } from '@omnitron-dev/titan-cache';
import { TitanRedisModule } from '@omnitron-dev/titan-redis';
import { TitanRateLimitModule } from '@omnitron-dev/titan-ratelimit';

@Module({
  imports: [
    ConfigModule.forRoot({/* … */}),
    LoggerModule.forRoot({ level: 'info' }),

    TitanRedisModule.forRoot({ config: { url: env.REDIS_URL } }),

    // Short-lived auth state cache
    TitanCacheModule.forRoot({
      maxSize:        10_000,
      defaultTtl:     600,                    // 10 min
      evictionPolicy: 'ttl',
    }),

    TitanAuthModule.forRoot({
      algorithm:     'HS256',
      jwtSecret:     env.JWT_SECRET,
      issuer:        env.APP_ISSUER,
      audience:      env.APP_AUDIENCE,
      urlSigningKey: env.URL_SIGNING_KEY,
    }),

    TitanRateLimitModule.forRoot({
      enabled:         true,
      storageType:     'redis',
      defaultLimit:    10,                   // 10 OAuth starts per minute per IP
      defaultWindowMs: 60_000,
    }),

    AuthFlowModule,
  ],
})
export class AppModule {}

Implementation

import { Service, Inject } from '@omnitron-dev/titan';
import { Public } from '@omnitron-dev/titan/netron';
import { Errors } from '@omnitron-dev/titan/errors';
import {
  JWT_SERVICE_TOKEN, SIGNED_URL_SERVICE_TOKEN,
  type IJWTService, type ISignedUrlService,
} from '@omnitron-dev/titan-auth';
import { CACHE_SERVICE_TOKEN, type ICacheService }
  from '@omnitron-dev/titan-cache';
import { createHash, randomBytes } from 'node:crypto';

const PROVIDER_AUTHORIZE = 'https://idp.example.com/oauth/authorize';
const PROVIDER_TOKEN     = 'https://idp.example.com/oauth/token';
const CLIENT_ID          = process.env.OAUTH_CLIENT_ID!;
const CLIENT_SECRET      = process.env.OAUTH_CLIENT_SECRET!;
const REDIRECT_URI       = `${process.env.APP_BASE_URL}/auth/callback`;

@Service({ name: 'auth-flow' })
class AuthFlowService {
  constructor(
    @Inject(CACHE_SERVICE_TOKEN)        private readonly cache:  ICacheService,
    @Inject(JWT_SERVICE_TOKEN)          private readonly jwt:    IJWTService,
    @Inject(SIGNED_URL_SERVICE_TOKEN)   private readonly signer: ISignedUrlService,
  ) {}

  /**
   * Step 1: start the flow. Returns the provider URL to redirect to.
   */
  @Public()
  async start(): Promise<{ redirectTo: string }> {
    const state    = randomBytes(32).toString('base64url');
    const verifier = randomBytes(32).toString('base64url');
    const challenge = createHash('sha256').update(verifier).digest('base64url');

    // Cache state → verifier with TTL = 10 min
    await this.cache.getCache('auth-flow').set(`state:${state}`, verifier, { ttl: 600 });

    const url = new URL(PROVIDER_AUTHORIZE);
    url.searchParams.set('response_type',         'code');
    url.searchParams.set('client_id',             CLIENT_ID);
    url.searchParams.set('redirect_uri',          REDIRECT_URI);
    url.searchParams.set('state',                 state);
    url.searchParams.set('code_challenge',        challenge);
    url.searchParams.set('code_challenge_method', 'S256');
    url.searchParams.set('scope',                 'openid profile email');

    return { redirectTo: url.toString() };
  }

  /**
   * Step 2: handle the provider's callback.
   */
  @Public()
  async callback(code: string, state: string): Promise<{ bootstrapUrl: string }> {
    // Validate state — atomic get-and-delete so it can't be replayed
    const cache = this.cache.getCache<string>('auth-flow');
    const verifier = await cache.get(`state:${state}`);
    if (!verifier) throw Errors.unauthorized('invalid or expired state');
    await cache.delete(`state:${state}`);

    // Exchange code for tokens
    const response = await fetch(PROVIDER_TOKEN, {
      method:  'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body:    new URLSearchParams({
        grant_type:    'authorization_code',
        client_id:     CLIENT_ID,
        client_secret: CLIENT_SECRET,
        code,
        code_verifier: verifier,
        redirect_uri:  REDIRECT_URI,
      }),
    });
    if (!response.ok) throw Errors.unauthorized('token exchange failed');
    const tokens = await response.json();
    // tokens: { access_token, refresh_token, id_token, expires_in }

    // Verify the id_token (the OAuth provider's JWT proves the user)
    // For external IDPs, use TitanAuthModule with jwksUrl for this verify.
    // Project-specific: store refresh_token securely (encrypted-at-rest).

    // Bootstrap the local session via signed URL.
    // ISignedTokenPayload is { resourceId, resourcePath, operation, transform? };
    // stash bootstrap claims under `transform`.
    const bootstrapToken = await this.signer.createSignedToken(
      {
        resourceId:   'session',
        resourcePath: 'bootstrap',
        operation:    'read',
        transform: {
          accessTokenHash: createHash('sha256').update(tokens.access_token).digest('hex'),
        },
      },
      120,    // 2 min expiry — single-use
    );

    return {
      bootstrapUrl: `${process.env.APP_BASE_URL}/session/bootstrap?token=${bootstrapToken}`,
    };
  }
}

Cross-module wiring notes

ConcernWiring detail
State cache nameDedicated cache ('auth-flow') — keeps short-lived auth state separate from app data
State key vs the valueCache key = state:${state} (opaque); value = verifier. State is what the URL carries; verifier never leaves the server.
State TTL10 minutes is generous — most users finish in seconds. Shorter is fine.
Atomic state consumptionget + delete — prevents replaying the same state. The getOrSet pattern doesn't apply here.
PKCE algorithmSHA-256 base64url; matches code_challenge_method: 'S256'
Token storagerefresh_token is long-lived and sensitive — store encrypted at rest (pgcrypto or KMS)
Rate-limit per IPPrevents brute-force state guessing against the cache
Signed bootstrap URLShort TTL (≤ 2 min) and single-use; check + reject reuse on the bootstrap endpoint
Tokens never reach the browserAccess + refresh tokens stay server-side; only your session JWT goes to the client

Production checklist

  • PKCE enabled — code_challenge_method: 'S256', never 'plain'

  • State validated atomically — get then delete; no race window

  • State TTL ≤ 15 minutes

  • Redirect URI exact-match on the provider side (no wildcards)

  • Token exchange uses POST with Content-Type: application/x-www-form-urlencoded

  • client_secret server-side only — never in the browser

  • id_token verified — JWKS-backed verify against the IDP's keys

  • refresh_token encrypted at rest in the database

  • Bootstrap JWT with very short TTL (≤ 2 min) and single-use semantics

  • Rate-limit /auth/start and /auth/callback per IP

  • Detect and reject reused authorisation codes — the provider should also do this; defense in depth

  • Detect impossible-time-window flows (state created seconds ago coming back from a different IP)

See also