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:
Neon-Specific Dependency: The
neon_auth.users_synctable 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.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
Code Duplication: Each app implements its own auth validation logic.
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/)
// 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
// 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
// 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
# 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.xyz5. Per-App Configuration
// 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)
| Endpoint | Description |
|---|---|
GET /api/auth/me | Get current user from token |
GET /api/auth/users/:id | Get user by ID |
POST /api/auth/validate | Validate 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
AuthUserinterface - 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
- Phase 1: Implement
@emp/core/authmodule with Stack and Dynamic providers - Phase 2: Add
/api/auth/*endpoints to emprops-api - Phase 3: Update Monitor to use new auth (replace
neon_auth.users_syncqueries) - Phase 4: Update Studio to use unified auth module
- Phase 5: Remove
neon_authschema dependency
Files Changed
New Files
packages/core/src/auth/index.tspackages/core/src/auth/types.tspackages/core/src/auth/providers/stack.tspackages/core/src/auth/providers/dynamic.tspackages/core/src/auth/middleware.ts
Modified Files
config/environments/components/auth.env- Add unified configconfig/environments/services/monitor.interface.ts- Set AUTH_PROVIDERconfig/environments/services/emprops-studio.interface.ts- Set AUTH_PROVIDERapps/api/src/lightweight-api-server.ts- Add auth endpointsapps/monitor/src/app/actions.ts- Replace neon_auth query
Related
- 2025-10-23-neon-stack-auth-monitor-integration - Previous approach (superseded)
- Stack Auth Documentation
- Dynamic Documentation
