Deep Dive: OAuth 2.0 Authentication
What is OAuth 2.0?
OAuth 2.0 is an authorization framework (RFC 6749) that allows a third-party application to access a user’s resources on another service — without the user sharing their password. When you click “Sign in with Google” or “Connect your GitHub account,” OAuth 2.0 is the protocol orchestrating that handshake behind the scenes.
The key insight: OAuth separates the concepts of authentication (who is this user?) and authorization (what can this app do on the user’s behalf?). OAuth 2.0 itself only handles authorization. OpenID Connect (OIDC), built on top of OAuth 2.0, adds the authentication layer by introducing the ID token — a JWT containing verified user identity claims.
OAuth 2.0 was published in 2012 as a replacement for OAuth 1.0, which required complex request signing. The simplified model uses bearer tokens and HTTPS, making integration significantly easier while maintaining security when implemented correctly.
Core Principles
- Delegated access: Users grant limited, scoped permissions to applications without sharing credentials.
- Authorization server as intermediary: A trusted third party (Google, GitHub, Azure AD) handles the actual authentication and consent.
- Token-based: After the authorization dance, the app receives tokens (access, refresh, and optionally ID) to interact with APIs.
- Scoped permissions: Applications request only the access they need (e.g.,
read:email,repo:read).
Authorization Code Flow (with PKCE)
This is the most common and most secure flow, used by web apps, mobile apps, and SPAs.
sequenceDiagram
participant U as User
participant App as Your Application
participant AS as Authorization Server (Google/GitHub)
participant API as Resource API
U->>App: Click "Sign in with Google"
App->>App: Generate code_verifier + code_challenge (PKCE)
App->>AS: Redirect to /authorize?response_type=code&client_id=...&code_challenge=...
AS->>U: Show login + consent screen
U->>AS: Enter credentials, approve scopes
AS->>App: Redirect to callback with ?code=AUTH_CODE
App->>AS: POST /token {code, client_secret, code_verifier}
AS-->>App: {access_token, refresh_token, id_token}
App->>API: GET /userinfo (Authorization: Bearer <access_token>)
API-->>App: {sub, email, name, picture}
App->>App: Create/find user, issue session or JWT
App-->>U: Logged in — redirect to dashboard
Now let’s explore the distinction between OAuth 2.0 and OpenID Connect in detail.
OAuth 2.0 vs OpenID Connect: The Critical Distinction
Many developers confuse OAuth 2.0 with OpenID Connect (OIDC). They’re related but solve different problems:
OAuth 2.0 is an authorization framework. It answers: “What can this application access on behalf of the user?” OAuth is primarily about delegating access to resources—like reading GitHub repositories or Google Drive files.
OpenID Connect is an authentication layer built on top of OAuth 2.0. It adds identity information. It answers: “Who is this user?” OIDC introduces the ID token (a JWT) containing user identity claims, enabling social login scenarios.
In Practice
- Use OAuth 2.0 alone when you need API access: integrating with Stripe, accessing Google Drive, reading GitHub repos
- Use OpenID Connect when implementing “Sign in with Google” or “Sign in with GitHub”—you care about identifying the user
- Use both in typical scenarios: “Sign in with GitHub” + “allow this app to read your repos”
Authorization Code Flow with PKCE
The Authorization Code Flow is the most secure flow for web applications and mobile apps. PKCE (Proof Key for Code Exchange) adds additional security, especially for mobile and SPA applications.
Authorization Code Flow (with PKCE) - Step by Step
User → Your App → Authorization Server → Your App → Authorization Server → Your App
↓ ↓ ↓ ↓ ↓ ↓
Click Redirect User logs in Exchanges code Returns tokens Logged in
Login to provider & consents for access token (access, refresh)
Step 1: Generate Challenge Your application generates a random string (code verifier) and creates a challenge from it:
import crypto from 'crypto';
function generateCodeChallenge(codeVerifier: string): string {
return crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = generateCodeChallenge(codeVerifier);import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class PKCEUtil {
public static String generateCodeVerifier() {
SecureRandom sr = new SecureRandom();
byte[] code = new byte[32];
sr.nextBytes(code);
return bytesToHex(code);
}
public static String generateCodeChallenge(String codeVerifier) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes("UTF-8"));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
}import hashlib
import os
import base64
def generate_code_verifier() -> str:
return os.urandom(32).hex()
def generate_code_challenge(code_verifier: str) -> str:
digest = hashlib.sha256(code_verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)using System.Security.Cryptography;
using System.Text;
public static class PKCEUtil
{
public static string GenerateCodeVerifier()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToHexString(bytes).ToLower();
}
public static string GenerateCodeChallenge(string codeVerifier)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}Step 2: Redirect to Authorization Server
https://provider.com/authorize?
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
response_type=code&
scope=openid%20profile%20email&
state=random_state_value&
code_challenge=CHALLENGE&
code_challenge_method=S256
Step 3: Authorization Server Redirects Back After user approval:
https://yourapp.com/callback?
code=AUTH_CODE&
state=random_state_value
Step 4: Exchange Code for Tokens Your backend exchanges the code (proving the code verifier) for tokens:
const response = await fetch('https://provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
client_id: YOUR_CLIENT_ID,
client_secret: YOUR_CLIENT_SECRET,
redirect_uri: 'https://yourapp.com/callback',
code_verifier: codeVerifier, // Proves we own the challenge
}),
});
const tokens = await response.json();import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
public Map<String, Object> exchangeCodeForTokens(
String authCode, String codeVerifier) {
RestTemplate rest = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", authCode);
body.add("client_id", YOUR_CLIENT_ID);
body.add("client_secret", YOUR_CLIENT_SECRET);
body.add("redirect_uri", "https://yourapp.com/callback");
body.add("code_verifier", codeVerifier);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = rest.postForEntity(
"https://provider.com/token", request, Map.class);
return response.getBody();
}import httpx
async def exchange_code_for_tokens(auth_code: str, code_verifier: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://provider.com/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"client_id": YOUR_CLIENT_ID,
"client_secret": YOUR_CLIENT_SECRET,
"redirect_uri": "https://yourapp.com/callback",
"code_verifier": code_verifier,
},
)
response.raise_for_status()
return response.json()using System.Net.Http;
using System.Collections.Generic;
using System.Text.Json;
public async Task<JsonDocument> ExchangeCodeForTokensAsync(
string authCode, string codeVerifier)
{
using var client = new HttpClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = authCode,
["client_id"] = YOUR_CLIENT_ID,
["client_secret"] = YOUR_CLIENT_SECRET,
["redirect_uri"] = "https://yourapp.com/callback",
["code_verifier"] = codeVerifier,
});
var response = await client.PostAsync("https://provider.com/token", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json);
}Other Grant Types: A Quick Reference
Client Credentials Flow
For server-to-server authentication when there’s no user involved:
const response = await fetch('https://provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: YOUR_CLIENT_ID,
client_secret: YOUR_CLIENT_SECRET,
scope: 'api.read api.write',
}),
});MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", YOUR_CLIENT_ID);
body.add("client_secret", YOUR_CLIENT_SECRET);
body.add("scope", "api.read api.write");
HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(body, headers);
ResponseEntity<Map> response = rest.postForEntity(
"https://provider.com/token", request, Map.class);async with httpx.AsyncClient() as client:
response = await client.post(
"https://provider.com/token",
data={
"grant_type": "client_credentials",
"client_id": YOUR_CLIENT_ID,
"client_secret": YOUR_CLIENT_SECRET,
"scope": "api.read api.write",
},
)
response.raise_for_status()
tokens = response.json()var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = YOUR_CLIENT_ID,
["client_secret"] = YOUR_CLIENT_SECRET,
["scope"] = "api.read api.write",
});
var response = await client.PostAsync("https://provider.com/token", content);
response.EnsureSuccessStatusCode();Use cases: CI/CD pipelines accessing APIs, backend services talking to each other, scheduled jobs.
Device Code Flow
For devices without browsers (smart TVs, IoT devices, CLIs):
The device displays a code to the user, who enters it on another device. The device polls for authorization completion.
Implicit Flow (Deprecated)
Legacy flow returning tokens directly in the URL. Don’t use this—it’s vulnerable to token exposure in browser history and referer headers. The Authorization Code Flow with PKCE is now the standard for SPAs.
Implementing Google OAuth with Passport.js
Here’s a complete, production-ready implementation using Passport.js:
Installation
npm install passport passport-google-oauth20 express-session
npm install -D @types/passport-google-oauth20
Configuration
// src/config/passport.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { User } from '@/models/user';
import { findOrCreateUser } from '@/services/auth';
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback',
scope: ['profile', 'email'],
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
const user = await findOrCreateUser({
provider: 'google',
providerId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
picture: profile.photos?.[0]?.value,
accessToken,
refreshToken,
});
done(null, user);
} catch (error) {
done(error);
}
}
)
);
// Serialize user for session
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id: string, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (error) {
done(error);
}
});
export default passport;// Spring Boot — application.properties + OAuth2 auto-configuration
// application.properties:
// spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
// spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
// spring.security.oauth2.client.registration.google.scope=profile,email
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomOAuth2UserService oAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(info -> info
.userService(oAuth2UserService))
.successHandler(this::onAuthSuccess)
.failureUrl("/login?error=true"))
.logout(logout -> logout
.logoutUrl("/auth/logout")
.deleteCookies("JSESSIONID"));
return http.build();
}
private void onAuthSuccess(HttpServletRequest req,
HttpServletResponse res, Authentication auth) throws IOException {
// Issue session token, redirect to dashboard
res.sendRedirect("/dashboard");
}
}
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired private UserRepository userRepo;
@Override
public OAuth2User loadUser(OAuth2UserRequest req) {
OAuth2User oauthUser = super.loadUser(req);
String provider = req.getClientRegistration().getRegistrationId();
String providerId = oauthUser.getName();
String email = oauthUser.getAttribute("email");
String name = oauthUser.getAttribute("name");
String picture = oauthUser.getAttribute("picture");
userRepo.findByProviderAndProviderId(provider, providerId)
.orElseGet(() -> userRepo.save(User.builder()
.provider(provider).providerId(providerId)
.email(email).name(name).picture(picture)
.emailVerified(true).build()));
return oauthUser;
}
}# FastAPI + Authlib
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
config = Config(".env")
oauth = OAuth(config)
oauth.register(
name="google",
client_id=config("GOOGLE_CLIENT_ID"),
client_secret=config("GOOGLE_CLIENT_SECRET"),
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
@router.get("/auth/google")
async def google_login(request: Request):
redirect_uri = request.url_for("google_callback")
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.google.authorize_access_token(request)
profile = token.get("userinfo")
user = find_or_create_user(db, provider="google",
provider_id=profile["sub"], email=profile["email"],
name=profile.get("name"), picture=profile.get("picture"),
access_token=token["access_token"])
session_token = create_session(db, user.id)
response = RedirectResponse("/dashboard")
response.set_cookie("sessionToken", session_token,
httponly=True, secure=True, samesite="lax",
max_age=7 * 24 * 60 * 60)
return response// Program.cs — ASP.NET Core
builder.Services.AddAuthentication(options => {
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogle(options => {
options.ClientId = builder.Configuration["Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true;
options.Events.OnCreatingTicket = async ctx => {
var userService = ctx.HttpContext.RequestServices
.GetRequiredService<IUserService>();
await userService.FindOrCreateAsync(new OAuthProfile {
Provider = "google",
ProviderId = ctx.Principal!.FindFirstValue(ClaimTypes.NameIdentifier)!,
Email = ctx.Principal.FindFirstValue(ClaimTypes.Email)!,
Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
Picture = ctx.Principal.FindFirstValue("picture"),
AccessToken = ctx.AccessToken,
RefreshToken = ctx.RefreshToken,
});
};
});Routes
// src/routes/auth.ts
import express from 'express';
import passport from 'passport';
import { generateSessionToken } from '@/utils/jwt';
const router = express.Router();
// Initiate Google login
router.get(
'/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
accessType: 'offline', // Request refresh token
})
);
// Google callback
router.get(
'/google/callback',
passport.authenticate('google', { failureRedirect: '/login?error=true' }),
(req, res) => {
// User is authenticated
const sessionToken = generateSessionToken(req.user);
// Set secure, httpOnly cookie
res.cookie('sessionToken', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.redirect('/dashboard');
}
);
// Logout
router.post('/logout', (req, res) => {
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('sessionToken');
res.json({ success: true });
});
});
export default router;@RestController
@RequestMapping("/auth")
public class AuthController {
@GetMapping("/google")
public void googleLogin(HttpServletResponse response) throws IOException {
// Spring Security handles redirect automatically via OAuth2LoginConfigurer
response.sendRedirect("/oauth2/authorization/google");
}
// Spring Security handles /login/oauth2/code/google callback automatically.
// Customize via successHandler in SecurityConfig.
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(
HttpServletRequest req, HttpServletResponse res,
Authentication auth) throws Exception {
new SecurityContextLogoutHandler().logout(req, res, auth);
// Clear session cookie
ResponseCookie cookie = ResponseCookie.from("sessionToken", "")
.httpOnly(true).secure(true).maxAge(0).path("/").build();
res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(Map.of("success", true));
}
}from fastapi import APIRouter, Request, Response
from starlette.responses import RedirectResponse, JSONResponse
router = APIRouter()
@router.get("/auth/google")
async def google_login(request: Request):
redirect_uri = request.url_for("google_callback")
return await oauth.google.authorize_redirect(request, redirect_uri,
access_type="offline")
@router.get("/auth/google/callback", name="google_callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.google.authorize_access_token(request)
profile = token.get("userinfo")
user = find_or_create_user(db, provider="google",
provider_id=profile["sub"], email=profile["email"],
name=profile.get("name"), access_token=token["access_token"])
session_token = create_session(db, user.id)
resp = RedirectResponse("/dashboard")
resp.set_cookie("sessionToken", session_token,
httponly=True, secure=True, samesite="lax",
max_age=7 * 24 * 60 * 60)
return resp
@router.post("/auth/logout")
async def logout(response: Response):
response.delete_cookie("sessionToken")
return {"success": True}[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
[HttpGet("google")]
public IActionResult GoogleLogin() =>
Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
GoogleDefaults.AuthenticationScheme);
[HttpGet("google/callback")]
public async Task<IActionResult> GoogleCallback()
{
var result = await HttpContext.AuthenticateAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
if (!result.Succeeded)
return Redirect("/login?error=true");
return Redirect("/dashboard");
}
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Response.Cookies.Delete("sessionToken");
return Ok(new { success = true });
}
}User Service
// src/services/auth.ts
import { User } from '@/models/user';
import bcrypt from 'bcrypt';
export async function findOrCreateUser(profile: {
provider: string;
providerId: string;
email: string;
name: string;
picture?: string;
accessToken: string;
refreshToken?: string;
}) {
// Look for existing user with this OAuth provider
let user = await User.findOne({
'oauth.provider': profile.provider,
'oauth.providerId': profile.providerId,
});
if (user) {
// Update tokens if user exists
user.oauth.accessToken = profile.accessToken;
if (profile.refreshToken) {
user.oauth.refreshToken = profile.refreshToken;
}
await user.save();
return user;
}
// Check if user exists by email
user = await User.findOne({ email: profile.email });
if (user) {
// Link OAuth to existing user
user.oauth = {
provider: profile.provider,
providerId: profile.providerId,
accessToken: profile.accessToken,
refreshToken: profile.refreshToken,
};
await user.save();
return user;
}
// Create new user
user = new User({
email: profile.email,
name: profile.name,
picture: profile.picture,
emailVerified: true, // OAuth providers verify email
oauth: {
provider: profile.provider,
providerId: profile.providerId,
accessToken: profile.accessToken,
refreshToken: profile.refreshToken,
},
});
await user.save();
return user;
}@Service
@Transactional
public class AuthService {
@Autowired private UserRepository userRepo;
public User findOrCreateUser(OAuthProfile profile) {
// Look for existing user with this OAuth provider
return userRepo.findByProviderAndProviderId(
profile.provider(), profile.providerId())
.map(user -> {
user.setAccessToken(profile.accessToken());
if (profile.refreshToken() != null)
user.setRefreshToken(profile.refreshToken());
return userRepo.save(user);
})
.orElseGet(() -> userRepo.findByEmail(profile.email())
.map(user -> {
// Link OAuth to existing user
user.setProvider(profile.provider());
user.setProviderId(profile.providerId());
user.setAccessToken(profile.accessToken());
user.setRefreshToken(profile.refreshToken());
return userRepo.save(user);
})
.orElseGet(() -> userRepo.save(User.builder()
.email(profile.email())
.name(profile.name())
.picture(profile.picture())
.emailVerified(true)
.provider(profile.provider())
.providerId(profile.providerId())
.accessToken(profile.accessToken())
.refreshToken(profile.refreshToken())
.build())));
}
}from sqlalchemy.orm import Session
from models import User
def find_or_create_user(
db: Session,
provider: str,
provider_id: str,
email: str,
name: str,
picture: str | None = None,
access_token: str = "",
refresh_token: str | None = None,
) -> User:
# Look for existing user with this OAuth provider
user = db.query(User).filter_by(
provider=provider, provider_id=provider_id).first()
if user:
user.access_token = access_token
if refresh_token:
user.refresh_token = refresh_token
db.commit()
return user
# Check if user exists by email
user = db.query(User).filter_by(email=email).first()
if user:
user.provider = provider
user.provider_id = provider_id
user.access_token = access_token
user.refresh_token = refresh_token
db.commit()
return user
# Create new user
user = User(
email=email, name=name, picture=picture,
email_verified=True, provider=provider,
provider_id=provider_id, access_token=access_token,
refresh_token=refresh_token,
)
db.add(user)
db.commit()
return userpublic class AuthService : IAuthService
{
private readonly AppDbContext _db;
public AuthService(AppDbContext db) => _db = db;
public async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
{
// Look for existing user with this OAuth provider
var user = await _db.Users.FirstOrDefaultAsync(u =>
u.Provider == profile.Provider &&
u.ProviderId == profile.ProviderId);
if (user != null)
{
user.AccessToken = profile.AccessToken;
if (profile.RefreshToken != null)
user.RefreshToken = profile.RefreshToken;
await _db.SaveChangesAsync();
return user;
}
// Check if user exists by email
user = await _db.Users.FirstOrDefaultAsync(u => u.Email == profile.Email);
if (user != null)
{
user.Provider = profile.Provider;
user.ProviderId = profile.ProviderId;
user.AccessToken = profile.AccessToken;
user.RefreshToken = profile.RefreshToken;
await _db.SaveChangesAsync();
return user;
}
// Create new user
user = new User {
Email = profile.Email, Name = profile.Name,
Picture = profile.Picture, EmailVerified = true,
Provider = profile.Provider, ProviderId = profile.ProviderId,
AccessToken = profile.AccessToken, RefreshToken = profile.RefreshToken,
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user;
}
}Implementing GitHub OAuth
GitHub OAuth follows the same pattern but with provider-specific configuration:
// src/config/passport-github.ts
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { findOrCreateUser } from '@/services/auth';
passport.use(
'github',
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackURL: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/auth/github/callback',
scope: ['user:email', 'repo'], // Request specific scopes
},
async (accessToken, refreshToken, profile, done) => {
try {
const primaryEmail = profile.emails?.find(e => e.primary)?.value ||
profile.emails?.[0]?.value;
const user = await findOrCreateUser({
provider: 'github',
providerId: String(profile.id),
email: primaryEmail!,
name: profile.displayName || profile.username || '',
picture: profile.photos?.[0]?.value,
accessToken,
});
done(null, user);
} catch (error) {
done(error);
}
}
)
);// Spring Boot — application.properties
// spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID}
// spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET}
// spring.security.oauth2.client.registration.github.scope=user:email,repo
@Service
public class GitHubOAuth2UserService extends DefaultOAuth2UserService {
@Autowired private UserRepository userRepo;
@Override
public OAuth2User loadUser(OAuth2UserRequest req) {
OAuth2User oauthUser = super.loadUser(req);
String providerId = String.valueOf(oauthUser.getAttribute("id"));
String name = oauthUser.getAttribute("name");
if (name == null) name = oauthUser.getAttribute("login");
String picture = oauthUser.getAttribute("avatar_url");
// Fetch primary email via GitHub API if needed
String email = oauthUser.getAttribute("email");
String accessToken = req.getAccessToken().getTokenValue();
userRepo.findByProviderAndProviderId("github", providerId)
.orElseGet(() -> userRepo.save(User.builder()
.provider("github").providerId(providerId)
.email(email).name(name).picture(picture)
.accessToken(accessToken)
.emailVerified(true).build()));
return oauthUser;
}
}oauth.register(
name="github",
client_id=config("GITHUB_CLIENT_ID"),
client_secret=config("GITHUB_CLIENT_SECRET"),
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com/",
client_kwargs={"scope": "user:email repo"},
)
@router.get("/auth/github/callback")
async def github_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.github.authorize_access_token(request)
profile_resp = await oauth.github.get("user", token=token)
profile = profile_resp.json()
# Get primary email if not public
emails_resp = await oauth.github.get("user/emails", token=token)
emails = emails_resp.json()
primary_email = next(
(e["email"] for e in emails if e.get("primary")),
profile.get("email"))
user = find_or_create_user(db, provider="github",
provider_id=str(profile["id"]),
email=primary_email,
name=profile.get("name") or profile.get("login", ""),
picture=profile.get("avatar_url"),
access_token=token["access_token"])
session_token = create_session(db, user.id)
resp = RedirectResponse("/dashboard")
resp.set_cookie("sessionToken", session_token,
httponly=True, secure=True, samesite="lax",
max_age=7 * 24 * 60 * 60)
return resp// Program.cs
builder.Services.AddAuthentication()
.AddGitHub(options => {
options.ClientId = builder.Configuration["GitHub:ClientId"]!;
options.ClientSecret = builder.Configuration["GitHub:ClientSecret"]!;
options.Scope.Add("user:email");
options.Scope.Add("repo");
options.SaveTokens = true;
options.Events.OnCreatingTicket = async ctx => {
var userService = ctx.HttpContext.RequestServices
.GetRequiredService<IUserService>();
var primaryEmail = ctx.Principal!
.FindFirstValue(ClaimTypes.Email) ?? "";
await userService.FindOrCreateAsync(new OAuthProfile {
Provider = "github",
ProviderId = ctx.Principal.FindFirstValue(ClaimTypes.NameIdentifier)!,
Email = primaryEmail,
Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
Picture = ctx.Principal.FindFirstValue("urn:github:avatar"),
AccessToken = ctx.AccessToken,
});
};
});Routes are identical to Google, just swap the provider name:
// GitHub routes
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));
router.get(
'/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
const sessionToken = generateSessionToken(req.user);
res.cookie('sessionToken', sessionToken, { /* ... */ });
res.redirect('/dashboard');
}
);@GetMapping("/github")
public void githubLogin(HttpServletResponse response) throws IOException {
response.sendRedirect("/oauth2/authorization/github");
}
// Spring Security handles /login/oauth2/code/github automatically.
// Success redirects to /dashboard via successHandler in SecurityConfig.@router.get("/auth/github")
async def github_login(request: Request):
redirect_uri = request.url_for("github_callback")
return await oauth.github.authorize_redirect(request, redirect_uri)[HttpGet("github")]
public IActionResult GitHubLogin() =>
Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
"GitHub");Accessing User’s GitHub Repositories
Once authenticated, use the stored access token to call GitHub’s API:
// src/services/github.ts
export async function getUserRepositories(accessToken: string) {
const response = await fetch('https://api.github.com/user/repos', {
headers: {
Authorization: `token ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return response.json();
}@Service
public class GitHubService {
private final RestTemplate restTemplate = new RestTemplate();
public List<Map<String, Object>> getUserRepositories(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "token " + accessToken);
headers.set("Accept", "application/vnd.github.v3+json");
ResponseEntity<List> response = restTemplate.exchange(
"https://api.github.com/user/repos",
HttpMethod.GET,
new HttpEntity<>(headers),
List.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("GitHub API error: " + response.getStatusCode());
}
return response.getBody();
}
}import httpx
from typing import Any
async def get_user_repositories(access_token: str) -> list[dict[str, Any]]:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.github.com/user/repos",
headers={
"Authorization": f"token {access_token}",
"Accept": "application/vnd.github.v3+json",
},
)
if not response.is_success:
raise RuntimeError(f"GitHub API error: {response.status_code}")
return response.json()public class GitHubService
{
private readonly HttpClient _http;
public GitHubService(HttpClient http) => _http = http;
public async Task<List<JsonElement>> GetUserRepositoriesAsync(string accessToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, "https://api.github.com/user/repos");
request.Headers.Add("Authorization", $"token {accessToken}");
request.Headers.Add("Accept", "application/vnd.github.v3+json");
request.Headers.Add("User-Agent", "MyApp/1.0");
var response = await _http.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<JsonElement>>(json)!;
}
}Managing OAuth Tokens
OAuth introduces three types of tokens:
Access Token
- Short-lived (typically 1 hour)
- Used to authenticate API requests
- Should be stored securely
- Never exposed to the browser in localStorage
// Use access token for API requests
const headers = {
Authorization: `Bearer ${accessToken}`,
};// Use access token for API requests
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);# Use access token for API requests
headers = {"Authorization": f"Bearer {access_token}"}// Use access token for API requests
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);Refresh Token
- Long-lived (days to months)
- Exchanged for a new access token when it expires
- Should be stored securely (httpOnly cookie or secure storage)
- Never sent to the browser
ID Token
- OpenID Connect only
- Contains user identity claims (name, email, picture, etc.)
- JWT format, can be decoded client-side (but always verify signature server-side)
// Decode ID token (client-side only for UI)
const decoded = jwtDecode(idToken);
console.log(decoded.email, decoded.name);
Token Refresh Implementation
// src/services/token.ts
export async function refreshAccessToken(refreshToken: string) {
const response = await fetch('https://provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
}),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresIn: data.expires_in,
};
}
// Middleware to refresh token if needed
export async function ensureValidToken(user: any) {
if (!user.oauth?.accessToken) {
throw new Error('No access token');
}
// Check if token expires in less than 5 minutes
const expiresAt = user.oauth.accessTokenExpiresAt;
if (expiresAt && new Date() > new Date(expiresAt.getTime() - 5 * 60 * 1000)) {
const refreshed = await refreshAccessToken(user.oauth.refreshToken);
user.oauth.accessToken = refreshed.accessToken;
user.oauth.accessTokenExpiresAt = new Date(Date.now() + refreshed.expiresIn * 1000);
await user.save();
}
return user.oauth.accessToken;
}@Service
public class TokenService {
@Autowired private UserRepository userRepo;
private final RestTemplate restTemplate = new RestTemplate();
@Value("${oauth.client-id}") private String clientId;
@Value("${oauth.client-secret}") private String clientSecret;
public TokenResponse refreshAccessToken(String refreshToken) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "refresh_token");
body.add("refresh_token", refreshToken);
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
ResponseEntity<Map> resp = restTemplate.postForEntity(
"https://provider.com/token",
new HttpEntity<>(body, headers), Map.class);
if (!resp.getStatusCode().is2xxSuccessful())
throw new RuntimeException("Token refresh failed");
Map<?, ?> data = resp.getBody();
String newRefresh = data.containsKey("refresh_token")
? (String) data.get("refresh_token") : refreshToken;
return new TokenResponse(
(String) data.get("access_token"),
newRefresh,
(Integer) data.get("expires_in"));
}
public String ensureValidToken(User user) {
if (user.getAccessToken() == null)
throw new IllegalStateException("No access token");
Instant expiresAt = user.getAccessTokenExpiresAt();
if (expiresAt != null &&
Instant.now().isAfter(expiresAt.minusSeconds(300))) {
TokenResponse refreshed = refreshAccessToken(user.getRefreshToken());
user.setAccessToken(refreshed.accessToken());
user.setAccessTokenExpiresAt(
Instant.now().plusSeconds(refreshed.expiresIn()));
userRepo.save(user);
}
return user.getAccessToken();
}
}import httpx
from datetime import datetime, timedelta, timezone
async def refresh_access_token(refresh_token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://provider.com/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.OAUTH_CLIENT_ID,
"client_secret": settings.OAUTH_CLIENT_SECRET,
},
)
if not response.is_success:
raise RuntimeError("Token refresh failed")
data = response.json()
return {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", refresh_token),
"expires_in": data["expires_in"],
}
async def ensure_valid_token(user: User, db: Session) -> str:
if not user.access_token:
raise ValueError("No access token")
expires_at = user.access_token_expires_at
if expires_at and datetime.now(timezone.utc) > expires_at - timedelta(minutes=5):
refreshed = await refresh_access_token(user.refresh_token)
user.access_token = refreshed["access_token"]
user.access_token_expires_at = (
datetime.now(timezone.utc) + timedelta(seconds=refreshed["expires_in"]))
db.commit()
return user.access_tokenpublic class TokenService
{
private readonly HttpClient _http;
private readonly AppDbContext _db;
private readonly IConfiguration _config;
public TokenService(HttpClient http, AppDbContext db, IConfiguration config)
{ _http = http; _db = db; _config = config; }
public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
{
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = _config["OAuth:ClientId"]!,
["client_secret"] = _config["OAuth:ClientSecret"]!,
});
var response = await _http.PostAsync("https://provider.com/token", content);
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadFromJsonAsync<JsonDocument>();
var root = data!.RootElement;
return new TokenResponse(
AccessToken: root.GetProperty("access_token").GetString()!,
RefreshToken: root.TryGetProperty("refresh_token", out var rt)
? rt.GetString()! : refreshToken,
ExpiresIn: root.GetProperty("expires_in").GetInt32());
}
public async Task<string> EnsureValidTokenAsync(User user)
{
if (user.AccessToken == null)
throw new InvalidOperationException("No access token");
if (user.AccessTokenExpiresAt.HasValue &&
DateTimeOffset.UtcNow > user.AccessTokenExpiresAt.Value.AddMinutes(-5))
{
var refreshed = await RefreshAccessTokenAsync(user.RefreshToken!);
user.AccessToken = refreshed.AccessToken;
user.AccessTokenExpiresAt = DateTimeOffset.UtcNow
.AddSeconds(refreshed.ExpiresIn);
await _db.SaveChangesAsync();
}
return user.AccessToken;
}
}Handling User Creation and Linking
When users first authenticate with OAuth, you need to decide:
- Create a new account immediately
- Match to existing email
- Require account linking
Strategy: Auto-link by Email
export async function findOrCreateUser(profile: OAuthProfile) {
// Strategy: Link by OAuth provider first, then by email
let user = await User.findOne({
$or: [
{ 'oauth.provider': profile.provider, 'oauth.providerId': profile.providerId },
{ email: profile.email }, // Link by email if not yet linked
],
});
if (!user) {
// Create new user
user = new User({
email: profile.email,
name: profile.name,
emailVerified: true, // Assume OAuth provider verified email
});
} else if (!user.oauth) {
// User exists by email, link the OAuth account
user.oauth = {
provider: profile.provider,
providerId: profile.providerId,
};
}
// Always update tokens
user.oauth.accessToken = profile.accessToken;
user.oauth.refreshToken = profile.refreshToken;
await user.save();
return user;
}@Transactional
public User findOrCreateUser(OAuthProfile profile) {
// Link by OAuth provider first, then by email
User user = userRepo
.findByProviderAndProviderId(profile.provider(), profile.providerId())
.or(() -> userRepo.findByEmail(profile.email()))
.orElse(null);
if (user == null) {
user = User.builder()
.email(profile.email())
.name(profile.name())
.emailVerified(true)
.build();
} else if (user.getProvider() == null) {
// User exists by email, link the OAuth account
user.setProvider(profile.provider());
user.setProviderId(profile.providerId());
}
// Always update tokens
user.setAccessToken(profile.accessToken());
user.setRefreshToken(profile.refreshToken());
return userRepo.save(user);
}def find_or_create_user(db: Session, profile: OAuthProfile) -> User:
# Link by OAuth provider first, then by email
user = (db.query(User)
.filter_by(provider=profile.provider, provider_id=profile.provider_id)
.first()
or db.query(User).filter_by(email=profile.email).first())
if user is None:
user = User(
email=profile.email,
name=profile.name,
email_verified=True,
)
db.add(user)
elif user.provider is None:
user.provider = profile.provider
user.provider_id = profile.provider_id
user.access_token = profile.access_token
user.refresh_token = profile.refresh_token
db.commit()
return userpublic async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
{
var user = await _db.Users.FirstOrDefaultAsync(u =>
(u.Provider == profile.Provider && u.ProviderId == profile.ProviderId)
|| u.Email == profile.Email);
if (user == null)
{
user = new User {
Email = profile.Email,
Name = profile.Name,
EmailVerified = true,
};
_db.Users.Add(user);
}
else if (user.Provider == null)
{
user.Provider = profile.Provider;
user.ProviderId = profile.ProviderId;
}
user.AccessToken = profile.AccessToken;
user.RefreshToken = profile.RefreshToken;
await _db.SaveChangesAsync();
return user;
}Strategy: Require Explicit Linking
For more control, show a linking page:
// Route: Display linking page
router.get('/auth/google/callback', (req, res) => {
const profile = req.user; // From Passport verify callback
// Check if user exists
const existingUser = await User.findOne({ email: profile.email });
if (existingUser && !existingUser.oauth) {
// Show linking page
const token = generateLinkingToken(profile);
return res.redirect(`/auth/link?token=${token}`);
}
// Otherwise, normal flow
});
// Linking endpoint
router.post('/auth/link', authenticateUser, async (req, res) => {
const linkingToken = req.body.token;
const profile = decodeLinkingToken(linkingToken);
req.user.oauth = {
provider: profile.provider,
providerId: profile.providerId,
accessToken: profile.accessToken,
};
await req.user.save();
res.json({ success: true });
});@GetMapping("/auth/google/callback")
public RedirectView googleCallback(Authentication auth,
HttpServletRequest req) {
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) auth;
String email = token.getPrincipal().getAttribute("email");
Optional<User> existingUser = userRepo.findByEmail(email);
if (existingUser.isPresent() && existingUser.get().getProvider() == null) {
String linkingToken = generateLinkingToken(token.getPrincipal());
return new RedirectView("/auth/link?token=" + linkingToken);
}
return new RedirectView("/dashboard");
}
@PostMapping("/auth/link")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> linkAccount(
@RequestBody LinkRequest body,
@AuthenticationPrincipal UserDetails currentUser) {
OAuthProfile profile = decodeLinkingToken(body.token());
User user = userRepo.findByEmail(currentUser.getUsername()).orElseThrow();
user.setProvider(profile.provider());
user.setProviderId(profile.providerId());
user.setAccessToken(profile.accessToken());
userRepo.save(user);
return ResponseEntity.ok(Map.of("success", true));
}@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.google.authorize_access_token(request)
profile = token.get("userinfo")
existing_user = db.query(User).filter_by(email=profile["email"]).first()
if existing_user and existing_user.provider is None:
linking_token = generate_linking_token(profile)
return RedirectResponse(f"/auth/link?token={linking_token}")
# Normal flow
user = find_or_create_user(db, provider="google", ...)
return create_session_response(user)
@router.post("/auth/link")
async def link_account(
body: LinkRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
profile = decode_linking_token(body.token)
current_user.provider = profile["provider"]
current_user.provider_id = profile["provider_id"]
current_user.access_token = profile["access_token"]
db.commit()
return {"success": True}[HttpGet("google/callback")]
public async Task<IActionResult> GoogleCallback()
{
var result = await HttpContext.AuthenticateAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
var email = result.Principal?.FindFirstValue(ClaimTypes.Email);
var existingUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (existingUser != null && existingUser.Provider == null)
{
var linkingToken = GenerateLinkingToken(result.Principal!);
return Redirect($"/auth/link?token={linkingToken}");
}
return Redirect("/dashboard");
}
[HttpPost("link")]
[Authorize]
public async Task<IActionResult> LinkAccount([FromBody] LinkRequest body)
{
var profile = DecodeLinkingToken(body.Token);
var user = await _db.Users.FirstAsync(u =>
u.Email == User.FindFirstValue(ClaimTypes.Email));
user.Provider = profile.Provider;
user.ProviderId = profile.ProviderId;
user.AccessToken = profile.AccessToken;
await _db.SaveChangesAsync();
return Ok(new { success = true });
}Enterprise SSO: SAML and OIDC
Enterprise customers often require SAML 2.0 or OpenID Connect for single sign-on (SSO).
Azure AD / Entra ID Setup
// src/config/passport-azure-ad.ts
import passport from 'passport';
import { BearerStrategy } from 'passport-azure-ad';
import jwt from 'jsonwebtoken';
passport.use(
'azure-ad',
new BearerStrategy(
{
clientID: process.env.AZURE_CLIENT_ID!,
audience: process.env.AZURE_CLIENT_ID!,
},
async (token: any, done) => {
try {
// Token is already validated by BearerStrategy
const user = await User.findOne({
'oauth.provider': 'azure-ad',
'oauth.providerId': token.oid,
});
if (!user) {
return done(null, false, { message: 'User not found' });
}
done(null, user, token);
} catch (error) {
done(error);
}
}
)
);// Spring Boot with Azure AD starter
// application.properties:
// spring.cloud.azure.active-directory.enabled=true
// spring.cloud.azure.active-directory.credential.client-id=${AZURE_CLIENT_ID}
// spring.cloud.azure.active-directory.credential.client-secret=${AZURE_CLIENT_SECRET}
// spring.cloud.azure.active-directory.profile.tenant-id=${AZURE_TENANT_ID}
@Configuration
@EnableWebSecurity
public class AzureSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.apply(AadWebApplicationHttpSecurityConfigurer.aadWebApplication())
.and()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}
@Service
public class AzureUserService {
@Autowired private UserRepository userRepo;
public User loadFromToken(OidcUser oidcUser) {
String oid = oidcUser.getClaim("oid");
return userRepo.findByProviderAndProviderId("azure-ad", oid)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer
azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
app_client_id=settings.AZURE_CLIENT_ID,
tenant_id=settings.AZURE_TENANT_ID,
scopes={f"api://{settings.AZURE_CLIENT_ID}/user_impersonation": "user_impersonation"},
)
@router.get("/protected")
async def protected_route(
token: dict = Security(azure_scheme),
db: Session = Depends(get_db),
):
oid = token.get("oid")
user = db.query(User).filter_by(provider="azure-ad", provider_id=oid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"user": user}// Program.cs — Azure AD OIDC
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
// appsettings.json:
// "AzureAd": {
// "Instance": "https://login.microsoftonline.com/",
// "TenantId": "YOUR_TENANT_ID",
// "ClientId": "YOUR_CLIENT_ID",
// "Audience": "YOUR_CLIENT_ID"
// }
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProtectedController : ControllerBase
{
private readonly AppDbContext _db;
public ProtectedController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get()
{
var oid = User.FindFirstValue("oid");
var user = await _db.Users.FirstOrDefaultAsync(u =>
u.Provider == "azure-ad" && u.ProviderId == oid);
if (user == null)
return NotFound(new { message = "User not found" });
return Ok(new { user });
}
}SAML 2.0 with Passport (Okta, Azure AD, others)
import { Strategy as SamlStrategy } from 'passport-saml';
passport.use(
'saml',
new SamlStrategy(
{
path: '/auth/saml/callback',
entryPoint: process.env.SAML_ENTRY_POINT!,
issuer: 'your-app-identifier',
cert: process.env.SAML_CERT!,
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
},
async (profile: any, done) => {
try {
let user = await User.findOne({
email: profile.email || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
});
if (!user) {
user = new User({
email: profile.email,
name: profile.displayName,
emailVerified: true,
oauth: {
provider: 'saml',
providerId: profile.nameID,
},
});
await user.save();
}
done(null, user);
} catch (error) {
done(error);
}
}
)
);
// Routes
app.get('/auth/saml', passport.authenticate('saml', { failureRedirect: '/login' }));
app.post(
'/auth/saml/callback',
passport.authenticate('saml', { failureRedirect: '/login' }),
(req, res) => {
const sessionToken = generateSessionToken(req.user);
res.cookie('sessionToken', sessionToken, { /* ... */ });
res.redirect('/dashboard');
}
);
// SAML metadata endpoint (for IdP configuration)
app.get('/auth/saml/metadata', (req, res) => {
res.type('application/xml');
res.send(new SamlStrategy(/* ... */).generateServiceProviderMetadata());
});// Spring Security SAML 2.0
// pom.xml: spring-security-saml2-service-provider
@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.saml2Login(saml2 -> saml2
.relyingPartyRegistrationRepository(relyingPartyRepo())
.successHandler((req, res, auth) -> res.sendRedirect("/dashboard")))
.saml2Logout(Customizer.withDefaults());
return http.build();
}
@Bean
public RelyingPartyRegistrationRepository relyingPartyRepo() {
RelyingPartyRegistration registration = RelyingPartyRegistration
.withRegistrationId("saml")
.entityId("your-app-identifier")
.assertionConsumerServiceLocation("/auth/saml/callback")
.assertingPartyDetails(party -> party
.entityId(System.getenv("SAML_IDP_ENTITY_ID"))
.singleSignOnServiceLocation(System.getenv("SAML_ENTRY_POINT"))
.verificationX509Credentials(c -> c.add(
Saml2X509Credential.verification(loadCert()))))
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
}
// SAML metadata endpoint — Spring generates automatically at:
// GET /saml2/service-provider-metadata/{registrationId}# Flask-SAML2 or python3-saml
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from fastapi import Request
def init_saml_auth(req: dict) -> OneLogin_Saml2_Auth:
return OneLogin_Saml2_Auth(req, custom_base_path="saml/")
@router.get("/auth/saml")
async def saml_login(request: Request):
saml_req = await prepare_fastapi_request(request)
auth = init_saml_auth(saml_req)
return RedirectResponse(auth.login())
@router.post("/auth/saml/callback")
async def saml_callback(request: Request, db: Session = Depends(get_db)):
saml_req = await prepare_fastapi_request(request)
auth = init_saml_auth(saml_req)
auth.process_response()
if not auth.is_authenticated():
raise HTTPException(status_code=401, detail="SAML authentication failed")
attrs = auth.get_attributes()
email = attrs.get("email", [None])[0] or auth.get_nameid()
name = attrs.get("displayName", [None])[0]
user = db.query(User).filter_by(email=email).first()
if not user:
user = User(email=email, name=name, email_verified=True,
provider="saml", provider_id=auth.get_nameid())
db.add(user)
db.commit()
return create_session_response(user)
@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
saml_req = await prepare_fastapi_request(request)
auth = init_saml_auth(saml_req)
metadata = auth.get_settings().get_sp_metadata()
return Response(content=metadata, media_type="application/xml")// Program.cs — SAML 2.0 with Sustainsys.Saml2
builder.Services.AddAuthentication()
.AddSaml2(options => {
options.SPOptions.EntityId = new EntityId("your-app-identifier");
options.IdentityProviders.Add(new IdentityProvider(
new EntityId(Environment.GetEnvironmentVariable("SAML_IDP_ENTITY_ID")),
options.SPOptions)
{
MetadataLocation = Environment.GetEnvironmentVariable("SAML_IDP_METADATA_URL"),
LoadMetadata = true,
});
});
[ApiController]
[Route("auth/saml")]
public class SamlController : ControllerBase
{
[HttpGet("")]
public IActionResult SamlLogin() =>
Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
Saml2Defaults.Scheme);
// Callback is handled automatically by Sustainsys.Saml2 middleware
// at POST /Saml2/Acs
[HttpGet("metadata")]
public IActionResult Metadata()
{
// Sustainsys exposes metadata at GET /Saml2
return Redirect("/Saml2");
}
}Security: Critical Implementation Details
1. State Parameter
Always use the state parameter to prevent CSRF attacks:
import crypto from 'crypto';
// Generate state before redirect
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
// Include in authorization URL
const authUrl = `https://provider.com/authorize?client_id=...&state=${state}`;
// Verify on callback
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: 'State mismatch' });
}import java.security.SecureRandom;
// Generate state before redirect
SecureRandom sr = new SecureRandom();
byte[] stateBytes = new byte[32];
sr.nextBytes(stateBytes);
String state = HexFormat.of().formatHex(stateBytes);
session.setAttribute("oauthState", state);
// Include in authorization URL
String authUrl = "https://provider.com/authorize?client_id=...&state=" + state;
// Verify on callback
String returnedState = request.getParameter("state");
String savedState = (String) session.getAttribute("oauthState");
if (!MessageDigest.isEqual(returnedState.getBytes(), savedState.getBytes())) {
return ResponseEntity.status(400).body(Map.of("error", "State mismatch"));
}import os
# Generate state before redirect
state = os.urandom(32).hex()
request.session["oauth_state"] = state
# Include in authorization URL
auth_url = f"https://provider.com/authorize?client_id=...&state={state}"
# Verify on callback
returned_state = request.query_params.get("state")
saved_state = request.session.get("oauth_state")
if not secrets.compare_digest(returned_state or "", saved_state or ""):
raise HTTPException(status_code=400, detail="State mismatch")using System.Security.Cryptography;
// Generate state before redirect
var stateBytes = RandomNumberGenerator.GetBytes(32);
var state = Convert.ToHexString(stateBytes).ToLower();
HttpContext.Session.SetString("OAuthState", state);
// Include in authorization URL
var authUrl = $"https://provider.com/authorize?client_id=...&state={state}";
// Verify on callback
var returnedState = Request.Query["state"].ToString();
var savedState = HttpContext.Session.GetString("OAuthState") ?? "";
if (!CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(returnedState),
System.Text.Encoding.UTF8.GetBytes(savedState)))
{
return BadRequest(new { error = "State mismatch" });
}2. PKCE for Mobile and SPAs
PKCE is mandatory for:
- Mobile apps
- Single Page Applications (SPAs)
- Native desktop apps
// Client-side: Generate and store code verifier
const codeVerifier = base64url(crypto.randomBytes(32));
sessionStorage.setItem('pkce_verifier', codeVerifier);
// Server-side: Verify code challenge
function verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean {
const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return hash === codeChallenge;
}// Client-side (Android/desktop): Generate and store code verifier
SecureRandom sr = new SecureRandom();
byte[] bytes = new byte[32];
sr.nextBytes(bytes);
String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// Store in secure prefs / session
// Server-side: Verify code challenge
public boolean verifyCodeChallenge(String codeVerifier, String codeChallenge) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
String computed = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
return MessageDigest.isEqual(computed.getBytes(), codeChallenge.getBytes());
}import secrets, hashlib, base64
# Client-side: Generate and store code verifier
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
# Store in session
# Server-side: Verify code challenge
def verify_code_challenge(code_verifier: str, code_challenge: str) -> bool:
digest = hashlib.sha256(code_verifier.encode()).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
return secrets.compare_digest(computed, code_challenge)// Client-side: Generate and store code verifier
var bytes = RandomNumberGenerator.GetBytes(32);
var codeVerifier = Base64UrlEncode(bytes);
// Store in session / secure storage
// Server-side: Verify code challenge
public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
var computed = Base64UrlEncode(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(codeChallenge));
}3. Redirect URI Validation
Always validate redirect URIs:
const ALLOWED_REDIRECTS = [
'https://yourapp.com/auth/callback',
'https://app.yourapp.com/auth/callback',
];
function validateRedirectUri(redirectUri: string): boolean {
return ALLOWED_REDIRECTS.includes(redirectUri);
}private static final Set<String> ALLOWED_REDIRECTS = Set.of(
"https://yourapp.com/auth/callback",
"https://app.yourapp.com/auth/callback"
);
public boolean validateRedirectUri(String redirectUri) {
return ALLOWED_REDIRECTS.contains(redirectUri);
}ALLOWED_REDIRECTS = {
"https://yourapp.com/auth/callback",
"https://app.yourapp.com/auth/callback",
}
def validate_redirect_uri(redirect_uri: str) -> bool:
return redirect_uri in ALLOWED_REDIRECTSprivate static readonly HashSet<string> AllowedRedirects = new()
{
"https://yourapp.com/auth/callback",
"https://app.yourapp.com/auth/callback",
};
public static bool ValidateRedirectUri(string redirectUri) =>
AllowedRedirects.Contains(redirectUri);Never use regex or wildcards. Be explicit.
4. Token Storage
DO:
- Store access tokens in memory (lost on page refresh, but secure)
- Store refresh tokens in httpOnly, secure cookies
- Store user data in Vuex/Redux, not localStorage
DON’T:
- Store access tokens in localStorage (vulnerable to XSS)
- Store tokens in cookies without httpOnly flag
- Log or transmit tokens in URLs or referer headers
// Secure token storage
export class AuthService {
private accessToken: string | null = null;
setAccessToken(token: string) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
// Refresh token in httpOnly cookie (handled by browser automatically)
}@Service
@RequestScope
public class AuthService {
// Store access token in request-scoped bean (memory only, never persisted to client)
private String accessToken;
public void setAccessToken(String token) {
this.accessToken = token;
}
public String getAccessToken() {
return accessToken;
}
// Refresh token is managed via HttpOnly cookie by the servlet container
}# Store access token in server-side session (Redis/DB backed), not client storage
class AuthService:
def __init__(self):
self._access_token: str | None = None # In-memory only
def set_access_token(self, token: str) -> None:
self._access_token = token
def get_access_token(self) -> str | None:
return self._access_token
# Refresh token is stored in HttpOnly cookie set by the server// Scoped service — token lives only for the duration of the HTTP request
public class AuthService : IAuthService
{
private string? _accessToken;
public void SetAccessToken(string token) => _accessToken = token;
public string? GetAccessToken() => _accessToken;
// Refresh token lives in an HttpOnly cookie — never exposed to JavaScript
}
// Program.cs
builder.Services.AddScoped<IAuthService, AuthService>();5. Token Validation
Always validate tokens server-side:
import jwt from 'jsonwebtoken';
export function validateToken(token: string): any {
try {
// Verify signature and expiration
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: process.env.TOKEN_ISSUER,
audience: process.env.TOKEN_AUDIENCE,
});
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}import io.jsonwebtoken.*;
public Claims validateToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(
System.getenv("JWT_SECRET").getBytes(StandardCharsets.UTF_8)))
.requireIssuer(System.getenv("TOKEN_ISSUER"))
.requireAudience(System.getenv("TOKEN_AUDIENCE"))
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new RuntimeException("Token expired");
} catch (JwtException e) {
throw new RuntimeException("Invalid token");
}
}import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
def validate_token(token: str) -> dict:
try:
return jwt.decode(
token,
settings.JWT_SECRET,
algorithms=["HS256"],
issuer=settings.TOKEN_ISSUER,
audience=settings.TOKEN_AUDIENCE,
)
except ExpiredSignatureError:
raise ValueError("Token expired")
except InvalidTokenError:
raise ValueError("Invalid token")using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
public ClaimsPrincipal ValidateToken(string token)
{
var handler = new JwtSecurityTokenHandler();
try
{
return handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ClockSkew = TimeSpan.Zero,
}, out _);
}
catch (SecurityTokenExpiredException)
{
throw new UnauthorizedAccessException("Token expired");
}
catch (SecurityTokenException)
{
throw new UnauthorizedAccessException("Invalid token");
}
}Common Pitfalls and Solutions
Pitfall 1: Storing Access Tokens in localStorage
Problem: Vulnerable to XSS attacks
Solution: Use httpOnly cookies for sensitive tokens, memory for short-lived access tokens
Pitfall 2: Not Implementing PKCE
Problem: Authorization code can be intercepted on mobile/SPA
Solution: Always use PKCE for public clients
Pitfall 3: Hardcoding Redirect URIs
Problem: Inflexible, difficult to manage environments
Solution: Use environment variables
const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback';
Pitfall 4: Mixing User Identity with Authorization Tokens
Problem: Difficult to separate user info from API access
Solution: Use ID tokens for identity, access tokens for APIs
Pitfall 5: Not Handling Token Expiration
Problem: Users get kicked out without refresh mechanism
Solution: Implement automatic token refresh
// Axios interceptor
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
const newToken = await refreshAccessToken();
// Retry request with new token
}
return Promise.reject(error);
}
);// Spring RestTemplate interceptor
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.getInterceptors().add((request, body, execution) -> {
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
String newToken = tokenService.refreshAccessToken(
getCurrentUser().getRefreshToken()).accessToken();
request.getHeaders().setBearerAuth(newToken);
response = execution.execute(request, body);
}
return response;
});
return restTemplate;
}# httpx event hook
async def refresh_on_401(response: httpx.Response) -> None:
if response.status_code == 401:
new_token = await refresh_access_token(current_user.refresh_token)
response.request.headers["Authorization"] = f"Bearer {new_token}"
await response.aread()
async_client = httpx.AsyncClient(event_hooks={"response": [refresh_on_401]})// DelegatingHandler for HttpClient
public class TokenRefreshHandler : DelegatingHandler
{
private readonly ITokenService _tokens;
public TokenRefreshHandler(ITokenService tokens) => _tokens = tokens;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var response = await base.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var newToken = await _tokens.RefreshAccessTokenAsync(
currentUser.RefreshToken!);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", newToken.AccessToken);
response = await base.SendAsync(request, ct);
}
return response;
}
}Pitfall 6: OAuth Scope Creep
Problem: Requesting unnecessary scopes damages user trust
Solution: Request minimal, specific scopes
// Good
scope: ['openid', 'profile', 'email']
// Bad
scope: ['*'] // Not standard, but principle applies
Production Checklist
Before deploying OAuth to production:
- Environment Variables: All credentials in
.env, never hardcoded - HTTPS Only: OAuth requires HTTPS (enforcement in Passport config)
- Secure Cookies:
httpOnly: true,secure: true,sameSite: 'lax' - PKCE: Enabled for all mobile and SPA clients
- State Parameter: Generated and verified for all flows
- Redirect URI Whitelist: Exact matches only, no wildcards
- Token Validation: Signature and expiration verified server-side
- Token Refresh: Automatic refresh implemented for long sessions
- Rate Limiting: Protect OAuth endpoints from brute force
- CORS Configuration: Restrict to allowed origins only
- Logging: Log auth events (without sensitive data) for debugging
- Error Handling: Don’t expose detailed error messages to users
- Session Timeout: Implement idle session timeout (e.g., 30 minutes)
- Account Linking: Handle existing user + new OAuth provider
- Revocation: Implement token revocation endpoint
- Testing: Test token expiration, refresh, revocation scenarios
- Documentation: Document supported providers and setup process
Monitoring and Debugging
Key Metrics to Monitor
// Log OAuth events
export function logAuthEvent(event: string, userId: string, provider: string, status: string) {
logger.info('auth_event', {
event,
userId,
provider,
status,
timestamp: new Date().toISOString(),
});
}
// Examples
logAuthEvent('login_start', undefined, 'google', 'initiated');
logAuthEvent('login_success', userId, 'google', 'completed');
logAuthEvent('token_refresh', userId, 'github', 'success');
logAuthEvent('token_refresh_failed', userId, 'github', 'failed');import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component
public class AuthEventLogger {
private static final Logger logger = LoggerFactory.getLogger(AuthEventLogger.class);
public void logAuthEvent(String event, String userId,
String provider, String status) {
logger.info("auth_event event={} userId={} provider={} status={} timestamp={}",
event, userId, provider, status,
Instant.now().toString());
}
}
// Examples
authEventLogger.logAuthEvent("login_start", null, "google", "initiated");
authEventLogger.logAuthEvent("login_success", userId, "google", "completed");
authEventLogger.logAuthEvent("token_refresh", userId, "github", "success");
authEventLogger.logAuthEvent("token_refresh_failed", userId, "github", "failed");import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
def log_auth_event(event: str, user_id: str | None,
provider: str, status: str) -> None:
logger.info("auth_event", extra={
"event": event,
"user_id": user_id,
"provider": provider,
"status": status,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
# Examples
log_auth_event("login_start", None, "google", "initiated")
log_auth_event("login_success", user_id, "google", "completed")
log_auth_event("token_refresh", user_id, "github", "success")
log_auth_event("token_refresh_failed", user_id, "github", "failed")public class AuthEventLogger
{
private readonly ILogger<AuthEventLogger> _logger;
public AuthEventLogger(ILogger<AuthEventLogger> logger) => _logger = logger;
public void LogAuthEvent(string eventName, string? userId,
string provider, string status)
{
_logger.LogInformation(
"auth_event {Event} userId={UserId} provider={Provider} " +
"status={Status} timestamp={Timestamp}",
eventName, userId, provider, status,
DateTimeOffset.UtcNow.ToString("o"));
}
}
// Examples
authLogger.LogAuthEvent("login_start", null, "google", "initiated");
authLogger.LogAuthEvent("login_success", userId, "google", "completed");
authLogger.LogAuthEvent("token_refresh", userId, "github", "success");
authLogger.LogAuthEvent("token_refresh_failed", userId, "github", "failed");Debugging Common Issues
Problem: “redirect_uri_mismatch”
- Check OAuth provider configuration matches your callback URL exactly
- Verify environment variable is set correctly
- Check URL encoding (spaces should be %20, not +)
Problem: “invalid_grant”
- Authorization code already used or expired
- Code verifier doesn’t match code challenge (PKCE)
- Invalid client credentials
Problem: User logged in but no profile data
- Check OAuth scope configuration
- Verify email is publicly visible (GitHub)
- Fallback to generic user creation if data missing
Conclusion
OAuth 2.0 implementation might seem complex, but breaking it down into components makes it manageable. The key is:
- Choose the right flow (Authorization Code with PKCE for most cases)
- Understand the distinction between OAuth (authorization) and OIDC (authentication)
- Implement security measures (state, PKCE, secure cookies, token validation)
- Handle edge cases (token expiration, account linking, enterprise SSO)
- Monitor and test thoroughly before production
With this guide and the production-ready code examples, you’re equipped to implement secure OAuth authentication in any application.