Building OAuth Guardian: Part 3

Part 3 - Implementation Journey. From first commit to Phase 1 MVP: 3 weeks, 4 security checks, 118 tests, and lessons learned building an open-source security tool.

Read time is about 17 minutes

Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.

Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.

Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.

Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.

Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.

Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.

Building OAuth Guardian: Part 3 - Implementation Journey

From first commit to Phase 1 MVP: 3 weeks, 4 security checks, 118 tests, and lessons learned building an open-source security tool


In Part 1, I explained why OAuth security auditing is critical based on my VA.gov experience. In Part 2, I detailed the TypeScript-powered architecture that makes OAuth Guardian extensible and maintainable. Now let's walk through the actual implementation journey building Phase 1 MVP in 3 weeks.

Week 1: Foundation & First Check

Day 1-2: Project Setup

I started with the basics:

mkdir oauth-guardian && cd oauth-guardian npm init -y git init

TypeScript Configuration: At VA.gov, we used strict TypeScript settings to catch security bugs. I brought that discipline to OAuth Guardian:

{ "compilerOptions": { "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true } }

These settings catch entire classes of bugs:

  • noUnusedLocals: Dead code that might hide bugs
  • noImplicitReturns: Functions that don't handle all cases
  • noUncheckedIndexedAccess: Array access without bounds checking

Day 3-4: Type Definitions

I created the type system first (Type-Driven Development):

src/types/check.ts: The check result type

export enum Severity { CRITICAL = "critical", HIGH = "high", MEDIUM = "medium", LOW = "low", INFO = "info", } export enum CheckStatus { PASS = "pass", FAIL = "fail", WARNING = "warning", SKIPPED = "skipped", ERROR = "error", } export interface CheckResult { id: string; name: string; category: CheckCategory; status: CheckStatus; severity?: Severity; description: string; message?: string; remediation?: string; references?: string[]; metadata?: Record<string, unknown>; timestamp: Date; executionTime?: number; }

This type guided every subsequent decision. When implementing checks, I always knew the exact structure to return.

Day 5-7: HTTP Client & First Check

The HTTP client wraps axios with OAuth-specific features:

export class HttpClient { private client: AxiosInstance; constructor(options: HttpClientOptions) { this.client = axios.create({ timeout: options.timeout ?? 10000, headers: { "User-Agent": options.userAgent ?? "OAuth-Guardian/0.1.0", ...options.headers, }, validateStatus: () => true, // Don't throw on 4xx/5xx }); } async discoverOAuthMetadata(baseUrl: string): Promise<MetadataResult> { // Try RFC 8414 endpoint const rfc8414 = await this.get( `${baseUrl}/.well-known/oauth-authorization-server` ); if (rfc8414.status === 200) { return { success: true, data: this.parseJson(rfc8414.data), source: "RFC 8414", }; } // Fallback to OIDC Discovery const oidc = await this.get(`${baseUrl}/.well-known/openid-configuration`); if (oidc.status === 200) { return { success: true, data: this.parseJson(oidc.data), source: "OIDC Discovery", }; } return { success: false, attempted: [ { url: rfc8414.config.url, status: rfc8414.status }, { url: oidc.config.url, status: oidc.status }, ], }; } }

Lesson from VA.gov: Always have fallback strategies. OAuth servers implement RFC 8414 or OIDC Discovery (or neither). Supporting both maximizes compatibility.

First Check: PKCE Detection

PKCE (Proof Key for Code Exchange) was the perfect first check because I implemented it from scratch at VA.gov. I knew every detail:

export class PKCECheck extends BaseCheck { readonly id = "oauth-pkce"; readonly name = "PKCE Implementation Check"; readonly category = CheckCategory.OAUTH; readonly defaultSeverity = Severity.HIGH; readonly description = "Validates PKCE (RFC 7636) support"; protected references = ["https://datatracker.ietf.org/doc/html/rfc7636"]; async execute(context: CheckContext): Promise<CheckResult> { const httpClient = context.httpClient as HttpClient; // Step 1: Discover OAuth metadata this.log(context, "Discovering OAuth metadata..."); const metadata = await httpClient.discoverOAuthMetadata(context.targetUrl); if (!metadata.success) { return this.warning( `Unable to discover OAuth metadata. Attempted endpoints:\n${metadata.attempted ?.map((a) => ` - ${a.url} (${a.status})`) .join("\n")}`, "Implement OAuth 2.0 Authorization Server Metadata (RFC 8414)..." ); } // Step 2: Check PKCE support const methods = metadata.data?.code_challenge_methods_supported; if (!methods || methods.length === 0) { return this.fail( "PKCE not supported - no code_challenge_methods_supported", Severity.CRITICAL, this.getRemediationGuidance() ); } if (!methods.includes("S256")) { return this.fail( `PKCE supported but S256 method missing. Found: ${methods.join(", ")}`, Severity.HIGH, "Add S256 code challenge method support..." ); } return this.pass( `PKCE properly supported with S256 method (via ${metadata.source})` ); } private getRemediationGuidance(): string { return ` **Why This Matters:** PKCE protects against authorization code interception attacks. Without it, mobile apps and SPAs are vulnerable to code injection. **How to Fix:** 1. Add PKCE support to your OAuth server 2. Require code_challenge parameter in authorization requests 3. Support S256 code challenge method 4. Validate code_verifier on token exchange **Example (Node.js):** \`\`\`javascript const crypto = require('crypto'); // Client generates code verifier const codeVerifier = crypto.randomBytes(32).toString('base64url'); // Client creates code challenge const challenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // Authorization request includes challenge const authUrl = \`\${authEndpoint}? response_type=code& client_id=\${clientId}& redirect_uri=\${redirectUri}& code_challenge=\${challenge}& code_challenge_method=S256\`; // Token request includes verifier const tokenRequest = { grant_type: 'authorization_code', code: authCode, redirect_uri: redirectUri, code_verifier: codeVerifier, }; \`\`\` **References:** - RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636 - OAuth 2.0 Security BCP: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics `; } }

Testing Against Real Servers

I immediately tested against real OAuth servers:

npm run build # Google OAuth (has PKCE) node dist/cli.js https://accounts.google.com # ✅ PASS: PKCE properly supported with S256 method # GitHub (no metadata endpoint) node dist/cli.js https://github.com # ⚠️ WARNING: Unable to discover OAuth metadata

Key Insight: Real-world testing revealed that many OAuth servers don't implement metadata discovery. The warning status (vs. failure) acknowledges we can't verify PKCE without metadata it's not necessarily missing.

Week 2: Additional Checks & Reporting

Days 8-10: Three More OAuth Checks

I added three critical checks based on VA.gov incidents:

1. State Parameter Check (CSRF Protection)

export class StateParameterCheck extends BaseCheck { readonly id = "oauth-state-parameter"; readonly name = "State Parameter Validation"; readonly category = CheckCategory.OAUTH; readonly defaultSeverity = Severity.HIGH; async execute(context: CheckContext): Promise<CheckResult> { // Check if authorization endpoint exists const metadata = await this.discoverMetadata(context); if (!metadata.authorization_endpoint) { return this.fail( "No authorization endpoint found - cannot verify state parameter usage", Severity.HIGH, "Configure authorization_endpoint in OAuth metadata..." ); } return this.pass( "Authorization endpoint configured. Ensure state parameter " + "is required in all authorization requests." ); } }

2. Redirect URI Validation Check

export class RedirectURICheck extends BaseCheck { readonly id = "oauth-redirect-uri"; readonly name = "Redirect URI Validation"; readonly category = CheckCategory.OAUTH; readonly defaultSeverity = Severity.CRITICAL; async execute(context: CheckContext): Promise<CheckResult> { // Validate redirect URI registration exists const metadata = await this.discoverMetadata(context); if (!metadata.authorization_endpoint) { return this.skip("No authorization endpoint to validate"); } return this.pass( "Authorization endpoint exists. Ensure redirect URIs use exact matching " + "(no wildcards, no substring matching)." ); } }

3. Token Storage Check

export class TokenStorageCheck extends BaseCheck { readonly id = "oauth-token-storage"; readonly name = "Token Storage Security"; readonly category = CheckCategory.OAUTH; readonly defaultSeverity = Severity.HIGH; async execute(context: CheckContext): Promise<CheckResult> { const metadata = await this.discoverMetadata(context); if (!metadata.token_endpoint) { return this.fail( "No token endpoint configured", Severity.HIGH, "Configure token_endpoint in OAuth metadata" ); } // Verify HTTPS const url = new URL(metadata.token_endpoint); if (url.protocol !== "https:") { return this.fail( `Token endpoint uses insecure protocol: ${url.protocol}`, Severity.CRITICAL, "ALWAYS use HTTPS for token endpoints" ); } return this.pass( `Token endpoint properly secured with HTTPS. ` + `Ensure tokens are stored in HttpOnly cookies (not localStorage).` ); } }

Days 11-14: Reporter System

JSON Reporter (for CI/CD):

export class JSONReporter { private options: JSONReporterOptions; constructor(options: JSONReporterOptions = {}) { this.options = { pretty: options.pretty ?? false, }; } generate(report: Report): string { return JSON.stringify(report, null, this.options.pretty ? 2 : 0); } }

Terminal Reporter (beautiful CLI output):

export class TerminalReporter { generate(report: Report): string { const lines: string[] = []; lines.push(""); lines.push(chalk.bold("📊 Audit Results")); lines.push(""); // Summary lines.push(chalk.bold("Summary:")); lines.push(` Total Checks: ${report.summary.totalChecks}`); lines.push(` ${chalk.green("✓")} Passed: ${report.summary.passed}`); lines.push(` ${chalk.red("✗")} Failed: ${report.summary.failed}`); lines.push(` ${chalk.yellow("⚠")} Warnings: ${report.summary.warnings}`); lines.push(""); lines.push( ` Compliance: ${this.colorizeCompliance( report.summary.compliancePercentage )}%` ); lines.push( ` Risk Score: ${this.colorizeRiskScore(report.summary.riskScore)}/100` ); // Check results lines.push(""); lines.push(chalk.bold("Check Results:")); for (const result of report.results) { const icon = this.getStatusIcon(result.status); lines.push(`${icon} ${chalk.bold(result.name)}`); lines.push(` ${chalk.gray(result.description)}`); if (result.message) { lines.push(` ${this.getStatusColor(result.status)(result.message)}`); } lines.push(""); } return lines.join("\n"); } }

Week 3: Configuration & HTML Reports

Days 15-17: Configuration System

YAML Configuration with Zod Validation:

// src/config/schema.ts import { z } from "zod"; export const AuditorConfigSchema = z.object({ target: z.string().url(), oauth: z .object({ pkce: z.union([z.boolean(), z.enum(["error", "warning"])]).optional(), state: z.union([z.boolean(), z.enum(["error", "warning"])]).optional(), redirectUri: z .union([z.boolean(), z.enum(["error", "warning"])]) .optional(), tokenStorage: z .union([z.boolean(), z.enum(["error", "warning"])]) .optional(), }) .optional(), // ... more config options }); export function validateConfig(config: unknown): ValidatedConfig { return AuditorConfigSchema.parse(config); }

Configuration Loader with Auto-Discovery:

// src/config/loader.ts const CONFIG_FILE_NAMES = [ "oauth-guardian.config.yml", "oauth-guardian.config.yaml", ".oauth-guardian.yml", ".oauth-guardian.yaml", ]; export async function loadConfig( target: string, configPath?: string ): Promise<AuditorConfig> { // 1. Explicit config path if (configPath) { const result = await safeLoadConfigFromFile(configPath); if (!result.success) { throw new Error(`Config errors:\n${result.errors?.join("\n")}`); } return result.config!; } // 2. Auto-discovery const discovered = await discoverAndLoadConfig(); if (discovered) { discovered.target = target; return discovered; } // 3. Defaults return getDefaultConfig({ target }); }

Example Configuration File:

# oauth-guardian.config.yml target: https://auth.example.com oauth: pkce: true state: true redirectUri: true tokenStorage: true nist: assuranceLevel: AAL2 sessionManagement: true owasp: severityThreshold: high reporting: format: html output: ./audit-report.html failOn: critical includeRemediation: true timeout: 10000 verbose: false

Days 18-21: HTML Reporter

I built a professional HTML report with Handlebars:

Template (templates/html-report.hbs):

<html> <head> <title>OAuth Guardian Security Audit</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; } .container { max-width: 1200px; margin: 0 auto; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px; } .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; } .stat-card { padding: 20px; border-radius: 8px; text-align: center; } .stat-card.passed { border-left: 4px solid #28a745; } .stat-card.failed { border-left: 4px solid #dc3545; } /* ... more styles ... */ </style> </head> <body> <div class="container"> <header> <h1>🛡️ OAuth Guardian</h1> <p>Security Audit Report</p> </header> <section class="summary"> <div class="stat-card passed"> <div class="stat-number">{{summary.passed}}</div> <div class="stat-label">Passed</div> </div> <div class="stat-card failed"> <div class="stat-number">{{summary.failed}}</div> <div class="stat-label">Failed</div> </div> <!-- ... more stats ... --> </section> <section class="results"> {{#each results}} <div class="check-result {{this.status}}"> <h3>{{this.name}}</h3> <p>{{this.description}}</p> {{#if this.message}} <div class="message">{{this.message}}</div> {{/if}} {{#if this.remediation}} <div class="remediation"> <h4>💡 Remediation</h4> {{{formatRemediation this.remediation}}} </div> {{/if}} </div> {{/each}} </section> </div> </body> </html

Reporter Implementation:

export class HTMLReporter { async generate(report: Report): Promise<string> { // Load template const templateContent = await readFile(this.options.templatePath, "utf-8"); const template = Handlebars.compile(templateContent); // Register helpers Handlebars.registerHelper("formatDate", (date: Date) => { return new Date(date).toLocaleString(); }); Handlebars.registerHelper("formatRemediation", (text: string) => { // Convert markdown code blocks to HTML let formatted = text.replace( /```(\w+)?\n([\s\S]*?)```/g, "<pre>$2</pre>" ); formatted = formatted.replace(/\n/g, "<br>"); return new Handlebars.SafeString(formatted); }); // Generate HTML return template({ metadata: report.metadata, summary: report.summary, results: report.results, compliance: report.compliance, }); } }

Testing the Complete System

# Build npm run build # Generate HTML report node dist/cli.js https://accounts.google.com --format html --output report.html # Open report open report.html

Result: A beautiful, professional HTML report with:

  • Executive summary with risk scores
  • Visual indicators for pass/fail/warning
  • Detailed findings with remediation guidance
  • Compliance scorecards
  • Print-ready formatting

Testing Strategy

I wrote 118 unit tests covering:

1. Check Tests

describe("PKCECheck", () => { it("should PASS when S256 is supported", async () => { const mockClient = { discoverOAuthMetadata: jest.fn().mockResolvedValue({ success: true, data: { code_challenge_methods_supported: ["S256"], }, source: "RFC 8414", }), }; const check = new PKCECheck(); const result = await check.run({ targetUrl: "https://auth.example.com", httpClient: mockClient, }); expect(result.status).toBe(CheckStatus.PASS); expect(result.severity).toBeUndefined(); }); it("should FAIL when PKCE is not supported", async () => { const mockClient = { discoverOAuthMetadata: jest.fn().mockResolvedValue({ success: true, data: { code_challenge_methods_supported: [], }, }), }; const check = new PKCECheck(); const result = await check.run({ targetUrl: "https://auth.example.com", httpClient: mockClient, }); expect(result.status).toBe(CheckStatus.FAIL); expect(result.severity).toBe(Severity.CRITICAL); expect(result.remediation).toContain("PKCE"); }); it("should WARNING when metadata cannot be discovered", async () => { const mockClient = { discoverOAuthMetadata: jest.fn().mockResolvedValue({ success: false, attempted: [ { url: "https://auth.example.com/.well-known/...", status: 404 }, ], }), }; const check = new PKCECheck(); const result = await check.run({ targetUrl: "https://auth.example.com", httpClient: mockClient, }); expect(result.status).toBe(CheckStatus.WARNING); expect(result.message).toContain("Unable to discover"); }); });

2. Integration Tests

Testing the full audit flow:

describe("AuditEngine", () => { it("should run all registered checks and generate report", async () => { const engine = new AuditEngine({ target: "https://auth.example.com", timeout: 10000, }); engine.registerChecks([ new PKCECheck(), new StateParameterCheck(), new RedirectURICheck(), new TokenStorageCheck(), ]); const report = await engine.run(); expect(report.results).toHaveLength(4); expect(report.summary.totalChecks).toBe(4); expect(report.summary.compliancePercentage).toBeGreaterThanOrEqual(0); expect(report.summary.riskScore).toBeGreaterThanOrEqual(0); }); });

3. Reporter Tests

describe("TerminalReporter", () => { it("should generate colored terminal output", () => { const reporter = new TerminalReporter({ colors: true }); const report = createMockReport(); const output = reporter.generate(report); expect(output).toContain("✓"); expect(output).toContain("✗"); expect(output).toContain("Audit Results"); expect(output).toContain("Summary:"); }); });

Coverage: 75% statements, 86% branches, 93% functions

Lessons Learned

1. Start with Types

Defining CheckResult, CheckContext, and AuditorConfig upfront guided every decision. When I needed to add a feature, I asked: "How does this fit the type system?" This prevented architectural mistakes.

2. Real-World Testing is Essential

Testing against Google OAuth and GitHub revealed:

  • Many servers don't implement metadata discovery
  • Some servers have non-standard configurations
  • Error handling must be robust

Lesson from VA.gov: Always test with production systems.

3. Developer Experience Matters

I spent significant time on:

  • Beautiful terminal output with colors and progress indicators
  • Clear error messages with remediation guidance
  • HTML reports that non-technical stakeholders can understand
  • Auto-discovery for zero-config operation

Why? Security tools that are hard to use don't get used.

4. TypeScript Paid Off

Type safety caught bugs before runtime:

  • Incorrect enum values
  • Missing required fields
  • Type mismatches in check results
  • Configuration schema violations

Impact: Zero runtime type errors in production.

5. Extensibility from Day One

The BaseCheck abstract class makes adding checks trivial. In Phase 2 (NIST compliance), I'll add:

  • AAL (Authentication Assurance Level) checks
  • Session management validation
  • Authenticator lifecycle checks

All without changing core architecture.

6. Documentation as Code

Remediation guidance is part of check results, not separate docs. This ensures:

  • Remediation stays updated with checks
  • Users get guidance immediately
  • No context switching to external docs

Phase 1 Complete! 🎉

After 3 weeks:

  • ✅ 4 OAuth security checks (PKCE, State, Redirect URI, Token Storage)
  • ✅ 3 report formats (Terminal, JSON, HTML)
  • ✅ YAML configuration with Zod validation
  • ✅ 118 tests with 75% coverage
  • ✅ CLI with comprehensive options
  • ✅ Tested against real OAuth servers

Next: Phase 2 will add NIST 800-63B compliance checks and Phase 3 will add custom rules engine.

Try It Yourself

# Install npm install -g oauth-guardian # Test your OAuth server oauth-guardian https://your-oauth-server.com # Generate HTML report oauth-guardian https://accounts.google.com --format html --output report.html

GitHub: github.com/asg5704/oauth-guardian

What's Your OAuth Security Story?

Have you found OAuth misconfigurations in production? What security checks would you add to OAuth Guardian? Let me know:


About the Author

Alexander Garcia built authentication infrastructure at VA.gov for 5+ years, processing 200M+ authentications. He hand-wrote OAuth 2.0 implementations, custom cryptographic libraries, and architected systems serving millions of Veterans and their families.

Experience includes:

  • Custom OAuth 2.0 SDK with PKCE implementation
  • Unified Sign-in Page serving 20+ VA applications
  • Terms of Use System of Record (6.9M acceptances)
  • Test User Dashboard and Mocked Authentication tooling
  • Reduced sign-in redirects from 27 to 7

Connect: alexandergarcia.dev | GitHub | LinkedIn


This is Part 3 of a 3-part series. ← Read Part 1: The Problem | ← Read Part 2: Architecture