Building OAuth Guardian: Part 2

Part 2: Architecture & Design. How TypeScript's type system enabled a security-first architecture that's both extensible and maintainable.

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 2 - Architecture & Design

How TypeScript's type system enabled a security-first architecture that's both extensible and maintainable


In Part 1, I explained why OAuth Guardian needed to exist: OAuth misconfigurations are everywhere, and I learned this firsthand building authentication for 200M+ users at VA.gov. Now let's dive into how I architected the tool to be both powerful and developer-friendly.

Design Principles

Before writing any code, I established core principles based on my VA.gov experience:

1. Security First, Always

Every architectural decision prioritizes security:

  • Type safety prevents runtime errors in security checks
  • Immutable data structures prevent state manipulation
  • No eval() or code execution from configuration
  • Strict input validation with Zod schemas
  • No external dependencies for core crypto operations

2. Extensibility Without Complexity

OAuth Guardian ships with built-in checks, but teams have unique requirements. The architecture enables:

  • Custom security checks extending BaseCheck
  • Plugin system for third-party checks
  • Configuration-driven check filtering
  • Severity customization per environment

3. Developer Experience Matters

I learned at VA.gov that security tools nobody uses don't improve security. OAuth Guardian prioritizes DX:

  • Beautiful terminal output with progress indicators
  • Clear error messages with remediation guidance
  • Multiple report formats (terminal, JSON, HTML)
  • Zero-config defaults that just work
  • Comprehensive TypeScript types for IDE autocomplete

4. CI/CD Native

Security checks must run automatically:

  • JSON output for programmatic consumption
  • Exit codes for pipeline control
  • Fast execution (< 10 seconds for most audits)
  • Configurable failure thresholds

The Core Architecture

OAuth Guardian uses a layered architecture that separates concerns:

┌─────────────────────────────────────────┐
│           CLI Interface                 │  (commander, chalk, ora)
│        src/cli.ts                       │
└───────────────┬─────────────────────────┘
                │
┌───────────────▼─────────────────────────┐
│      Configuration System               │  (js-yaml, zod)
│  src/config/loader.ts                   │
│  src/config/schema.ts                   │
│  src/config/defaults.ts                 │
└───────────────┬─────────────────────────┘
                │
┌───────────────▼─────────────────────────┐
│        Audit Engine                     │
│    src/auditor/engine.ts                │  ← Orchestrates everything
│    src/auditor/http-client.ts           │
└───────┬───────────────────────┬─────────┘
        │                       │
┌───────▼───────┐      ┌────────▼──────────┐
│  Check System │      │  Report System    │
│  src/checks/  │      │  src/reporters/   │
│   - oauth/    │      │   - json          │
│   - nist/     │      │   - html          │
│   - owasp/    │      │   - terminal      │
│   - custom/   │      │   - markdown      │
└───────────────┘      └───────────────────┘

Let's explore each layer.

Layer 1: Type System Foundation

Everything starts with TypeScript types in src/types/. This was crucial at VA.gov where type safety prevented security bugs.

Check Result Type

Every security check returns a structured result:

export interface CheckResult { id: string; // Unique check identifier name: string; // Human-readable name category: CheckCategory; // OAUTH | NIST | OWASP | CUSTOM status: CheckStatus; // PASS | FAIL | WARNING | SKIPPED | ERROR severity?: Severity; // CRITICAL | HIGH | MEDIUM | LOW | INFO description: string; // What this check validates message?: string; // Details about the finding remediation?: string; // How to fix it references?: string[]; // RFC specs, OWASP guides metadata?: Record<string, unknown>; // Additional context timestamp: Date; // When check ran executionTime?: number; // Performance tracking }

Why this structure?

  • Consistent reporting: Every check speaks the same language
  • Type-safe aggregation: The engine can process results without runtime errors
  • Clear separation: Status (what happened) vs Severity (how bad is it)
  • Actionable guidance: Remediation isn't optional it's first-class
  • Audit trail: Timestamps and execution time for compliance

Check Context Type

Checks need access to shared resources:

export interface CheckContext { targetUrl: string; // OAuth server being audited config?: Record<string, unknown>; // User configuration httpClient?: HttpClient; // Shared HTTP client logger?: { // Optional logging debug: (message: string, ...args: unknown[]) => void; info: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; }; }

This dependency injection pattern enables:

  • Testability: Mock httpClient in tests
  • Shared state: One HTTP client for all checks (connection pooling)
  • Consistent logging: All checks use the same logger
  • Configuration access: Checks can customize behavior

Configuration Type

OAuth Guardian is highly configurable, and Zod validates everything:

export interface AuditorConfig { target: string; // OAuth server URL oauth?: OAuthCheckConfig; // OAuth-specific settings nist?: NISTCheckConfig; // NIST compliance settings owasp?: OWASPCheckConfig; // OWASP settings checks?: CheckFilterConfig; // Which checks to run reporting?: ReportingConfig; // Output format timeout?: number; // HTTP timeout verbose?: boolean; // Detailed logging userAgent?: string; // Custom User-Agent headers?: Record<string, string>; // Additional headers pluginsDir?: string; // Custom check plugins }

Layer 2: The Check System

The check system is OAuth Guardian's heart. I designed it based on my VA.gov experience writing dozens of security checks.

BaseCheck Abstract Class

All checks extend this base:

export abstract class BaseCheck { // Check metadata (subclass defines these) abstract readonly id: string; abstract readonly name: string; abstract readonly category: CheckCategory; abstract readonly defaultSeverity: Severity; abstract readonly description: string; protected references: string[] = []; // Main execution method (subclass implements this) abstract execute(context: CheckContext): Promise<CheckResult>; // Public run method with error handling & timing async run(context: CheckContext): Promise<CheckResult> { const startTime = Date.now(); try { const result = await this.execute(context); return { ...result, executionTime: Date.now() - startTime, }; } catch (error) { return this.error( "Check execution failed", error instanceof Error ? error : undefined ); } } // Helper methods for creating results protected pass( message?: string, metadata?: Record<string, unknown> ): CheckResult { return { id: this.id, name: this.name, category: this.category, status: CheckStatus.PASS, description: this.description, message, references: this.references, metadata, timestamp: new Date(), }; } protected fail( message: string, severity?: Severity, remediation?: string, metadata?: Record<string, unknown> ): CheckResult { return { id: this.id, name: this.name, category: this.category, status: CheckStatus.FAIL, severity: severity ?? this.defaultSeverity, description: this.description, message, remediation, references: this.references, metadata, timestamp: new Date(), }; } // Similar helpers for warning(), skip(), error()... }

Why This Design?

Template Method Pattern: run() provides structure, execute() contains logic. This ensures:

  • Consistent error handling across all checks
  • Automatic timing for performance tracking
  • Type-safe result construction

Helper Methods: pass(), fail(), warning() make checks readable:

// Compare this: return { id: this.id, name: this.name, category: this.category, status: CheckStatus.FAIL, severity: Severity.CRITICAL, description: this.description, message: "PKCE not supported", remediation: "...", references: this.references, metadata: {}, timestamp: new Date(), }; // To this: return this.fail( "PKCE not supported", Severity.CRITICAL, "Implement PKCE (RFC 7636)..." );

Extensibility: Creating a new check is straightforward:

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) implementation"; protected references = ["https://datatracker.ietf.org/doc/html/rfc7636"]; async execute(context: CheckContext): Promise<CheckResult> { const httpClient = context.httpClient as HttpClient; // 1. Discover OAuth metadata const metadata = await httpClient.discoverOAuthMetadata(context.targetUrl); if (!metadata.success) { return this.warning( "Unable to discover OAuth metadata", "Implement OAuth 2.0 Authorization Server Metadata (RFC 8414)" ); } // 2. Check PKCE support const methods = metadata.data?.code_challenge_methods_supported; if (!methods || !methods.includes("S256")) { return this.fail( "PKCE not supported or S256 method missing", Severity.CRITICAL, "Enable PKCE with S256 code challenge method...\n\nExample:\n..." ); } return this.pass("PKCE properly supported with S256 method"); } }

Layer 3: The Audit Engine

The AuditEngine orchestrates check execution:

export class AuditEngine { private config: AuditorConfig; private httpClient: HttpClient; private checks: BaseCheck[] = []; private logger?: Logger; constructor(config: AuditorConfig) { this.config = config; this.httpClient = new HttpClient({ timeout: config.timeout, userAgent: config.userAgent, headers: config.headers, verbose: config.verbose, }); if (config.verbose) { this.setupLogger(); } } registerCheck(check: BaseCheck): void { this.checks.push(check); } registerChecks(checks: BaseCheck[]): void { this.checks.push(...checks); } async run(): Promise<Report> { const startTime = new Date(); // 1. Filter checks based on config const checksToRun = this.filterChecks(); // 2. Create shared context const context: CheckContext = { targetUrl: this.config.target, config: this.config as unknown as Record<string, unknown>, httpClient: this.httpClient, logger: this.logger, }; // 3. Run all checks const results: CheckResult[] = []; for (const check of checksToRun) { this.logger?.info(`Running check: ${check.name}`); const result = await check.run(context); results.push(result); } const endTime = new Date(); // 4. Generate comprehensive report return this.generateReport(results, startTime, endTime); } private generateReport( results: CheckResult[], startTime: Date, endTime: Date ): Report { return { metadata: { targetUrl: this.config.target, startTime, endTime, executionTime: endTime.getTime() - startTime.getTime(), version: "0.1.0", }, summary: this.generateSummary(results), results, findings: this.generateFindings(results), compliance: this.generateComplianceScorecard(results), }; } // Risk scoring, compliance calculation, etc... }

Key Design Decisions

Sequential Execution: Checks run sequentially (not parallel) to:

  • Avoid HTTP connection pool exhaustion
  • Provide predictable progress indicators
  • Simplify error handling

Shared HTTP Client: One client instance for all checks:

  • Connection reuse
  • Consistent timeout handling
  • Centralized request logging

Rich Report Generation: The engine calculates:

  • Pass/fail/warning counts
  • Risk score (weighted by severity)
  • Compliance percentage
  • Per-category scorecards

Layer 4: Configuration System

OAuth Guardian uses YAML configuration validated by Zod schemas.

Why YAML + Zod?

YAML: Human-friendly, comment-supported, widely adopted Zod: Runtime type validation, great error messages, TypeScript integration

import { z } from "zod"; const OAuthCheckConfigSchema = z .object({ pkce: z.union([z.boolean(), z.enum(["error", "warning"])]).optional(), state: z.union([z.boolean(), z.enum(["error", "warning"])]).optional(), // ... }) .optional(); export const AuditorConfigSchema = z.object({ target: z.string().url(), oauth: OAuthCheckConfigSchema, nist: NISTCheckConfigSchema, owasp: OWASPCheckConfigSchema, // ... }); export type ValidatedConfig = z.infer<typeof AuditorConfigSchema>;

Benefits:

  • Compile-time types for IDE autocomplete
  • Runtime validation catches config errors
  • Clear error messages: oauth.pkce: Expected boolean, received string

Configuration Auto-Discovery

OAuth Guardian searches for config files automatically:

const CONFIG_FILE_NAMES = [ "oauth-guardian.config.yml", "oauth-guardian.config.yaml", ".oauth-guardian.yml", ".oauth-guardian.yaml", ]; export async function discoverAndLoadConfig(): Promise<AuditorConfig | null> { for (const fileName of CONFIG_FILE_NAMES) { try { const filePath = resolve(process.cwd(), fileName); return await loadConfigFromFile(filePath); } catch { continue; } } return null; }

This enables zero-config operation while supporting advanced customization.

Layer 5: Report System

OAuth Guardian supports multiple output formats, all generated from the same Report type.

Terminal Reporter

Beautiful CLI output with colored formatting:

export class TerminalReporter { generate(report: Report): string { const lines: string[] = []; // Summary with colors lines.push(chalk.bold("📊 Audit Results")); lines.push(` ${chalk.green("✓")} Passed: ${report.summary.passed}`); lines.push(` ${chalk.red("✗")} Failed: ${report.summary.failed}`); // Check results with icons 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)}`); } return lines.join("\n"); } }

HTML Reporter

Professional reports with Handlebars templates:

export class HTMLReporter { async generate(report: Report): Promise<string> { const template = Handlebars.compile(htmlTemplate); return template({ metadata: report.metadata, summary: report.summary, results: report.results, compliance: report.compliance, }); } }

JSON Reporter

Machine-readable for CI/CD:

export class JSONReporter { generate(report: Report): string { return JSON.stringify(report, null, 2); } }

What's Next?

In Part 3, I'll walk through the implementation journey:

  • Building the first OAuth check (PKCE detection)
  • Implementing HTML reports with Handlebars
  • Adding configuration validation with Zod
  • Testing strategies for security tools
  • Lessons learned shipping an open-source project

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