| 77 | * ADMIN_USERS — comma-separated user IDs or emails that get admin role |
| 78 | */ |
| 79 | export class OAuthAdapter implements AuthAdapter { |
| 80 | private readonly store: SessionStore; |
| 81 | private readonly clientId: string; |
| 82 | private readonly clientSecret: string; |
| 83 | private readonly issuer: string; |
| 84 | private readonly callbackUrl: string; |
| 85 | private readonly scopes: string; |
| 86 | private readonly adminUsers: ReadonlySet<string>; |
| 87 | /** Cached OIDC discovery document. */ |
| 88 | private discovery: OIDCDiscovery | null = null; |
| 89 | /** In-flight state tokens to prevent CSRF. */ |
| 90 | private readonly pendingStates = new Map<string, number>(); |
| 91 | |
| 92 | constructor(store: SessionStore) { |
| 93 | this.store = store; |
| 94 | this.clientId = process.env.OAUTH_CLIENT_ID ?? ""; |
| 95 | this.clientSecret = process.env.OAUTH_CLIENT_SECRET ?? ""; |
| 96 | this.issuer = (process.env.OAUTH_ISSUER ?? "").replace(/\/$/, ""); |
| 97 | this.callbackUrl = |
| 98 | process.env.OAUTH_CALLBACK_URL ?? "http://localhost:3000/auth/callback"; |
| 99 | this.scopes = process.env.OAUTH_SCOPES ?? "openid email profile"; |
| 100 | this.adminUsers = new Set( |
| 101 | (process.env.ADMIN_USERS ?? "") |
| 102 | .split(",") |
| 103 | .map((s) => s.trim()) |
| 104 | .filter(Boolean), |
| 105 | ); |
| 106 | |
| 107 | // Periodically prune stale state tokens (>10 min old). |
| 108 | setInterval(() => { |
| 109 | const cutoff = Date.now() - 10 * 60_000; |
| 110 | for (const [s, t] of this.pendingStates) { |
| 111 | if (t < cutoff) this.pendingStates.delete(s); |
| 112 | } |
| 113 | }, 5 * 60_000).unref(); |
| 114 | } |
| 115 | |
| 116 | authenticate(req: IncomingMessage): AuthUser | null { |
| 117 | const session = this.store.getFromRequest(req); |
| 118 | if (!session) return null; |
| 119 | return { |
| 120 | id: session.userId, |
| 121 | email: session.email, |
| 122 | name: session.name, |
| 123 | isAdmin: |
| 124 | session.isAdmin || |
| 125 | this.adminUsers.has(session.userId) || |
| 126 | (session.email ? this.adminUsers.has(session.email) : false), |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | setupRoutes(app: Application): void { |
| 131 | // GET /auth/login — redirect to the identity provider |
| 132 | app.get("/auth/login", async (_req, res) => { |
| 133 | try { |
| 134 | const disc = await this.getDiscovery(); |
| 135 | const state = randomBytes(16).toString("hex"); |
| 136 | this.pendingStates.set(state, Date.now()); |
nothing calls this directly
no outgoing calls
no test coverage detected