Back to Blog
OAuthAuthenticationSecurityAPI

OAuth 2.0 Implementation Guide

Implement OAuth correctly. From authorization flows to token handling to security best practices.

B
Bootspring Team
Engineering
February 20, 2023
6 min read

OAuth 2.0 enables secure delegated authorization. Implementing it correctly is crucial for security. Here's how to do it right.

OAuth Flows#

Authorization Code (with PKCE): - Best for web and mobile apps - Most secure - Server exchanges code for tokens Client Credentials: - Machine-to-machine - No user involvement - Service accounts Implicit (Deprecated): - Don't use for new apps - Tokens exposed in URL - Replaced by Auth Code + PKCE Resource Owner Password: - Legacy systems only - User credentials sent to client - Avoid if possible

Authorization Code Flow with PKCE#

1import crypto from 'crypto'; 2 3// Client-side: Generate PKCE verifier and challenge 4function generatePKCE(): { verifier: string; challenge: string } { 5 const verifier = crypto.randomBytes(32).toString('base64url'); 6 const challenge = crypto 7 .createHash('sha256') 8 .update(verifier) 9 .digest('base64url'); 10 11 return { verifier, challenge }; 12} 13 14// Step 1: Redirect to authorization endpoint 15function getAuthorizationUrl(state: string, pkce: { challenge: string }): string { 16 const params = new URLSearchParams({ 17 client_id: process.env.OAUTH_CLIENT_ID!, 18 redirect_uri: process.env.OAUTH_REDIRECT_URI!, 19 response_type: 'code', 20 scope: 'openid profile email', 21 state, 22 code_challenge: pkce.challenge, 23 code_challenge_method: 'S256', 24 }); 25 26 return `https://auth.example.com/authorize?${params}`; 27} 28 29// Step 2: Handle callback and exchange code for tokens 30async function handleCallback( 31 code: string, 32 verifier: string 33): Promise<TokenResponse> { 34 const response = await fetch('https://auth.example.com/token', { 35 method: 'POST', 36 headers: { 37 'Content-Type': 'application/x-www-form-urlencoded', 38 }, 39 body: new URLSearchParams({ 40 grant_type: 'authorization_code', 41 client_id: process.env.OAUTH_CLIENT_ID!, 42 client_secret: process.env.OAUTH_CLIENT_SECRET!, 43 redirect_uri: process.env.OAUTH_REDIRECT_URI!, 44 code, 45 code_verifier: verifier, 46 }), 47 }); 48 49 if (!response.ok) { 50 const error = await response.json(); 51 throw new OAuthError(error.error, error.error_description); 52 } 53 54 return response.json(); 55} 56 57interface TokenResponse { 58 access_token: string; 59 token_type: string; 60 expires_in: number; 61 refresh_token?: string; 62 id_token?: string; 63 scope: string; 64}

OAuth Server Implementation#

1import express from 'express'; 2import jwt from 'jsonwebtoken'; 3 4// Authorization endpoint 5app.get('/authorize', async (req, res) => { 6 const { 7 client_id, 8 redirect_uri, 9 response_type, 10 scope, 11 state, 12 code_challenge, 13 code_challenge_method, 14 } = req.query; 15 16 // Validate client 17 const client = await db.oauthClient.findUnique({ 18 where: { clientId: client_id as string }, 19 }); 20 21 if (!client) { 22 return res.status(400).json({ error: 'invalid_client' }); 23 } 24 25 // Validate redirect URI 26 if (!client.redirectUris.includes(redirect_uri as string)) { 27 return res.status(400).json({ error: 'invalid_redirect_uri' }); 28 } 29 30 // Store authorization request 31 const authRequest = await db.authRequest.create({ 32 data: { 33 clientId: client_id as string, 34 redirectUri: redirect_uri as string, 35 scope: scope as string, 36 state: state as string, 37 codeChallenge: code_challenge as string, 38 codeChallengeMethod: code_challenge_method as string, 39 expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes 40 }, 41 }); 42 43 // Show consent screen 44 res.render('consent', { 45 client, 46 scopes: (scope as string).split(' '), 47 authRequestId: authRequest.id, 48 }); 49}); 50 51// User approves consent 52app.post('/authorize/consent', authenticate, async (req, res) => { 53 const { authRequestId, approved } = req.body; 54 55 const authRequest = await db.authRequest.findUnique({ 56 where: { id: authRequestId }, 57 }); 58 59 if (!authRequest || authRequest.expiresAt < new Date()) { 60 return res.status(400).json({ error: 'invalid_request' }); 61 } 62 63 if (!approved) { 64 const redirectUrl = new URL(authRequest.redirectUri); 65 redirectUrl.searchParams.set('error', 'access_denied'); 66 redirectUrl.searchParams.set('state', authRequest.state); 67 return res.redirect(redirectUrl.toString()); 68 } 69 70 // Generate authorization code 71 const code = crypto.randomBytes(32).toString('hex'); 72 73 await db.authCode.create({ 74 data: { 75 code, 76 clientId: authRequest.clientId, 77 userId: req.user.id, 78 redirectUri: authRequest.redirectUri, 79 scope: authRequest.scope, 80 codeChallenge: authRequest.codeChallenge, 81 codeChallengeMethod: authRequest.codeChallengeMethod, 82 expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes 83 }, 84 }); 85 86 // Redirect with code 87 const redirectUrl = new URL(authRequest.redirectUri); 88 redirectUrl.searchParams.set('code', code); 89 redirectUrl.searchParams.set('state', authRequest.state); 90 91 res.redirect(redirectUrl.toString()); 92}); 93 94// Token endpoint 95app.post('/token', async (req, res) => { 96 const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = 97 req.body; 98 99 // Validate client credentials 100 const client = await db.oauthClient.findUnique({ 101 where: { clientId: client_id }, 102 }); 103 104 if (!client || client.clientSecret !== client_secret) { 105 return res.status(401).json({ error: 'invalid_client' }); 106 } 107 108 if (grant_type === 'authorization_code') { 109 return handleAuthorizationCodeGrant(req, res, client, code, redirect_uri, code_verifier); 110 } 111 112 if (grant_type === 'refresh_token') { 113 return handleRefreshTokenGrant(req, res, client, refresh_token); 114 } 115 116 res.status(400).json({ error: 'unsupported_grant_type' }); 117}); 118 119async function handleAuthorizationCodeGrant( 120 req: Request, 121 res: Response, 122 client: OAuthClient, 123 code: string, 124 redirectUri: string, 125 codeVerifier: string 126): Promise<void> { 127 const authCode = await db.authCode.findUnique({ where: { code } }); 128 129 if (!authCode || authCode.expiresAt < new Date()) { 130 res.status(400).json({ error: 'invalid_grant' }); 131 return; 132 } 133 134 if (authCode.clientId !== client.clientId) { 135 res.status(400).json({ error: 'invalid_grant' }); 136 return; 137 } 138 139 if (authCode.redirectUri !== redirectUri) { 140 res.status(400).json({ error: 'invalid_grant' }); 141 return; 142 } 143 144 // Verify PKCE 145 const challenge = crypto 146 .createHash('sha256') 147 .update(codeVerifier) 148 .digest('base64url'); 149 150 if (challenge !== authCode.codeChallenge) { 151 res.status(400).json({ error: 'invalid_grant' }); 152 return; 153 } 154 155 // Delete used code (one-time use) 156 await db.authCode.delete({ where: { code } }); 157 158 // Generate tokens 159 const tokens = await generateTokens(authCode.userId, client, authCode.scope); 160 161 res.json(tokens); 162} 163 164async function generateTokens( 165 userId: string, 166 client: OAuthClient, 167 scope: string 168): Promise<TokenResponse> { 169 const accessToken = jwt.sign( 170 { 171 sub: userId, 172 client_id: client.clientId, 173 scope, 174 }, 175 process.env.JWT_SECRET!, 176 { expiresIn: '1h' } 177 ); 178 179 const refreshToken = crypto.randomBytes(32).toString('hex'); 180 181 await db.refreshToken.create({ 182 data: { 183 token: refreshToken, 184 userId, 185 clientId: client.clientId, 186 scope, 187 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days 188 }, 189 }); 190 191 return { 192 access_token: accessToken, 193 token_type: 'Bearer', 194 expires_in: 3600, 195 refresh_token: refreshToken, 196 scope, 197 }; 198}

Token Validation#

1// Validate access tokens 2function validateAccessToken(token: string): TokenPayload { 3 try { 4 const payload = jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload; 5 6 // Check if token is revoked 7 // ... revocation check 8 9 return payload; 10 } catch (error) { 11 if (error instanceof jwt.TokenExpiredError) { 12 throw new OAuthError('invalid_token', 'Token has expired'); 13 } 14 throw new OAuthError('invalid_token', 'Invalid token'); 15 } 16} 17 18// Middleware 19function requireAuth(scopes?: string[]) { 20 return async (req: Request, res: Response, next: NextFunction) => { 21 const authHeader = req.headers.authorization; 22 23 if (!authHeader?.startsWith('Bearer ')) { 24 return res.status(401).json({ error: 'invalid_token' }); 25 } 26 27 const token = authHeader.slice(7); 28 29 try { 30 const payload = validateAccessToken(token); 31 32 // Check scopes 33 if (scopes) { 34 const tokenScopes = payload.scope.split(' '); 35 const hasScope = scopes.every((s) => tokenScopes.includes(s)); 36 if (!hasScope) { 37 return res.status(403).json({ error: 'insufficient_scope' }); 38 } 39 } 40 41 req.user = payload; 42 next(); 43 } catch (error) { 44 if (error instanceof OAuthError) { 45 return res.status(401).json({ error: error.code, error_description: error.message }); 46 } 47 return res.status(401).json({ error: 'invalid_token' }); 48 } 49 }; 50} 51 52// Usage 53app.get('/api/profile', requireAuth(['profile']), async (req, res) => { 54 const user = await db.user.findUnique({ where: { id: req.user.sub } }); 55 res.json(user); 56});

Refresh Token Rotation#

1async function handleRefreshTokenGrant( 2 req: Request, 3 res: Response, 4 client: OAuthClient, 5 refreshToken: string 6): Promise<void> { 7 const storedToken = await db.refreshToken.findUnique({ 8 where: { token: refreshToken }, 9 }); 10 11 if (!storedToken || storedToken.expiresAt < new Date()) { 12 res.status(400).json({ error: 'invalid_grant' }); 13 return; 14 } 15 16 if (storedToken.clientId !== client.clientId) { 17 res.status(400).json({ error: 'invalid_grant' }); 18 return; 19 } 20 21 // Delete old refresh token (rotation) 22 await db.refreshToken.delete({ where: { token: refreshToken } }); 23 24 // Generate new tokens 25 const tokens = await generateTokens(storedToken.userId, client, storedToken.scope); 26 27 res.json(tokens); 28}

Best Practices#

Security: ✓ Always use PKCE ✓ Validate redirect URIs exactly ✓ Use short-lived access tokens ✓ Rotate refresh tokens ✓ Store tokens securely Implementation: ✓ Use state parameter against CSRF ✓ Validate all inputs ✓ Log security events ✓ Rate limit token endpoints Token Handling: ✓ Never expose tokens in URLs ✓ Use HttpOnly cookies when possible ✓ Clear tokens on logout ✓ Implement token revocation

Conclusion#

OAuth 2.0 requires careful implementation. Use Authorization Code with PKCE, validate everything, rotate refresh tokens, and follow security best practices. When possible, use battle-tested libraries rather than implementing from scratch.

Share this article

Help spread the word about Bootspring