OAuth 2.0 Implementation Guide

This guide provides comprehensive technical details for implementing OAuth 2.0 with the Fanvue API. If you’re looking for a quick start, see the OAuth 2.0 Tutorial.

OAuth 2.0 Flow Diagram

PKCE (Proof Key for Code Exchange)

PKCE is required for all OAuth 2.0 flows. It’s a security extension that prevents authorization code interception attacks.

How PKCE Works

  1. Generate a Code Verifier: Create a cryptographically random string (43-128 characters)
  2. Create a Code Challenge: Hash the verifier using SHA-256 and encode it as Base64URL
  3. Send Challenge with Authorization: Include code_challenge in the authorization request
  4. Store Verifier Securely: Keep the code_verifier in your application (see storage options below)
  5. Send Verifier with Token Exchange: Include code_verifier when exchanging the authorization code
  6. Server Verification: Fanvue verifies that SHA256(code_verifier) matches the original code_challenge

This ensures that even if an attacker intercepts the authorization code, they cannot exchange it for tokens without the original code_verifier.

Storing the Code Verifier

You must securely store the code_verifier between the authorization request and token exchange:

Recommended Options:

  • Server-side session storage (preferred): Store in Redis, database, or in-memory session
  • Secure HTTP-only cookies: Use Secure, HttpOnly, and SameSite=Lax flags
  • Encrypted client storage: Only if server-side storage isn’t possible

⚠️ Never store the verifier in:

  • Local storage or session storage (vulnerable to XSS)
  • URL parameters
  • Client-side JavaScript variables that persist across page loads

Code Examples

1import { randomBytes, createHash } from 'crypto';
2
3// Generate code verifier (43-128 characters)
4function generateCodeVerifier() {
5 return base64URLEncode(randomBytes(32));
6}
7
8// Generate code challenge from verifier
9function generateCodeChallenge(verifier) {
10 return base64URLEncode(
11 createHash('sha256').update(verifier).digest()
12 );
13}
14
15// Helper function for Base64URL encoding
16function base64URLEncode(buffer) {
17 return buffer
18 .toString('base64')
19 .replace(/=/g, '')
20 .replace(/\+/g, '-')
21 .replace(/\//g, '_');
22}
23
24// Usage
25const codeVerifier = generateCodeVerifier();
26const codeChallenge = generateCodeChallenge(codeVerifier);
27
28// Store codeVerifier in session/cookie for later use
29// Send codeChallenge with authorization request

Authorization URL Format

You should always include these default scopes:

  • openid - Required for OpenID Connect; enables ID token generation and access to user identity
  • offline_access - Provides refresh tokens so your app can obtain new access tokens without re-authentication
  • offline - Enables long-term access for background operations

Required Parameters:

  • client_id - Your OAuth application’s client ID
  • redirect_uri - Where users are redirected after authorization (must match your app configuration)
  • response_type=code - Indicates authorization code flow
  • scope - Space-separated list of permissions (URL encoded with +)
  • state - Random string to prevent CSRF attacks (verify this matches on callback)
  • code_challenge - [PKCE Required] The Base64URL-encoded SHA-256 hash of your code_verifier
  • code_challenge_method=S256 - [PKCE Required] Indicates SHA-256 hashing method
https://auth.fanvue.com/oauth2/auth?
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
response_type=code&
scope=openid+offline_access+offline+read:self+read:chat&
state=RANDOM_STRING&
code_challenge=CODE_CHALLENGE&
code_challenge_method=S256

Token Exchange

After receiving the authorization code, exchange it for access and refresh tokens.

Required Parameters:

  • grant_type=authorization_code - Indicates you’re exchanging an authorization code
  • client_id - Your OAuth application’s client ID
  • client_secret - Your OAuth application’s client secret
  • code - The authorization code received from the callback
  • redirect_uri - Must match the redirect URI used in the authorization request
  • code_verifier - [PKCE Required] The original code_verifier you generated (NOT the challenge)

⚠️ Important: Send the code_verifier, not the code_challenge. The server will hash the verifier and compare it to the challenge you sent earlier.

1POST https://auth.fanvue.com/oauth2/token
2Content-Type: application/x-www-form-urlencoded
3
4grant_type=authorization_code&
5client_id=YOUR_CLIENT_ID&
6client_secret=YOUR_CLIENT_SECRET&
7code=AUTHORIZATION_CODE&
8redirect_uri=YOUR_REDIRECT_URI&
9code_verifier=CODE_VERIFIER

Response:

1{
2 "access_token": "eyJhbGc...",
3 "refresh_token": "eyJhbGc...",
4 "id_token": "eyJhbGc...",
5 "token_type": "Bearer",
6 "expires_in": 3600,
7 "scope": "openid offline_access offline read:self read:chat"
8}

Handling the OAuth Callback

After the user authorizes your application, Fanvue redirects them back to your specified redirect_uri with the authorization code and state parameter.

Callback URL Format

https://your-app.com/callback?code=AUTHORIZATION_CODE&state=STATE_VALUE

Implementation Examples

1import express from 'express';
2import { randomBytes } from 'crypto';
3
4const app = express();
5
6// Step 1: Initiate OAuth flow
7app.get('/auth/fanvue', (req, res) => {
8 const codeVerifier = generateCodeVerifier();
9 const codeChallenge = generateCodeChallenge(codeVerifier);
10 const state = randomBytes(32).toString('hex');
11
12 // Store code_verifier and state in session
13 req.session.codeVerifier = codeVerifier;
14 req.session.oauthState = state;
15
16 const authUrl = new URL('https://auth.fanvue.com/oauth2/auth');
17 authUrl.searchParams.append('client_id', process.env.CLIENT_ID);
18 authUrl.searchParams.append('redirect_uri', process.env.REDIRECT_URI);
19 authUrl.searchParams.append('response_type', 'code');
20 authUrl.searchParams.append('scope', 'openid offline_access offline read:self');
21 authUrl.searchParams.append('state', state);
22 authUrl.searchParams.append('code_challenge', codeChallenge);
23 authUrl.searchParams.append('code_challenge_method', 'S256');
24
25 res.redirect(authUrl.toString());
26});
27
28// Step 2: Handle OAuth callback
29app.get('/callback', async (req, res) => {
30 const { code, state } = req.query;
31
32 // Validate state parameter (CSRF protection)
33 if (!state || state !== req.session.oauthState) {
34 return res.status(400).send('Invalid state parameter');
35 }
36
37 // Retrieve the stored code_verifier
38 const codeVerifier = req.session.codeVerifier;
39 if (!codeVerifier) {
40 return res.status(400).send('Code verifier not found in session');
41 }
42
43 // Clear session data
44 delete req.session.oauthState;
45 delete req.session.codeVerifier;
46
47 try {
48 // Exchange authorization code for tokens
49 const tokenResponse = await fetch('https://auth.fanvue.com/oauth2/token', {
50 method: 'POST',
51 headers: {
52 'Content-Type': 'application/x-www-form-urlencoded',
53 },
54 body: new URLSearchParams({
55 grant_type: 'authorization_code',
56 client_id: process.env.CLIENT_ID,
57 client_secret: process.env.CLIENT_SECRET,
58 code: code,
59 redirect_uri: process.env.REDIRECT_URI,
60 code_verifier: codeVerifier,
61 }),
62 });
63
64 if (!tokenResponse.ok) {
65 const error = await tokenResponse.json();
66 throw new Error(`Token exchange failed: ${error.error_description}`);
67 }
68
69 const tokens = await tokenResponse.json();
70
71 // Store tokens securely (e.g., encrypted in database)
72 req.session.accessToken = tokens.access_token;
73 req.session.refreshToken = tokens.refresh_token;
74 req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);
75
76 res.redirect('/dashboard');
77 } catch (error) {
78 console.error('OAuth callback error:', error);
79 res.status(500).send('Authentication failed');
80 }
81});

Key Implementation Points

  1. State Validation: Always validate the state parameter to prevent CSRF attacks
  2. Retrieve Code Verifier: Get the stored code_verifier from session/cookies
  3. Error Handling: Handle missing parameters and failed token exchanges gracefully
  4. Clean Up: Remove temporary session data after successful exchange
  5. Secure Storage: Store tokens securely (encrypted database preferred over sessions)

Token Refresh Flow

Access tokens expire after a short period (typically 1 hour). Use refresh tokens to obtain new access tokens without requiring users to re-authenticate.

Refresh Token Request

1POST https://auth.fanvue.com/oauth2/token
2Content-Type: application/x-www-form-urlencoded
3
4grant_type=refresh_token&
5client_id=YOUR_CLIENT_ID&
6client_secret=YOUR_CLIENT_SECRET&
7refresh_token=YOUR_REFRESH_TOKEN

Response:

1{
2 "access_token": "eyJhbGc...",
3 "refresh_token": "eyJhbGc...",
4 "token_type": "Bearer",
5 "expires_in": 3600,
6 "scope": "openid offline_access offline read:self read:chat"
7}

Automatic Token Refresh Implementation

1class FanvueClient {
2 constructor(accessToken, refreshToken, expiresAt) {
3 this.accessToken = accessToken;
4 this.refreshToken = refreshToken;
5 this.tokenExpiresAt = expiresAt;
6 this.refreshPromise = null;
7 }
8
9 async ensureValidToken() {
10 // Check if token is expired or about to expire (within 5 minutes)
11 const now = Date.now();
12 const bufferTime = 5 * 60 * 1000; // 5 minutes
13
14 if (now + bufferTime >= this.tokenExpiresAt) {
15 // If already refreshing, wait for that promise
16 if (this.refreshPromise) {
17 return this.refreshPromise;
18 }
19
20 // Start refresh process
21 this.refreshPromise = this.refreshAccessToken();
22
23 try {
24 await this.refreshPromise;
25 } finally {
26 this.refreshPromise = null;
27 }
28 }
29
30 return this.accessToken;
31 }
32
33 async refreshAccessToken() {
34 try {
35 const response = await fetch('https://auth.fanvue.com/oauth2/token', {
36 method: 'POST',
37 headers: {
38 'Content-Type': 'application/x-www-form-urlencoded',
39 },
40 body: new URLSearchParams({
41 grant_type: 'refresh_token',
42 client_id: process.env.CLIENT_ID,
43 client_secret: process.env.CLIENT_SECRET,
44 refresh_token: this.refreshToken,
45 }),
46 });
47
48 if (!response.ok) {
49 const error = await response.json();
50 throw new Error(`Token refresh failed: ${error.error_description}`);
51 }
52
53 const tokens = await response.json();
54
55 // Update tokens
56 this.accessToken = tokens.access_token;
57 this.refreshToken = tokens.refresh_token;
58 this.tokenExpiresAt = Date.now() + (tokens.expires_in * 1000);
59
60 // Save updated tokens to storage (database, session, etc.)
61 await this.saveTokens();
62
63 return this.accessToken;
64 } catch (error) {
65 console.error('Token refresh error:', error);
66 throw error;
67 }
68 }
69
70 async makeApiRequest(endpoint, options = {}) {
71 // Ensure we have a valid token
72 const token = await this.ensureValidToken();
73
74 const response = await fetch(`https://api.fanvue.com${endpoint}`, {
75 ...options,
76 headers: {
77 ...options.headers,
78 'Authorization': `Bearer ${token}`,
79 },
80 });
81
82 // Handle token expiration during request
83 if (response.status === 401) {
84 // Token might have expired, try refreshing and retry once
85 await this.refreshAccessToken();
86 const token = this.accessToken;
87
88 return fetch(`https://api.fanvue.com${endpoint}`, {
89 ...options,
90 headers: {
91 ...options.headers,
92 'Authorization': `Bearer ${token}`,
93 },
94 });
95 }
96
97 return response;
98 }
99
100 async saveTokens() {
101 // Implement your storage logic here
102 // Example: save to database, update session, etc.
103 }
104}
105
106// Usage
107const client = new FanvueClient(accessToken, refreshToken, expiresAt);
108const response = await client.makeApiRequest('/users/me');
109const user = await response.json();

Token Refresh Best Practices

  1. Proactive Refresh: Refresh tokens before they expire (5-10 minutes buffer)
  2. Handle Concurrent Requests: Use locks/promises to prevent multiple simultaneous refresh attempts
  3. Retry Logic: If a request fails with 401, try refreshing the token once and retry
  4. Secure Storage: Store refresh tokens securely and encrypted
  5. Update Both Tokens: The refresh response may include a new refresh token - always update both
  6. Error Handling: If refresh fails, redirect user to re-authenticate

Best Practices

Security

  • PKCE is mandatory - Always implement PKCE for all OAuth flows (see PKCE section above)
  • Code Verifier Requirements:
    • Minimum 43 characters, maximum 128 characters
    • Use cryptographically secure random generation
    • Store securely server-side (session/Redis preferred) or in HTTP-only cookies
    • Never expose in URLs, local storage, or client-side JavaScript
  • Always use HTTPS in production
  • Store tokens securely (encrypted at rest)
  • Implement proper token refresh logic
  • Use the state parameter to prevent CSRF attacks (minimum 32 random characters)
  • Validate redirect URIs match exactly what’s configured in your app
  • Keep your Client Secret secure and never expose it in client-side code

User Experience

  • Clearly explain what permissions your app needs
  • Handle authorization errors gracefully
  • Provide a way for users to disconnect your app
  • Respect rate limits and user privacy

Token Management

  • Access tokens are short-lived (typically 1 hour)
  • Use refresh tokens to get new access tokens
  • Handle token expiration gracefully
  • Revoke tokens when users disconnect
  • Implement automatic token refresh before expiration

Troubleshooting

Common Errors

This section provides code examples for handling common OAuth errors.

Invalid Client ID

Error Response:

1{
2 "error": "invalid_client",
3 "error_description": "Client authentication failed"
4}

Solutions:

  • Verify your Client ID is correct
  • Ensure the app is active in your developer dashboard

Redirect URI Mismatch

Error Response:

1{
2 "error": "invalid_request",
3 "error_description": "The redirect_uri does not match the registered callback URL"
4}

Solutions:

  • Check that the redirect URI matches exactly what’s configured
  • Ensure the URI is properly URL-encoded
  • Verify protocol (http vs https) matches exactly

Invalid Scope

Error Response:

1{
2 "error": "invalid_scope",
3 "error_description": "The requested scope is invalid or not available"
4}

Solutions:

  • Verify the requested scopes are available and properly formatted
  • Check that your app has been granted the necessary permissions
  • Ensure scopes are space-separated and URL-encoded with +

Token Expired

Error Response:

1{
2 "error": "invalid_grant",
3 "error_description": "Token has expired"
4}

Solutions:

  • Implement automatic token refresh (see Token Refresh Flow section)
  • Handle 401 responses by refreshing tokens

PKCE Validation Failed

Error Response:

1{
2 "error": "invalid_grant",
3 "error_description": "Code verifier does not match code challenge"
4}

Solutions:

  • Ensure you’re sending the original code_verifier, not the code_challenge
  • Verify the code verifier is stored and retrieved correctly
  • Check that the same verifier used to generate the challenge is sent in token exchange

Error Handling Implementation

1class OAuthError extends Error {
2 constructor(error, errorDescription, statusCode) {
3 super(errorDescription || error);
4 this.name = 'OAuthError';
5 this.error = error;
6 this.errorDescription = errorDescription;
7 this.statusCode = statusCode;
8 }
9}
10
11async function handleOAuthRequest(url, options) {
12 try {
13 const response = await fetch(url, options);
14
15 // Handle HTTP errors
16 if (!response.ok) {
17 const contentType = response.headers.get('content-type');
18
19 if (contentType && contentType.includes('application/json')) {
20 const errorData = await response.json();
21 throw new OAuthError(
22 errorData.error || 'unknown_error',
23 errorData.error_description || 'An unknown error occurred',
24 response.status
25 );
26 }
27
28 throw new OAuthError(
29 'http_error',
30 `HTTP ${response.status}: ${response.statusText}`,
31 response.status
32 );
33 }
34
35 return await response.json();
36 } catch (error) {
37 if (error instanceof OAuthError) {
38 throw error;
39 }
40
41 // Handle network errors
42 if (error.name === 'TypeError' && error.message.includes('fetch')) {
43 throw new OAuthError(
44 'network_error',
45 'Network request failed. Please check your connection.',
46 0
47 );
48 }
49
50 throw error;
51 }
52}
53
54// Usage with specific error handling
55async function exchangeCodeForTokens(code, codeVerifier) {
56 try {
57 const tokens = await handleOAuthRequest(
58 'https://auth.fanvue.com/oauth2/token',
59 {
60 method: 'POST',
61 headers: {
62 'Content-Type': 'application/x-www-form-urlencoded',
63 },
64 body: new URLSearchParams({
65 grant_type: 'authorization_code',
66 client_id: process.env.CLIENT_ID,
67 client_secret: process.env.CLIENT_SECRET,
68 code: code,
69 redirect_uri: process.env.REDIRECT_URI,
70 code_verifier: codeVerifier,
71 }),
72 }
73 );
74
75 return tokens;
76 } catch (error) {
77 if (error instanceof OAuthError) {
78 // Handle specific OAuth errors
79 switch (error.error) {
80 case 'invalid_client':
81 console.error('Invalid client credentials. Check CLIENT_ID and CLIENT_SECRET.');
82 break;
83 case 'invalid_grant':
84 if (error.errorDescription.includes('code verifier')) {
85 console.error('PKCE validation failed. Check code_verifier matches code_challenge.');
86 } else if (error.errorDescription.includes('expired')) {
87 console.error('Authorization code expired. User needs to re-authenticate.');
88 }
89 break;
90 case 'invalid_request':
91 if (error.errorDescription.includes('redirect_uri')) {
92 console.error('Redirect URI mismatch. Check your app configuration.');
93 }
94 break;
95 default:
96 console.error(`OAuth error: ${error.error} - ${error.errorDescription}`);
97 }
98 }
99
100 throw error;
101 }
102}
103
104// Retry logic with exponential backoff
105async function makeApiRequestWithRetry(endpoint, options = {}, maxRetries = 3) {
106 let lastError;
107
108 for (let attempt = 0; attempt < maxRetries; attempt++) {
109 try {
110 const response = await fetch(`https://api.fanvue.com${endpoint}`, options);
111
112 if (response.status === 429) {
113 // Rate limit hit
114 const retryAfter = response.headers.get('Retry-After');
115 const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000;
116
117 console.log(`Rate limited. Retrying after ${waitTime}ms...`);
118 await new Promise(resolve => setTimeout(resolve, waitTime));
119 continue;
120 }
121
122 if (response.status >= 500) {
123 // Server error - retry with exponential backoff
124 if (attempt < maxRetries - 1) {
125 const waitTime = Math.pow(2, attempt) * 1000;
126 console.log(`Server error. Retrying after ${waitTime}ms...`);
127 await new Promise(resolve => setTimeout(resolve, waitTime));
128 continue;
129 }
130 }
131
132 if (!response.ok) {
133 const error = await response.json();
134 throw new Error(`API error: ${error.message || response.statusText}`);
135 }
136
137 return await response.json();
138 } catch (error) {
139 lastError = error;
140
141 // Don't retry on client errors (4xx except 429)
142 if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429) {
143 throw error;
144 }
145
146 // If this is the last attempt, throw
147 if (attempt === maxRetries - 1) {
148 throw error;
149 }
150 }
151 }
152
153 throw lastError;
154}

Best Practices for Error Handling

  1. Distinguish Error Types: Separate OAuth errors, network errors, and application errors
  2. Provide Context: Log enough information to debug issues without exposing secrets
  3. Retry Strategically: Retry on server errors (5xx) and rate limits (429), not client errors (4xx)
  4. Exponential Backoff: Wait longer between each retry attempt
  5. User-Friendly Messages: Show helpful error messages to users, not raw error codes
  6. Monitor Errors: Track error rates to identify systemic issues

Support

For additional help with OAuth 2.0 integration: