Skip to content

ADR: Unified Auth Provider Abstraction

Status: Accepted Date: 2025-12-21 Deciders: Engineering Team Supersedes: 2025-10-23-neon-stack-auth-monitor-integration

Context and Problem Statement

The current authentication architecture has several issues:

  1. Neon-Specific Dependency: The neon_auth.users_sync table is populated by Neon's "Neon Authorize" integration, which automatically syncs Stack Auth users. This feature does not exist on Cloud SQL and blocks GCP migration.

  2. Multiple Auth Providers: Different apps use different auth providers:

    • Monitor: Stack Auth (internal team dashboard)
    • Studio: Dynamic (Web3 wallet auth)
    • API: Needs to serve both
  3. Code Duplication: Each app implements its own auth validation logic.

  4. Direct Provider Coupling: Apps query auth providers directly instead of through a centralized service.

Decision Drivers

  • GCP Migration: Must eliminate dependency on neon_auth.users_sync
  • DRY Principle: Single auth implementation shared across apps
  • Provider Isolation: Apps shouldn't need to know about other providers
  • ENV-Driven Configuration: Consistent with existing env system architecture
  • Centralized Access: Auth should flow through emprops-api for sharing across services

Decision

Implement a unified auth module in @emp/core with provider abstraction, where each app configures which provider to use via environment variables.

Architecture

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Monitor   │     │   Studio    │     │   Worker    │
│ AUTH=stack  │     │ AUTH=dynamic│     │ AUTH=stack  │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       └───────────────────┼───────────────────┘

                    GET /api/users/:id


                    ┌─────────────┐
                    │ emprops-api │
                    │  @emp/core  │
                    │    /auth    │
                    └──────┬──────┘

              ┌────────────┴────────────┐
              │                         │
              ▼                         ▼
       ┌─────────────┐           ┌─────────────┐
       │ Stack Auth  │           │   Dynamic   │
       └─────────────┘           └─────────────┘

Implementation

1. Unified Auth Module (packages/core/src/auth/)

typescript
// types.ts
export interface AuthUser {
  id: string;
  email: string | null;
  displayName: string | null;
  provider: 'stack' | 'dynamic';
  raw: unknown;
}

export interface AuthConfig {
  provider: 'stack' | 'dynamic';
  // Stack Auth
  stackProjectId?: string;
  stackSecretKey?: string;
  // Dynamic
  dynamicEnvironmentId?: string;
  dynamicApiKey?: string;
}

// index.ts
export function createAuthService(config: AuthConfig): AuthService {
  switch (config.provider) {
    case 'stack':
      return new StackAuthService(config);
    case 'dynamic':
      return new DynamicAuthService(config);
  }
}

export interface AuthService {
  validateToken(token: string): Promise<AuthUser | null>;
  getUser(userId: string): Promise<AuthUser | null>;
}

2. Provider Implementations

typescript
// providers/stack.ts
export class StackAuthService implements AuthService {
  async validateToken(token: string): Promise<AuthUser | null> {
    // Validate JWT using Stack Auth SDK/API
    // Return normalized AuthUser
  }

  async getUser(userId: string): Promise<AuthUser | null> {
    // Fetch user from Stack Auth API
  }
}

// providers/dynamic.ts
export class DynamicAuthService implements AuthService {
  async validateToken(token: string): Promise<AuthUser | null> {
    // Validate using Dynamic API
  }

  async getUser(userId: string): Promise<AuthUser | null> {
    // Fetch user from Dynamic API
  }
}

3. Express Middleware Factory

typescript
// middleware.ts
export function createAuthMiddleware(authService: AuthService) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const user = await authService.validateToken(token);
    if (!user) {
      return res.status(401).json({ error: 'Invalid token' });
    }

    req.user = user; // Unified AuthUser regardless of provider
    next();
  };
}

4. Environment Configuration

env
# config/environments/components/auth.env

[default]
# Provider selection - determines which auth system to use
AUTH_PROVIDER=stack

# Stack Auth credentials
AUTH_STACK_PROJECT_ID=2dd79195-ac90-44a7-af26-cf83abb53623
AUTH_STACK_PUBLISHABLE_KEY=pck_522rb12qj8d7scykqb7h9ga7t1cn2fgap5av14tx8m6br
AUTH_STACK_SECRET_KEY=${SECRET_STACK_SECRET_SERVER_KEY}

# Dynamic credentials
AUTH_DYNAMIC_ENVIRONMENT_ID=${SECRET_DYNAMIC_ENVIRONMENT_ID}
AUTH_DYNAMIC_API_KEY=${SECRET_DYNAMIC_API_KEY}
AUTH_DYNAMIC_API_URL=https://app.dynamic.xyz

5. Per-App Configuration

typescript
// monitor.interface.ts
required: {
  "AUTH_PROVIDER": "'stack'",  // Hardcoded - Monitor always uses Stack
  "AUTH_STACK_PROJECT_ID": "AUTH_STACK_PROJECT_ID",
  "AUTH_STACK_SECRET_KEY": "AUTH_STACK_SECRET_KEY",
}

// emprops-studio.interface.ts
required: {
  "AUTH_PROVIDER": "'dynamic'",  // Hardcoded - Studio always uses Dynamic
  "AUTH_DYNAMIC_ENVIRONMENT_ID": "AUTH_DYNAMIC_ENVIRONMENT_ID",
  "AUTH_DYNAMIC_API_KEY": "AUTH_DYNAMIC_API_KEY",
}

API Endpoints (emprops-api)

EndpointDescription
GET /api/auth/meGet current user from token
GET /api/auth/users/:idGet user by ID
POST /api/auth/validateValidate token, return user

These endpoints replace direct neon_auth.users_sync queries.

Consequences

Positive

  • GCP Compatible: No dependency on Neon-specific features
  • DRY: Single auth implementation in @emp/core
  • Provider Isolation: Apps only see unified AuthUser interface
  • ENV-Driven: Consistent with existing configuration system
  • Testable: Easy to mock auth service in tests
  • Extensible: New providers can be added without changing app code

Negative

  • API Latency: User lookups go through auth provider API instead of local database
  • Provider Dependency: Still depends on external auth services (Stack, Dynamic)
  • Migration Effort: Apps must update to use new auth module

Mitigations

  • Caching: Add Redis caching for user lookups to reduce API calls
  • Graceful Degradation: Return cached user data if provider is temporarily unavailable

Migration Path

  1. Phase 1: Implement @emp/core/auth module with Stack and Dynamic providers
  2. Phase 2: Add /api/auth/* endpoints to emprops-api
  3. Phase 3: Update Monitor to use new auth (replace neon_auth.users_sync queries)
  4. Phase 4: Update Studio to use unified auth module
  5. Phase 5: Remove neon_auth schema dependency

Files Changed

New Files

  • packages/core/src/auth/index.ts
  • packages/core/src/auth/types.ts
  • packages/core/src/auth/providers/stack.ts
  • packages/core/src/auth/providers/dynamic.ts
  • packages/core/src/auth/middleware.ts

Modified Files

  • config/environments/components/auth.env - Add unified config
  • config/environments/services/monitor.interface.ts - Set AUTH_PROVIDER
  • config/environments/services/emprops-studio.interface.ts - Set AUTH_PROVIDER
  • apps/api/src/lightweight-api-server.ts - Add auth endpoints
  • apps/monitor/src/app/actions.ts - Replace neon_auth query

Released under the MIT License.