TechLead
Lesson 3 of 22
6 min read
Cybersecurity

OAuth 2.0 and OpenID Connect

Understand OAuth 2.0 authorization flows, OpenID Connect for authentication, and how to implement secure third-party login

Understanding OAuth 2.0

OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on third-party services. It works by delegating user authentication to the service that hosts the account and authorizing third-party applications to access that account. OAuth 2.0 is not an authentication protocol — it is an authorization delegation protocol. The distinction matters: OAuth tells you what a user has allowed an app to do, not who the user is.

Before OAuth, if you wanted to let a third-party app access your email, you had to give it your actual email password. OAuth eliminates this dangerous pattern by introducing access tokens — limited, revocable credentials that grant specific permissions without exposing the user's password.

OAuth 2.0 Roles

  • Resource Owner: The user who authorizes an application to access their account. The resource owner's access is limited to the scope of the authorization granted.
  • Client: The application requesting access to the user's account. It must be authorized by the user and validated by the authorization server.
  • Authorization Server: Verifies the identity of the resource owner and issues access tokens. Examples include Google, GitHub, and Auth0.
  • Resource Server: Hosts the protected resources (APIs). It accepts and validates access tokens issued by the authorization server.

Authorization Code Flow (Recommended)

The Authorization Code flow is the most secure OAuth 2.0 flow for server-side applications. It involves exchanging an authorization code for an access token via a back-channel (server-to-server) request, so the access token is never exposed to the browser. For public clients (SPAs, mobile apps), this flow should be combined with PKCE (Proof Key for Code Exchange).

import crypto from "crypto";

// Step 1: Generate PKCE challenge
function generatePKCE() {
  // Code verifier: random 43-128 character string
  const codeVerifier = crypto.randomBytes(32).toString("base64url");

  // Code challenge: SHA-256 hash of the verifier
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");

  return { codeVerifier, codeChallenge };
}

// Step 2: Redirect user to authorization server
function getAuthorizationUrl(config: OAuthConfig) {
  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = crypto.randomBytes(16).toString("hex");

  // Store codeVerifier and state in session (server-side)
  session.oauthState = state;
  session.codeVerifier = codeVerifier;

  const params = new URLSearchParams({
    response_type: "code",
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: "openid profile email",
    state: state, // CSRF protection
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  return `${config.authorizationEndpoint}?${params.toString()}`;
}

// Step 3: Handle the callback and exchange code for tokens
async function handleOAuthCallback(req: Request) {
  const { code, state } = req.query;

  // Verify state matches (CSRF protection)
  if (state !== req.session.oauthState) {
    throw new SecurityError("Invalid state parameter - possible CSRF attack");
  }

  // Exchange authorization code for tokens
  const tokenResponse = await fetch(config.tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code as string,
      redirect_uri: config.redirectUri,
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code_verifier: req.session.codeVerifier, // PKCE proof
    }),
  });

  if (!tokenResponse.ok) {
    throw new Error("Token exchange failed");
  }

  const tokens = await tokenResponse.json();
  // tokens contains: access_token, id_token, refresh_token, expires_in

  // Clean up session
  delete req.session.oauthState;
  delete req.session.codeVerifier;

  return tokens;
}

OpenID Connect (OIDC)

OpenID Connect is an identity layer built on top of OAuth 2.0. While OAuth 2.0 only handles authorization (what can the app do?), OIDC adds authentication (who is the user?). OIDC introduces the concept of an ID Token — a JWT that contains claims about the authenticated user such as their name, email, and unique identifier.

When you use "Sign in with Google" or "Sign in with GitHub," you are using OpenID Connect. The key difference from plain OAuth is the openid scope and the ID Token that comes back in the response.

import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

// Verify an OpenID Connect ID Token
const client = jwksClient({
  jwksUri: "https://accounts.google.com/.well-known/jwks",
  cache: true,
  rateLimit: true,
});

function getSigningKey(header: jwt.JwtHeader): Promise<string> {
  return new Promise((resolve, reject) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) return reject(err);
      resolve(key!.getPublicKey());
    });
  });
}

async function verifyIdToken(idToken: string): Promise<OIDCClaims> {
  return new Promise((resolve, reject) => {
    jwt.verify(
      idToken,
      (header, callback) => {
        getSigningKey(header).then(
          (key) => callback(null, key),
          (err) => callback(err)
        );
      },
      {
        algorithms: ["RS256"],
        audience: process.env.GOOGLE_CLIENT_ID, // Must match your client ID
        issuer: "https://accounts.google.com", // Must match expected issuer
        clockTolerance: 5, // 5 seconds clock skew tolerance
      },
      (err, decoded) => {
        if (err) return reject(new Error(`ID token verification failed: ${err.message}`));
        resolve(decoded as OIDCClaims);
      }
    );
  });
}

interface OIDCClaims {
  iss: string; // Issuer
  sub: string; // Subject (unique user ID)
  aud: string; // Audience (your client ID)
  exp: number; // Expiration time
  iat: number; // Issued at
  email?: string;
  email_verified?: boolean;
  name?: string;
  picture?: string;
}

// Complete OIDC login flow
async function handleOIDCLogin(tokens: TokenResponse) {
  // Always verify the ID token
  const claims = await verifyIdToken(tokens.id_token);

  // Verify email is verified (important!)
  if (!claims.email_verified) {
    throw new Error("Email not verified by identity provider");
  }

  // Find or create user in your database
  let user = await db.query(
    "SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2",
    ["google", claims.sub]
  );

  if (!user.rows.length) {
    user = await db.query(
      "INSERT INTO users (email, name, oauth_provider, oauth_id, avatar_url) VALUES ($1, $2, $3, $4, $5) RETURNING *",
      [claims.email, claims.name, "google", claims.sub, claims.picture]
    );
  }

  return user.rows[0];
}

Security Warning: OAuth Pitfalls

  • Always validate the state parameter: Without state validation, your application is vulnerable to CSRF attacks that can lead to account hijacking.
  • Always use PKCE: Even for server-side apps, PKCE prevents authorization code interception attacks.
  • Always verify ID tokens: Verify the signature, issuer, audience, and expiration. Never trust an unverified token.
  • Never use the Implicit flow: The Implicit flow exposes tokens in the URL and has been deprecated. Use Authorization Code with PKCE instead.
  • Store tokens securely: Access tokens should be in memory or secure HttpOnly cookies. Never store them in localStorage.

Token Refresh Pattern

Access tokens should be short-lived (typically 15-60 minutes) to limit the damage if they are compromised. Refresh tokens are longer-lived and can be used to obtain new access tokens without requiring the user to re-authenticate. Refresh tokens must be stored securely and should be rotated on each use.

// Token refresh with rotation
async function refreshAccessToken(refreshToken: string) {
  const response = await fetch(config.tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: config.clientId,
      client_secret: config.clientSecret,
    }),
  });

  if (!response.ok) {
    // Refresh token may be expired or revoked
    throw new Error("Token refresh failed - re-authentication required");
  }

  const tokens = await response.json();

  // IMPORTANT: The response may include a new refresh token
  // Always use the latest refresh token (rotation)
  return {
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token || refreshToken,
    expiresIn: tokens.expires_in,
  };
}

// Automatic token refresh middleware
async function authenticatedFetch(url: string, options: RequestInit = {}) {
  let accessToken = getStoredAccessToken();

  // Check if token is about to expire (5 min buffer)
  if (isTokenExpiringSoon(accessToken, 300)) {
    const refreshToken = getStoredRefreshToken();
    const newTokens = await refreshAccessToken(refreshToken);
    accessToken = newTokens.accessToken;
    storeTokens(newTokens);
  }

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });
}

OAuth 2.0 Flow Comparison

Flow Use Case Security Level
Authorization Code + PKCEWeb apps, SPAs, mobileHigh (recommended)
Client CredentialsService-to-serviceHigh (no user context)
Device CodeTVs, CLI tools, IoTMedium
Implicit (deprecated)Legacy SPAsLow (avoid)

Continue Learning