Skip to main content

OAuth 2.1 Integration Guide

Alpha Feature: OAuth 2.1 integration is in alpha. While secure and functional, expect potential API changes. Test thoroughly before production use. Full alpha notice →
This guide covers the complete OAuth 2.1 integration process, including advanced topics like token management, error handling, and production deployment.

Architecture Overview

AI Stats implements OAuth 2.1 with the following components:
┌──────────────────┐
│  Your App        │
│  (OAuth Client)  │
└────────┬─────────┘

         │ 1. Authorization Request

┌──────────────────────────────────┐
│  AI Stats Authorization Server   │
│  /oauth/consent                  │
└────────┬─────────────────────────┘

         │ 2. User Consent

┌──────────────────┐
│  User Approves   │
│  (Selects Team)  │
└────────┬─────────┘

         │ 3. Authorization Code

┌──────────────────┐
│  Your App        │
│  Backend         │
└────────┬─────────┘

         │ 4. Token Exchange

┌──────────────────────────────────┐
│  AI Stats Token Endpoint         │
│  Returns JWT Access Token        │
└────────┬─────────────────────────┘

         │ 5. API Requests

┌──────────────────────────────────┐
│  AI Stats API Gateway            │
│  Validates JWT, Executes Request │
└──────────────────────────────────┘

Key Components

  • Authorization Server: Issues authorization codes
  • Token Endpoint: Exchanges codes for JWT tokens
  • Resource Server: AI Stats API gateway (validates tokens)
  • JWKS Endpoint: Public keys for JWT verification

OAuth 2.1 vs OAuth 2.0

AI Stats implements OAuth 2.1, which includes security improvements:
FeatureOAuth 2.0OAuth 2.1
PKCEOptionalMandatory
Implicit FlowSupportedRemoved
Refresh Token RotationOptionalRecommended
Bearer Token UsageVariousSimplified

Authorization Code Flow (Detailed)

1. Generate PKCE Parameters

import crypto from 'crypto';

interface PKCEParams {
  verifier: string;
  challenge: string;
  method: 'S256';
}

function generatePKCE(): PKCEParams {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return {
    verifier,
    challenge,
    method: 'S256',
  };
}

2. Build Authorization URL

interface AuthorizationParams {
  clientId: string;
  redirectUri: string;
  scope: string;
  state: string;
  codeChallenge: string;
  codeChallengeMethod: 'S256';
}

function buildAuthorizationUrl(params: AuthorizationParams): string {
  const url = new URL('https://api.phaseo.app/oauth/consent');

  url.searchParams.set('client_id', params.clientId);
  url.searchParams.set('redirect_uri', params.redirectUri);
  url.searchParams.set('scope', params.scope);
  url.searchParams.set('state', params.state);
  url.searchParams.set('code_challenge', params.codeChallenge);
  url.searchParams.set('code_challenge_method', params.codeChallengeMethod);
  url.searchParams.set('response_type', 'code'); // Optional, defaults to 'code'

  return url.toString();
}

3. Handle Authorization Response

interface AuthorizationResponse {
  code?: string;
  state?: string;
  error?: string;
  error_description?: string;
}

function parseAuthorizationResponse(url: string): AuthorizationResponse {
  const params = new URL(url).searchParams;

  return {
    code: params.get('code') ?? undefined,
    state: params.get('state') ?? undefined,
    error: params.get('error') ?? undefined,
    error_description: params.get('error_description') ?? undefined,
  };
}

4. Exchange Code for Token

interface TokenRequest {
  grantType: 'authorization_code';
  code: string;
  codeVerifier: string;
  redirectUri: string;
  clientId: string;
  clientSecret: string;
}

interface TokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in: number;
  token_type: 'Bearer';
  scope: string;
}

async function exchangeCodeForToken(
  request: TokenRequest
): Promise<TokenResponse> {
  const response = await fetch(
    `${process.env.SUPABASE_URL}/auth/v1/oauth/token`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: request.grantType,
        code: request.code,
        code_verifier: request.codeVerifier,
        redirect_uri: request.redirectUri,
        client_id: request.clientId,
        client_secret: request.clientSecret,
      }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Token exchange failed: ${error.error}`);
  }

  return response.json();
}

Token Management

Access Token Structure

AI Stats issues JWT access tokens with the following claims:
{
  "iss": "https://your-project.supabase.co",
  "sub": "user_id_123",
  "aud": "authenticated",
  "exp": 1234567890,
  "iat": 1234564290,
  "user_id": "user_id_123",
  "team_id": "team_id_456",
  "client_id": "oauth_client_789",
  "scope": "openid email gateway:access",
  "email": "user@example.com"
}

Decoding Tokens (Client-Side)

interface TokenClaims {
  user_id: string;
  team_id: string;
  client_id: string;
  scope: string;
  exp: number;
  email?: string;
}

function decodeToken(token: string): TokenClaims {
  const [, payloadB64] = token.split('.');
  const payload = JSON.parse(
    atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))
  );
  return payload;
}

function isTokenExpired(token: string): boolean {
  const claims = decodeToken(token);
  return claims.exp * 1000 < Date.now();
}

Token Storage

class TokenStore {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  private expiresAt: number | null = null;

  setTokens(access: string, refresh: string, expiresIn: number) {
    this.accessToken = access;
    this.refreshToken = refresh;
    this.expiresAt = Date.now() + expiresIn * 1000;

    // Persist to secure storage
    localStorage.setItem('tokens', JSON.stringify({
      access,
      refresh,
      expiresAt: this.expiresAt,
    }));
  }

  getAccessToken(): string | null {
    // Check if token is expired
    if (this.expiresAt && Date.now() >= this.expiresAt) {
      return null;
    }
    return this.accessToken;
  }

  getRefreshToken(): string | null {
    return this.refreshToken;
  }

  clear() {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = null;
    localStorage.removeItem('tokens');
  }
}

Automatic Token Refresh

class TokenManager {
  private store = new TokenStore();
  private refreshPromise: Promise<TokenResponse> | null = null;

  async getValidAccessToken(): Promise<string> {
    const token = this.store.getAccessToken();

    // Token still valid
    if (token && !this.isTokenExpiringSoon(token)) {
      return token;
    }

    // Token expired or expiring soon - refresh
    return this.refreshAccessToken();
  }

  private isTokenExpiringSoon(token: string): boolean {
    const claims = decodeToken(token);
    const bufferTime = 300; // 5 minutes before expiry
    return claims.exp * 1000 - Date.now() < bufferTime * 1000;
  }

  private async refreshAccessToken(): Promise<string> {
    // Prevent multiple simultaneous refresh requests
    if (this.refreshPromise) {
      const tokens = await this.refreshPromise;
      return tokens.access_token;
    }

    this.refreshPromise = this.performRefresh();

    try {
      const tokens = await this.refreshPromise;
      this.store.setTokens(
        tokens.access_token,
        tokens.refresh_token,
        tokens.expires_in
      );
      return tokens.access_token;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async performRefresh(): Promise<TokenResponse> {
    const refreshToken = this.store.getRefreshToken();
    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await fetch(
      `${process.env.SUPABASE_URL}/auth/v1/oauth/token`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          grant_type: 'refresh_token',
          refresh_token: refreshToken,
          client_id: process.env.OAUTH_CLIENT_ID,
          client_secret: process.env.OAUTH_CLIENT_SECRET,
        }),
      }
    );

    if (!response.ok) {
      this.store.clear();
      throw new Error('Token refresh failed');
    }

    return response.json();
  }
}

API Client with Token Management

class AIStatsClient {
  private tokenManager = new TokenManager();
  private baseUrl = 'https://api.phaseo.app';

  async chat(params: {
    model: string;
    messages: Array<{ role: string; content: string }>;
  }) {
    const token = await this.tokenManager.getValidAccessToken();

    const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(params),
    });

    if (response.status === 401) {
      // Token was revoked or invalid - trigger re-authorization
      throw new Error('OAUTH_AUTHORIZATION_REVOKED');
    }

    if (!response.ok) {
      throw new Error(`API request failed: ${response.statusText}`);
    }

    return response.json();
  }
}

// Usage
const client = new AIStatsClient();

try {
  const completion = await client.chat({
    model: 'gpt-4',
    messages: [{ role: 'user', content: 'Hello!' }],
  });
  console.log(completion.choices[0].message.content);
} catch (error) {
  if (error.message === 'OAUTH_AUTHORIZATION_REVOKED') {
    // Redirect to re-authorize
    window.location.href = '/api/auth/authorize';
  }
}

Error Handling

Authorization Errors

interface AuthorizationError {
  error: 'access_denied' | 'invalid_request' | 'unauthorized_client';
  error_description?: string;
  error_uri?: string;
}

function handleAuthorizationError(error: AuthorizationError) {
  switch (error.error) {
    case 'access_denied':
      // User denied authorization
      console.log('User declined authorization');
      break;

    case 'invalid_request':
      // Missing or invalid parameters
      console.error('Authorization request invalid:', error.error_description);
      break;

    case 'unauthorized_client':
      // Client not authorized for this grant type
      console.error('OAuth app configuration error');
      break;
  }
}

Token Errors

interface TokenError {
  error: 'invalid_grant' | 'invalid_client' | 'invalid_request';
  error_description?: string;
}

function handleTokenError(error: TokenError) {
  switch (error.error) {
    case 'invalid_grant':
      // Authorization code expired or already used
      console.error('Authorization code invalid - restart flow');
      break;

    case 'invalid_client':
      // Client authentication failed
      console.error('Client credentials invalid');
      break;

    case 'invalid_request':
      // Malformed token request
      console.error('Token request invalid:', error.error_description);
      break;
  }
}

API Request Errors

async function makeAPIRequest(token: string) {
  const response = await fetch('https://api.phaseo.app/v1/chat/completions', {
    headers: { Authorization: `Bearer ${token}` },
    // ...
  });

  switch (response.status) {
    case 401:
      // Token invalid or expired
      throw new Error('OAUTH_TOKEN_INVALID');

    case 403:
      // Authorization revoked
      throw new Error('OAUTH_AUTHORIZATION_REVOKED');

    case 429:
      // Rate limit exceeded
      const retryAfter = response.headers.get('Retry-After');
      throw new Error(`Rate limited. Retry after ${retryAfter}s`);

    case 500:
    case 502:
    case 503:
      // Server error - implement retry logic
      throw new Error('SERVER_ERROR');
  }

  return response.json();
}

Production Deployment

Environment Variables

# .env.production
OAUTH_CLIENT_ID=oauth_your_client_id
OAUTH_CLIENT_SECRET=secret_your_client_secret
SUPABASE_URL=https://your-project.supabase.co
REDIRECT_URI=https://yourapp.com/auth/callback

Security Checklist

  • Use HTTPS for all redirects and endpoints
  • Validate redirect URIs server-side
  • Store client_secret securely (environment variables, secrets manager)
  • Implement state parameter validation
  • Use httpOnly cookies for token storage
  • Implement CSRF protection
  • Rate limit token endpoints
  • Log security events (failed auth attempts)
  • Monitor for token abuse
  • Implement token revocation
  • Use secure session management

Rate Limiting

class RateLimiter {
  private attempts = new Map<string, number[]>();

  isAllowed(userId: string, maxAttempts = 10, windowMs = 60000): boolean {
    const now = Date.now();
    const userAttempts = this.attempts.get(userId) ?? [];

    // Filter attempts within window
    const recentAttempts = userAttempts.filter(
      (timestamp) => now - timestamp < windowMs
    );

    if (recentAttempts.length >= maxAttempts) {
      return false;
    }

    recentAttempts.push(now);
    this.attempts.set(userId, recentAttempts);

    return true;
  }
}

Monitoring & Analytics

Track OAuth Events

interface OAuthEvent {
  type: 'authorization_started' | 'authorization_completed' | 'token_refreshed' | 'authorization_revoked';
  userId?: string;
  clientId: string;
  timestamp: Date;
  metadata?: Record<string, any>;
}

function trackOAuthEvent(event: OAuthEvent) {
  // Send to analytics platform
  console.log('OAuth Event:', event);

  // Example with PostHog
  posthog.capture(event.type, {
    client_id: event.clientId,
    user_id: event.userId,
    ...event.metadata,
  });
}

Metrics to Monitor

  • Authorization success rate
  • Token exchange latency
  • Token refresh success rate
  • API request success rate (by OAuth client)
  • Authorization revocation rate
  • Token expiration rate without refresh

Advanced Topics

Webhook Notifications (Coming Soon)

interface WebhookEvent {
  event: 'authorization.revoked' | 'authorization.expired';
  data: {
    user_id: string;
    client_id: string;
    team_id: string;
    timestamp: string;
  };
}

// Register webhook endpoint
app.post('/webhooks/oauth', async (req, res) => {
  const event: WebhookEvent = req.body;

  if (event.event === 'authorization.revoked') {
    // Clean up user session
    await invalidateUserSessions(event.data.user_id);
  }

  res.sendStatus(200);
});

Multi-Tenant Support (Coming Soon)

// Each user can authorize your app for multiple teams
interface Authorization {
  userId: string;
  teamId: string;
  clientId: string;
  scopes: string[];
}

async function getUserAuthorizations(userId: string): Promise<Authorization[]> {
  // Coming Soon:
  // Public listing endpoints for OAuth authorizations are not yet available.
  // For now, track active authorizations in your own app/session state.
  return [];
}

Testing

Unit Tests

import { describe, it, expect } from 'vitest';

describe('PKCE', () => {
  it('generates valid code verifier', () => {
    const verifier = generateCodeVerifier();
    expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
    expect(verifier.length).toBeGreaterThanOrEqual(43);
  });

  it('generates valid code challenge', () => {
    const verifier = 'test_verifier_123';
    const challenge = generateCodeChallenge(verifier);
    expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
  });
});

Integration Tests

describe('OAuth Flow', () => {
  it('completes authorization flow', async () => {
    // 1. Start authorization
    const authUrl = buildAuthorizationUrl(/* ... */);
    const response = await fetch(authUrl);
    expect(response.status).toBe(200);

    // 2. Simulate user approval
    // (requires browser automation)

    // 3. Exchange code for token
    const tokens = await exchangeCodeForToken(/* ... */);
    expect(tokens.access_token).toBeDefined();
    expect(tokens.refresh_token).toBeDefined();

    // 4. Make API request
    const completion = await makeAPIRequest(tokens.access_token);
    expect(completion.choices).toHaveLength(1);
  });
});

Resources


Support

Need help with your OAuth integration?
Last modified on February 17, 2026