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.

Read time is about 13 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 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 →