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:
| Feature | OAuth 2.0 | OAuth 2.1 |
|---|
| PKCE | Optional | Mandatory |
| Implicit Flow | Supported | Removed |
| Refresh Token Rotation | Optional | Recommended |
| Bearer Token Usage | Various | Simplified |
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
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