OAuth 2.1 Guide
Overview
OAuth 2.1 is essential for production NitroStack servers, especially when integrating with platforms like OpenAI Apps SDK. This guide shows you how to implement OAuth 2.1 authentication using v3.0 decorators.
Why OAuth 2.1?
OAuth 2.1 is required for:
- OpenAI Apps SDK integration - Required by OpenAI's ecosystem
- Third-party integrations - Google, GitHub, Microsoft, etc.
- Secure delegated access - Users grant access without sharing passwords
- Production-grade security - Industry-standard authorization
Quick Start
1. Install Dependencies
Bash
npm install @panva/oauth4webapi
2. Environment Variables
Env
# .env
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback
OAUTH_ISSUER=https://accounts.google.com
OAUTH_SCOPES=openid profile email
3. Create OAuth Module
Typescript
// src/modules/oauth/oauth.module.ts
import { Module } from '@nitrostack/core';
import { OAuthTools } from './oauth.tools.js';
import { OAuthService } from './oauth.service.js';
import { OAuthGuard } from './oauth.guard.js';
@Module({
name: 'oauth',
description: 'OAuth 2.1 authentication',
controllers: [OAuthTools],
providers: [OAuthService, OAuthGuard],
exports: [OAuthService, OAuthGuard]
})
export class OAuthModule {}
OAuth Service
Complete OAuth Service Implementation
Typescript
// src/modules/oauth/oauth.service.ts
import { Injectable } from '@nitrostack/core';
import * as oauth from '@panva/oauth4webapi';
interface OAuthConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
issuer: string;
scopes: string[];
}
@Injectable()
export class OAuthService {
private config: OAuthConfig;
private client!: oauth.Client;
private authServer!: oauth.AuthorizationServer;
constructor(private configService: ConfigService) {
this.config = {
clientId: this.configService.get('OAUTH_CLIENT_ID'),
clientSecret: this.configService.get('OAUTH_CLIENT_SECRET'),
redirectUri: this.configService.get('OAUTH_REDIRECT_URI'),
issuer: this.configService.get('OAUTH_ISSUER'),
scopes: this.configService.get('OAUTH_SCOPES', 'openid profile email').split(' ')
};
this.initialize();
}
private async initialize() {
// Discover OAuth server configuration
const issuer = new URL(this.config.issuer);
this.authServer = await oauth
.discoveryRequest(issuer)
.then((response) => oauth.processDiscoveryResponse(issuer, response));
// Setup client
this.client = {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
token_endpoint_auth_method: 'client_secret_basic'
};
}
/**
* Generate authorization URL with PKCE
*/
async generateAuthUrl(): Promise<{ url: string; codeVerifier: string; state: string }> {
// Generate PKCE code verifier and challenge
const codeVerifier = oauth.generateRandomCodeVerifier();
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
const codeChallengeMethod = 'S256';
// Generate state for CSRF protection
const state = oauth.generateRandomState();
// Build authorization URL
const authorizationUrl = new URL(this.authServer.authorization_endpoint!);
authorizationUrl.searchParams.set('client_id', this.config.clientId);
authorizationUrl.searchParams.set('redirect_uri', this.config.redirectUri);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('scope', this.config.scopes.join(' '));
authorizationUrl.searchParams.set('state', state);
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
return {
url: authorizationUrl.toString(),
codeVerifier,
state
};
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(
code: string,
codeVerifier: string,
state: string,
receivedState: string
): Promise<oauth.TokenEndpointResponse> {
// Verify state to prevent CSRF
if (state !== receivedState) {
throw new Error('State mismatch - possible CSRF attack');
}
// Exchange authorization code for tokens
const params = new URLSearchParams();
params.set('grant_type', 'authorization_code');
params.set('code', code);
params.set('redirect_uri', this.config.redirectUri);
params.set('code_verifier', codeVerifier);
const response = await oauth.authorizationCodeGrantRequest(
this.authServer,
this.client,
params
);
return await oauth.processAuthorizationCodeResponse(
this.authServer,
this.client,
response
);
}
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<oauth.TokenEndpointResponse> {
const response = await oauth.refreshTokenGrantRequest(
this.authServer,
this.client,
refreshToken
);
return await oauth.processRefreshTokenResponse(
this.authServer,
this.client,
response
);
}
/**
* Validate and decode access token
*/
async validateToken(accessToken: string): Promise<any> {
const response = await oauth.userInfoRequest(
this.authServer,
this.client,
accessToken
);
return await oauth.processUserInfoResponse(
this.authServer,
this.client,
oauth.skipSubjectCheck,
response
);
}
/**
* Revoke token
*/
async revokeToken(token: string): Promise<void> {
if (!this.authServer.revocation_endpoint) {
throw new Error('Revocation not supported');
}
await oauth.revocationRequest(
this.authServer,
this.client,
token
);
}
}
OAuth Tools
Authentication Flow Tools
Typescript
// src/modules/oauth/oauth.tools.ts
import { Tool, Widget, ExecutionContext } from '@nitrostack/core';
import { z } from 'zod';
export class OAuthTools {
constructor(
private oauthService: OAuthService,
private sessionService: SessionService
) {}
@Tool({
name: 'oauth_authorize',
description: 'Initiate OAuth 2.1 authorization flow',
inputSchema: z.object({
provider: z.enum(['google', 'github', 'microsoft']).describe('OAuth provider')
}),
examples: {
response: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth?...',
sessionId: 'session-123'
}
}
})
@Widget('oauth-authorize')
async initiateOAuth(input: any, ctx: ExecutionContext) {
// Generate authorization URL with PKCE
const { url, codeVerifier, state } = await this.oauthService.generateAuthUrl();
// Store PKCE parameters in session
const sessionId = await this.sessionService.create({
codeVerifier,
state,
provider: input.provider
});
ctx.logger.info('OAuth flow initiated', { provider: input.provider });
return {
authUrl: url,
sessionId,
message: 'Please visit the authorization URL to continue'
};
}
@Tool({
name: 'oauth_callback',
description: 'Handle OAuth callback and exchange code for token',
inputSchema: z.object({
code: z.string().describe('Authorization code from OAuth provider'),
state: z.string().describe('State parameter for CSRF protection'),
sessionId: z.string().describe('Session ID from authorization request')
}),
examples: {
response: {
accessToken: 'ya29.a0AfH6...',
refreshToken: 'ya29.a0AfH6...',
expiresIn: 3600,
tokenType: 'Bearer',
user: {
sub: 'user-123',
email: 'user@example.com',
name: 'John Doe'
}
}
}
})
@Widget('oauth-success')
async handleCallback(input: any, ctx: ExecutionContext) {
// Retrieve session data
const session = await this.sessionService.get(input.sessionId);
if (!session) {
throw new Error('Invalid session');
}
// Exchange code for tokens
const tokenResponse = await this.oauthService.exchangeCodeForToken(
input.code,
session.codeVerifier,
session.state,
input.state
);
// Get user info
const userInfo = await this.oauthService.validateToken(tokenResponse.access_token);
// Store tokens securely
await this.sessionService.update(input.sessionId, {
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: Date.now() + (tokenResponse.expires_in * 1000),
userInfo
});
ctx.logger.info('OAuth authentication successful', { sub: userInfo.sub });
return {
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresIn: tokenResponse.expires_in,
tokenType: tokenResponse.token_type,
user: userInfo
};
}
@Tool({
name: 'oauth_refresh',
description: 'Refresh expired access token',
inputSchema: z.object({
refreshToken: z.string().describe('Refresh token')
})
})
async refreshToken(input: any, ctx: ExecutionContext) {
const tokenResponse = await this.oauthService.refreshToken(input.refreshToken);
ctx.logger.info('Token refreshed successfully');
return {
accessToken: tokenResponse.access_token,
expiresIn: tokenResponse.expires_in,
tokenType: tokenResponse.token_type
};
}
@Tool({
name: 'oauth_revoke',
description: 'Revoke OAuth token',
inputSchema: z.object({
token: z.string().describe('Access or refresh token to revoke')
})
})
async revokeToken(input: any, ctx: ExecutionContext) {
await this.oauthService.revokeToken(input.token);
ctx.logger.info('Token revoked successfully');
return { success: true, message: 'Token revoked' };
}
}
OAuth Guard
Protect Tools with OAuth
Typescript
// src/modules/oauth/oauth.guard.ts
import { Guard, ExecutionContext, Injectable } from '@nitrostack/core';
@Injectable()
export class OAuthGuard implements Guard {
constructor(private oauthService: OAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const accessToken = this.extractToken(context);
if (!accessToken) {
context.logger.warn('No OAuth token provided');
return false;
}
try {
// Validate token and get user info
const userInfo = await this.oauthService.validateToken(accessToken);
// Attach user info to context
context.auth = {
subject: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
provider: 'oauth',
token: accessToken
};
context.logger.info('OAuth authentication successful', { sub: userInfo.sub });
return true;
} catch (error) {
context.logger.error('OAuth token validation failed:', error);
return false;
}
}
private extractToken(context: ExecutionContext): string | null {
// Check Authorization header
const authHeader = context.metadata?.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Check metadata
if (context.metadata?.accessToken) {
return context.metadata.accessToken;
}
return null;
}
}
Using OAuth Guard
Typescript
import { Tool, UseGuards } from '@nitrostack/core';
import { OAuthGuard } from '../oauth/oauth.guard.js';
export class ProtectedTools {
@Tool({ name: 'protected_resource' })
@UseGuards(OAuthGuard) // ← Requires OAuth authentication
async protectedResource(input: any, ctx: ExecutionContext) {
const userEmail = ctx.auth?.email;
const userId = ctx.auth?.subject;
// Access protected resource
return await this.resourceService.getForUser(userId);
}
}
OpenAI Apps SDK Integration
Configure for OpenAI
Typescript
// OpenAI Apps SDK requires specific OAuth 2.1 configuration
export const openAIConfig = {
clientId: process.env.OPENAI_CLIENT_ID!,
clientSecret: process.env.OPENAI_CLIENT_SECRET!,
redirectUri: process.env.OPENAI_REDIRECT_URI!,
issuer: 'https://auth.openai.com',
scopes: ['openid', 'profile', 'email', 'offline_access'],
// OpenAI-specific requirements
audience: 'https://api.openai.com',
responseType: 'code',
grantType: 'authorization_code',
// PKCE is required
codeChallengeMethod: 'S256'
};
OpenAI-Compatible Tools
Typescript
@Tool({
name: 'openai_compatible_tool',
description: 'Tool compatible with OpenAI Apps SDK',
inputSchema: z.object({
query: z.string()
})
})
@UseGuards(OAuthGuard) // OAuth 2.1 required for OpenAI
async openAITool(input: any, ctx: ExecutionContext) {
// Tool implementation
return { result: 'data' };
}
Provider-Specific Configurations
Google OAuth
Env
OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_ISSUER=https://accounts.google.com
OAUTH_SCOPES=openid profile email
GitHub OAuth
Env
OAUTH_CLIENT_ID=your-github-client-id
OAUTH_CLIENT_SECRET=your-github-client-secret
OAUTH_ISSUER=https://github.com
OAUTH_SCOPES=read:user user:email
Microsoft OAuth
Env
OAUTH_CLIENT_ID=your-microsoft-client-id
OAUTH_CLIENT_SECRET=your-microsoft-client-secret
OAUTH_ISSUER=https://login.microsoftonline.com/common/v2.0
OAUTH_SCOPES=openid profile email
Security Best Practices
1. Always Use PKCE
Typescript
// Good - PKCE enabled
const codeVerifier = oauth.generateRandomCodeVerifier();
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
// Avoid - No PKCE (insecure)
// Authorization code flow without PKCE is vulnerable
2. Validate State Parameter
Typescript
// Good - State validation
if (receivedState !== storedState) {
throw new Error('CSRF detected');
}
// Avoid - No state validation
3. Store Tokens Securely
Typescript
// Good - Encrypted storage
await this.secureStorage.encrypt('token', accessToken);
// Avoid - Plain text storage
localStorage.setItem('token', accessToken);
4. Implement Token Refresh
Typescript
// Good - Auto-refresh before expiry
if (Date.now() >= tokenExpiresAt - 60000) {
await this.refreshToken();
}
// Avoid - No refresh logic
5. Revoke on Logout
Typescript
// Good - Revoke token on logout
await this.oauthService.revokeToken(accessToken);
// Avoid - Just delete locally
delete storage.accessToken;
Testing OAuth Flow
Local Testing
Typescript
// Use ngrok for local HTTPS
// ngrok http 3000
// Update redirect URI
OAUTH_REDIRECT_URI=https://your-ngrok-url.ngrok.io/auth/callback
Mock OAuth for Testing
Typescript
@Injectable()
export class MockOAuthService implements OAuthService {
async generateAuthUrl() {
return {
url: 'http://localhost:3000/mock-auth',
codeVerifier: 'mock-verifier',
state: 'mock-state'
};
}
async exchangeCodeForToken() {
return {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
token_type: 'Bearer'
};
}
}
Troubleshooting
Invalid Grant Error
Cause: Code verifier doesn't match challenge
Solution: Ensure PKCE verifier is stored correctly
State Mismatch
Cause: CSRF protection failed
Solution: Verify state parameter is preserved
Token Expired
Cause: Access token expired
Solution: Implement automatic refresh
Next Steps
Tip: Always test your OAuth flow with actual providers before production deployment!