oauth-auth

OAuth2, OIDC, JWT validation, session management, PKCE flows, refresh token rotation, and auth middleware

OAuth and Authentication

Implement secure OAuth2 flows, JWT validation, session management, and authentication patterns.

OAuth2 Authorization Code Flow with PKCE

BAD: No PKCE, state stored in localStorage

// Client initiates OAuth without PKCE
const authUrl = `https://provider.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code`;
localStorage.setItem('oauth_state', Math.random().toString());
window.location.href = authUrl;

GOOD: PKCE with httpOnly cookie state

import crypto from 'crypto';

// Generate PKCE challenge
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
  return { verifier, challenge };
}

// Initiate OAuth with PKCE
export async function initiateOAuth(res: Response) {
  const { verifier, challenge } = generatePKCE();
  const state = crypto.randomBytes(32).toString('base64url');

  // Store verifier and state in httpOnly cookie
  res.setHeader('Set-Cookie', [
    `pkce_verifier=${verifier}; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
    `oauth_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=600`
  ]);

  const params = new URLSearchParams({
    client_id: process.env.OAUTH_CLIENT_ID!,
    redirect_uri: process.env.OAUTH_REDIRECT_URI!,
    response_type: 'code',
    scope: 'openid profile email',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256'
  });

  return `https://provider.com/oauth/authorize?${params}`;
}

Token Exchange and Validation

BAD: No validation, symmetric JWT with weak secret

// Exchange code without validating state
const response = await fetch('https://provider.com/oauth/token', {
  method: 'POST',
  body: JSON.stringify({ code, client_id: clientId, client_secret: secret })
});
const { access_token } = await response.json();

// Weak symmetric JWT
import jwt from 'jsonwebtoken';
const token = jwt.sign({ userId }, 'weak-secret', { expiresIn: '1h' });

GOOD: State validation, PKCE verification, asymmetric JWT

import { SignJWT, jwtVerify, importSPKI, importPKCS8 } from 'jose';

export async function handleOAuthCallback(
  code: string,
  state: string,
  cookies: Record<string, string>
) {
  // Validate state to prevent CSRF
  if (state !== cookies.oauth_state) {
    throw new Error('Invalid state parameter');
  }

  // Exchange code with PKCE verifier
  const response = await fetch('https://provider.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.OAUTH_REDIRECT_URI!,
      client_id: process.env.OAUTH_CLIENT_ID!,
      code_verifier: cookies.pkce_verifier
    })
  });

  if (!response.ok) {
    throw new Error('Token exchange failed');
  }

  const tokens = await response.json();
  return tokens;
}

// Create asymmetric JWT (RS256)
export async function createAccessToken(payload: Record<string, any>) {
  const privateKey = await importPKCS8(
    process.env.JWT_PRIVATE_KEY!,
    'RS256'
  );

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
    .setIssuedAt()
    .setIssuer(process.env.JWT_ISSUER!)
    .setAudience(process.env.JWT_AUDIENCE!)
    .setExpirationTime('15m')
    .sign(privateKey);
}

// Verify JWT with public key
export async function verifyAccessToken(token: string) {
  const publicKey = await importSPKI(
    process.env.JWT_PUBLIC_KEY!,
    'RS256'
  );

  const { payload } = await jwtVerify(token, publicKey, {
    issuer: process.env.JWT_ISSUER!,
    audience: process.env.JWT_AUDIENCE!
  });

  return payload;
}

Refresh Token Rotation with Reuse Detection

BAD: No rotation, long-lived refresh tokens

// Single refresh token, no rotation
const refreshToken = crypto.randomBytes(32).toString('hex');
await db.saveRefreshToken(userId, refreshToken, { expiresIn: '30d' });

GOOD: Rotation with family tracking and reuse detection

interface RefreshToken {
  tokenHash: string;
  userId: string;
  family: string;
  expiresAt: Date;
  revokedAt?: Date;
  replacedBy?: string;
}

export async function createRefreshToken(userId: string, family?: string) {
  const token = crypto.randomBytes(32).toString('base64url');
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const tokenFamily = family || crypto.randomBytes(16).toString('hex');

  await db.refreshTokens.create({
    tokenHash,
    userId,
    family: tokenFamily,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
  });

  return { token, family: tokenFamily };
}

export async function rotateRefreshToken(token: string) {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const existing = await db.refreshTokens.findOne({ tokenHash });

  if (!existing || existing.expiresAt < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }

  // Reuse detection: if token already replaced, revoke entire family
  if (existing.revokedAt || existing.replacedBy) {
    await db.refreshTokens.updateMany(
      { family: existing.family },
      { revokedAt: new Date() }
    );
    throw new Error('Token reuse detected, family revoked');
  }

  // Create new token in same family
  const { token: newToken, family } = await createRefreshToken(
    existing.userId,
    existing.family
  );

  // Mark old token as replaced
  await db.refreshTokens.updateOne(
    { tokenHash },
    {
      revokedAt: new Date(),
      replacedBy: crypto.createHash('sha256').update(newToken).digest('hex')
    }
  );

  return { accessToken: await createAccessToken({ userId: existing.userId }), refreshToken: newToken };
}

Session Management

BAD: Tokens in localStorage, no CSRF protection

// Client stores tokens in localStorage
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);

// No CSRF protection
fetch('/api/sensitive', {
  headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` }
});

GOOD: httpOnly cookies with CSRF tokens

import { serialize } from 'cookie';

export function setAuthCookies(
  res: Response,
  accessToken: string,
  refreshToken: string
) {
  const csrfToken = crypto.randomBytes(32).toString('base64url');

  res.setHeader('Set-Cookie', [
    serialize('access_token', accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 15 * 60, // 15 minutes
      path: '/'
    }),
    serialize('refresh_token', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60, // 7 days
      path: '/api/auth/refresh'
    }),
    serialize('csrf_token', csrfToken, {
      httpOnly: false, // Accessible to JS for request headers
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 15 * 60,
      path: '/'
    })
  ]);

  return csrfToken;
}

// Middleware validates CSRF token
export function validateCSRF(req: Request) {
  const cookieCSRF = req.cookies.csrf_token;
  const headerCSRF = req.headers.get('X-CSRF-Token');

  if (!cookieCSRF || cookieCSRF !== headerCSRF) {
    throw new Error('CSRF validation failed');
  }
}

NextAuth.js/Auth.js Pattern

import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { JWT } from 'next-auth/jwt';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          prompt: 'consent',
          access_type: 'offline',
          response_type: 'code'
        }
      }
    })
  ],
  session: {
    strategy: 'jwt',
    maxAge: 7 * 24 * 60 * 60 // 7 days
  },
  callbacks: {
    async jwt({ token, account, user }) {
      if (account && user) {
        return {
          ...token,
          accessToken: account.access_token,
          refreshToken: account.refresh_token,
          accessTokenExpires: account.expires_at! * 1000,
          userId: user.id
        };
      }

      // Return previous token if access token not expired
      if (Date.now() < (token.accessTokenExpires as number)) {
        return token;
      }

      // Refresh access token
      return await refreshAccessToken(token);
    },
    async session({ session, token }) {
      session.user.id = token.userId as string;
      session.accessToken = token.accessToken as string;
      return session;
    }
  }
});

async function refreshAccessToken(token: JWT) {
  try {
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: process.env.GOOGLE_CLIENT_ID!,
        client_secret: process.env.GOOGLE_CLIENT_SECRET!,
        grant_type: 'refresh_token',
        refresh_token: token.refreshToken as string
      })
    });

    const refreshed = await response.json();

    if (!response.ok) {
      throw refreshed;
    }

    return {
      ...token,
      accessToken: refreshed.access_token,
      accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
      refreshToken: refreshed.refresh_token ?? token.refreshToken
    };
  } catch (error) {
    return {
      ...token,
      error: 'RefreshAccessTokenError'
    };
  }
}

Password Hashing with bcrypt

BAD: Plain text or weak hashing

// Plain text password
await db.users.create({ email, password });

// Weak hash
import crypto from 'crypto';
const hash = crypto.createHash('md5').update(password).digest('hex');

GOOD: bcrypt with salt rounds

import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return await bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return await bcrypt.compare(password, hash);
}

// Usage in registration
export async function registerUser(email: string, password: string) {
  const existingUser = await db.users.findOne({ email });
  if (existingUser) {
    throw new Error('User already exists');
  }

  const passwordHash = await hashPassword(password);
  return await db.users.create({ email, passwordHash });
}

Authentication Middleware

import { NextRequest, NextResponse } from 'next/server';

export async function authMiddleware(req: NextRequest) {
  const token = req.cookies.get('access_token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  try {
    const payload = await verifyAccessToken(token);

    // Attach user to request headers for route handlers
    const requestHeaders = new Headers(req.headers);
    requestHeaders.set('X-User-Id', payload.userId as string);

    return NextResponse.next({
      request: { headers: requestHeaders }
    });
  } catch (error) {
    // Token invalid or expired, try refresh
    const refreshToken = req.cookies.get('refresh_token')?.value;

    if (!refreshToken) {
      return NextResponse.redirect(new URL('/login', req.url));
    }

    try {
      const { accessToken, refreshToken: newRefreshToken } =
        await rotateRefreshToken(refreshToken);

      const response = NextResponse.next();
      setAuthCookies(response, accessToken, newRefreshToken);

      return response;
    } catch (refreshError) {
      return NextResponse.redirect(new URL('/login', req.url));
    }
  }
}

// middleware.ts
export { authMiddleware as middleware };

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*']
};

Token Revocation

// Revoke all tokens for a user (on password change, logout all devices)
export async function revokeAllUserTokens(userId: string) {
  await db.refreshTokens.updateMany(
    { userId, revokedAt: null },
    { revokedAt: new Date() }
  );
}

// Revoke specific token
export async function revokeRefreshToken(token: string) {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  await db.refreshTokens.updateOne(
    { tokenHash },
    { revokedAt: new Date() }
  );
}

// Cleanup expired tokens (run daily)
export async function cleanupExpiredTokens() {
  const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days
  await db.refreshTokens.deleteMany({
    expiresAt: { $lt: cutoff }
  });
}

OIDC ID Token Validation

import { jwtVerify, createRemoteJWKSet } from 'jose';

export async function validateIDToken(
  idToken: string,
  clientId: string,
  issuer: string
) {
  const JWKS = createRemoteJWKSet(
    new URL(`${issuer}/.well-known/jwks.json`)
  );

  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer,
    audience: clientId,
    maxTokenAge: '10m'
  });

  // Validate nonce if using implicit flow
  if (payload.nonce) {
    // Compare with stored nonce from initial request
  }

  return {
    sub: payload.sub,
    email: payload.email as string,
    email_verified: payload.email_verified as boolean,
    name: payload.name as string
  };
}

Run workflow: detect auth pattern needed, choose flow (OAuth2 code + PKCE for third-party, session cookies for first-party), implement token storage with httpOnly cookies, add CSRF protection, implement refresh rotation with reuse detection, add middleware guards, validate all tokens with asymmetric keys.