OAuth 2.0 and OIDC Authorization Testing Patterns

OAuth 2.0 and OIDC Authorization Testing Patterns

OAuth 2.0 and OpenID Connect (OIDC) are the foundation of modern authorization. Testing them is challenging because they involve multiple parties (client, authorization server, resource server), time-sensitive tokens, and complex flows. Bugs in OAuth implementations lead to security vulnerabilities that are hard to detect manually.

This guide covers testing OAuth 2.0 and OIDC flows: authorization code flow, token validation, refresh tokens, scope enforcement, and PKCE.

What to Test in OAuth/OIDC

Authorization flow: Does the authorization code flow work end-to-end?

Token validation: Are access tokens verified (signature, expiry, audience) before trusting them?

Scope enforcement: Does the resource server enforce scopes on each endpoint?

Refresh token behavior: Are refresh tokens single-use? Do they expire correctly?

PKCE validation: Is the code verifier validated against the code challenge?

State parameter: Is the state validated to prevent CSRF?

Token storage: Are tokens stored securely (httpOnly cookies, not localStorage)?

Unit Testing JWT Validation

// src/auth/token-validator.ts
import jwt from 'jsonwebtoken';

export interface TokenPayload {
  sub: string;
  email: string;
  scope: string;
  aud: string | string[];
  iss: string;
  exp: number;
  iat: number;
}

export class TokenValidator {
  constructor(
    private readonly jwksUri: string,
    private readonly expectedIssuer: string,
    private readonly expectedAudience: string
  ) {}

  async validate(token: string): Promise<TokenPayload> {
    // Decode header to get key ID
    const decoded = jwt.decode(token, { complete: true });
    if (!decoded) throw new Error('Invalid token: cannot decode');
    
    // Fetch public key from JWKS endpoint
    const publicKey = await this.fetchPublicKey(decoded.header.kid);
    
    // Verify signature, expiry, issuer, and audience
    const payload = jwt.verify(token, publicKey, {
      issuer: this.expectedIssuer,
      audience: this.expectedAudience,
      algorithms: ['RS256'],
    }) as TokenPayload;
    
    return payload;
  }

  hasScope(payload: TokenPayload, requiredScope: string): boolean {
    const scopes = payload.scope.split(' ');
    return scopes.includes(requiredScope);
  }

  private async fetchPublicKey(kid: string): Promise<string> {
    // ... JWKS fetch implementation
    throw new Error('Not implemented in example');
  }
}
// tests/auth/token-validator.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import jwt from 'jsonwebtoken';
import { TokenValidator } from '~/auth/token-validator';
import { generateRSAKeyPair } from '../helpers/crypto-helpers';

describe('TokenValidator', () => {
  let privateKey: string;
  let publicKey: string;
  let validator: TokenValidator;
  
  const ISSUER = 'https://auth.example.com';
  const AUDIENCE = 'https://api.example.com';

  beforeAll(async () => {
    const keys = await generateRSAKeyPair();
    privateKey = keys.privateKey;
    publicKey = keys.publicKey;
  });

  beforeEach(() => {
    validator = new TokenValidator('https://auth.example.com/.well-known/jwks.json', ISSUER, AUDIENCE);
    
    // Mock JWKS fetch to return our test public key
    vi.spyOn(validator as any, 'fetchPublicKey').mockResolvedValue(publicKey);
  });

  function createToken(claims: Partial<jwt.JwtPayload> = {}): string {
    return jwt.sign(
      {
        sub: 'user-123',
        email: 'user@example.com',
        scope: 'read write',
        aud: AUDIENCE,
        iss: ISSUER,
        ...claims,
      },
      privateKey,
      { algorithm: 'RS256', expiresIn: '1h' }
    );
  }

  it('validates a valid token', async () => {
    const token = createToken();
    const payload = await validator.validate(token);
    
    expect(payload.sub).toBe('user-123');
    expect(payload.email).toBe('user@example.com');
  });

  it('rejects expired token', async () => {
    const token = createToken({ exp: Math.floor(Date.now() / 1000) - 3600 });
    
    await expect(validator.validate(token)).rejects.toThrow('jwt expired');
  });

  it('rejects token with wrong issuer', async () => {
    const token = createToken({ iss: 'https://evil.com' });
    
    await expect(validator.validate(token)).rejects.toThrow('jwt issuer invalid');
  });

  it('rejects token with wrong audience', async () => {
    const token = createToken({ aud: 'https://other-api.example.com' });
    
    await expect(validator.validate(token)).rejects.toThrow('jwt audience invalid');
  });

  it('rejects token with invalid signature', async () => {
    const token = createToken();
    const [header, payload, signature] = token.split('.');
    const tampered = `${header}.${payload}.${signature}zzz`;
    
    await expect(validator.validate(tampered)).rejects.toThrow('invalid signature');
  });

  describe('hasScope', () => {
    it('returns true when scope is present', async () => {
      const token = createToken({ scope: 'read write delete' });
      const payload = await validator.validate(token);
      
      expect(validator.hasScope(payload, 'read')).toBe(true);
      expect(validator.hasScope(payload, 'write')).toBe(true);
    });

    it('returns false when scope is missing', async () => {
      const token = createToken({ scope: 'read' });
      const payload = await validator.validate(token);
      
      expect(validator.hasScope(payload, 'delete')).toBe(false);
    });
  });
});

Integration Testing Scope Enforcement

// tests/auth/scope-enforcement.test.ts
import request from 'supertest';
import app from '~/app';

describe('Scope enforcement on API endpoints', () => {
  const scopeTestCases = [
    { endpoint: 'GET /api/documents', requiredScope: 'documents:read' },
    { endpoint: 'POST /api/documents', requiredScope: 'documents:write' },
    { endpoint: 'DELETE /api/documents/1', requiredScope: 'documents:delete' },
    { endpoint: 'GET /api/admin/users', requiredScope: 'admin' },
  ];

  for (const { endpoint, requiredScope } of scopeTestCases) {
    const [method, path] = endpoint.split(' ');

    it(`${endpoint} requires ${requiredScope} scope`, async () => {
      const tokenWithScope = await generateToken({ scope: requiredScope });
      const tokenWithoutScope = await generateToken({ scope: 'other:scope' });
      
      // With correct scope
      const allowedRes = await request(app)
        [method.toLowerCase()](path)
        .set('Authorization', `Bearer ${tokenWithScope}`);
      
      expect(allowedRes.status).not.toBe(403);
      
      // Without required scope
      const deniedRes = await request(app)
        [method.toLowerCase()](path)
        .set('Authorization', `Bearer ${tokenWithoutScope}`);
      
      expect(deniedRes.status).toBe(403);
      expect(deniedRes.body.error).toContain('insufficient_scope');
    });
  }
});

Testing PKCE Implementation

PKCE prevents authorization code interception attacks. Test that code_verifier is validated:

// tests/auth/pkce.test.ts
import crypto from 'crypto';
import request from 'supertest';
import app from '~/app';

function generatePKCEPair() {
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
  
  return { codeVerifier, codeChallenge };
}

describe('PKCE validation', () => {
  it('exchanges code with valid code_verifier', async () => {
    const { codeVerifier, codeChallenge } = generatePKCEPair();
    
    // Step 1: Get authorization code with code_challenge
    const authRes = await request(app)
      .get('/oauth/authorize')
      .query({
        response_type: 'code',
        client_id: 'test-client',
        redirect_uri: 'http://localhost:3000/callback',
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
        state: 'test-state-123',
      });
    
    const { code } = extractCodeFromRedirect(authRes.headers.location);
    
    // Step 2: Exchange code with code_verifier
    const tokenRes = await request(app)
      .post('/oauth/token')
      .send({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'http://localhost:3000/callback',
        client_id: 'test-client',
        code_verifier: codeVerifier,
      });
    
    expect(tokenRes.status).toBe(200);
    expect(tokenRes.body.access_token).toBeDefined();
  });

  it('rejects code exchange with wrong code_verifier', async () => {
    const { codeChallenge } = generatePKCEPair();
    const wrongVerifier = 'wrong-verifier-that-does-not-match';
    
    const { code } = await getAuthCode(codeChallenge);
    
    const tokenRes = await request(app)
      .post('/oauth/token')
      .send({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'http://localhost:3000/callback',
        client_id: 'test-client',
        code_verifier: wrongVerifier,
      });
    
    expect(tokenRes.status).toBe(400);
    expect(tokenRes.body.error).toBe('invalid_grant');
  });

  it('rejects code exchange without code_verifier when PKCE was used', async () => {
    const { codeChallenge } = generatePKCEPair();
    const { code } = await getAuthCode(codeChallenge);
    
    const tokenRes = await request(app)
      .post('/oauth/token')
      .send({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'http://localhost:3000/callback',
        client_id: 'test-client',
        // No code_verifier
      });
    
    expect(tokenRes.status).toBe(400);
    expect(tokenRes.body.error).toBe('invalid_grant');
  });
});

Testing Refresh Token Behavior

describe('Refresh tokens', () => {
  it('issues new access token with valid refresh token', async () => {
    const { refreshToken } = await loginAndGetTokens();
    
    const res = await request(app)
      .post('/oauth/token')
      .send({ grant_type: 'refresh_token', refresh_token: refreshToken });
    
    expect(res.status).toBe(200);
    expect(res.body.access_token).toBeDefined();
  });

  it('invalidates used refresh token (rotation)', async () => {
    const { refreshToken } = await loginAndGetTokens();
    
    // First use — succeeds
    const firstRes = await request(app)
      .post('/oauth/token')
      .send({ grant_type: 'refresh_token', refresh_token: refreshToken });
    expect(firstRes.status).toBe(200);
    
    // Second use — old token should be invalid
    const secondRes = await request(app)
      .post('/oauth/token')
      .send({ grant_type: 'refresh_token', refresh_token: refreshToken });
    
    expect(secondRes.status).toBe(400);
    expect(secondRes.body.error).toBe('invalid_grant');
  });

  it('refresh token expires after configured TTL', async () => {
    const { refreshToken } = await loginAndGetTokens();
    
    // Advance time past refresh token TTL
    vi.setSystemTime(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
    
    const res = await request(app)
      .post('/oauth/token')
      .send({ grant_type: 'refresh_token', refresh_token: refreshToken });
    
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('invalid_grant');
  });
});

Summary

OAuth 2.0 and OIDC testing:

  • Unit test JWT validation — signature, expiry, issuer, audience
  • Test scope enforcement — each API endpoint refuses requests with insufficient scope
  • Test PKCE — code_verifier must match code_challenge; missing verifier is rejected
  • Test refresh token rotation — used tokens must be invalidated
  • Test state parameter — CSRF protection requires state validation on callback
  • Use synthetic key pairs — generate RSA keys in tests to avoid dependency on real IdP

OAuth bugs are security vulnerabilities, not just functionality bugs. Systematic automated testing is the only way to verify your implementation is correct across all edge cases.

Read more