@bevel-software/platform-core-backend 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +13 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts.map +1 -1
  5. package/dist/core/create-core-services.js +35 -5
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/core-config.d.ts +45 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +66 -2
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/admin/admin-access.service.d.ts +17 -1
  12. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  13. package/dist/modules/admin/admin-access.service.js +14 -1
  14. package/dist/modules/admin/admin-access.service.js.map +1 -1
  15. package/dist/modules/auth/account.routes.d.ts +17 -0
  16. package/dist/modules/auth/account.routes.d.ts.map +1 -0
  17. package/dist/modules/auth/account.routes.js +47 -0
  18. package/dist/modules/auth/account.routes.js.map +1 -0
  19. package/dist/modules/auth/auth.routes.d.ts +13 -7
  20. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  21. package/dist/modules/auth/auth.routes.js +51 -3
  22. package/dist/modules/auth/auth.routes.js.map +1 -1
  23. package/dist/modules/auth/auth.service.d.ts +44 -8
  24. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  25. package/dist/modules/auth/auth.service.js +129 -32
  26. package/dist/modules/auth/auth.service.js.map +1 -1
  27. package/dist/modules/auth/oidc-auth-provider.d.ts +51 -0
  28. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -0
  29. package/dist/modules/auth/oidc-auth-provider.js +189 -0
  30. package/dist/modules/auth/oidc-auth-provider.js.map +1 -0
  31. package/dist/modules/auth/password-hash.d.ts +12 -0
  32. package/dist/modules/auth/password-hash.d.ts.map +1 -0
  33. package/dist/modules/auth/password-hash.js +67 -0
  34. package/dist/modules/auth/password-hash.js.map +1 -0
  35. package/dist/modules/auth/rate-limit.d.ts +32 -0
  36. package/dist/modules/auth/rate-limit.d.ts.map +1 -0
  37. package/dist/modules/auth/rate-limit.js +49 -0
  38. package/dist/modules/auth/rate-limit.js.map +1 -0
  39. package/dist/modules/database/core-schema.d.ts +17 -0
  40. package/dist/modules/database/core-schema.d.ts.map +1 -1
  41. package/dist/modules/database/core-schema.js +6 -0
  42. package/dist/modules/database/core-schema.js.map +1 -1
  43. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  44. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.js +12 -0
  46. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  47. package/migrations/0001_user_password_hash.sql +4 -0
  48. package/migrations/meta/_journal.json +20 -13
  49. package/package.json +2 -2
  50. package/src/core/create-core-server.ts +352 -338
  51. package/src/core/create-core-services.ts +542 -501
  52. package/src/core-config.ts +65 -2
  53. package/src/modules/admin/admin-access.service.ts +10 -0
  54. package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
  55. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
  56. package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
  57. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
  58. package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
  59. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
  60. package/src/modules/auth/account.routes.ts +59 -0
  61. package/src/modules/auth/auth.routes.ts +68 -10
  62. package/src/modules/auth/auth.service.ts +151 -32
  63. package/src/modules/auth/oidc-auth-provider.ts +229 -0
  64. package/src/modules/auth/password-hash.ts +80 -0
  65. package/src/modules/auth/rate-limit.ts +45 -0
  66. package/src/modules/database/core-schema.ts +437 -431
  67. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +25 -1
  68. package/src/modules/tool-auth/internal-token.service.ts +13 -0
@@ -1,4 +1,3 @@
1
- import { timingSafeEqual } from 'node:crypto';
2
1
  import jwt from 'jsonwebtoken';
3
2
  import { eq } from 'drizzle-orm';
4
3
  import type { Database } from '../database/connection.js';
@@ -6,9 +5,28 @@ import type { CoreConfig } from '../../core-config.js';
6
5
  import { users } from '../database/schema.js';
7
6
  import type { AuthUser } from '@bevel-software/platform-shared';
8
7
  import { hashEmail } from '../../shared/hash-email.js';
8
+ import {
9
+ hashPassword,
10
+ verifyPassword,
11
+ timingSafeStringEqual,
12
+ MIN_PASSWORD_LENGTH,
13
+ } from './password-hash.js';
9
14
 
10
15
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
16
 
17
+ /**
18
+ * Decoy hash verified when the email is unknown or has no password set, so
19
+ * those paths cost the same scrypt work as a real wrong-password attempt —
20
+ * without it, response timing would reveal which emails have accounts.
21
+ * Lazily computed once; the compared password never matches it (verification
22
+ * result is discarded — those paths always refuse).
23
+ */
24
+ let decoyHashPromise: Promise<string> | null = null;
25
+ function decoyHash(): Promise<string> {
26
+ decoyHashPromise ??= hashPassword('bevel-decoy-password-never-matches');
27
+ return decoyHashPromise;
28
+ }
29
+
12
30
  function toAuthUser(user: {
13
31
  id: string;
14
32
  email: string;
@@ -29,6 +47,21 @@ export class AuthService {
29
47
  private readonly config: CoreConfig,
30
48
  ) {}
31
49
 
50
+ /**
51
+ * Password login. Two credential sources, in order:
52
+ *
53
+ * 1. The env bootstrap admin (`ADMIN_EMAIL` + `ADMIN_PASSWORD`), checked
54
+ * directly against the environment — never stored, so unsetting either
55
+ * variable disables it immediately. Lets a fresh deployment sign in
56
+ * before any account exists.
57
+ * 2. A per-user account: the `users` row's scrypt `password_hash`
58
+ * (created by an admin from Roles & Members, or set by the user on
59
+ * their Account page). Accounts that only ever signed in via SSO have
60
+ * no hash and are refused here.
61
+ *
62
+ * A generic "Invalid credentials" error for every failure mode — never
63
+ * reveal whether the email exists or has a password.
64
+ */
32
65
  async loginWithPassword(
33
66
  email: string,
34
67
  password: string,
@@ -36,34 +69,54 @@ export class AuthService {
36
69
  const normalizedEmail = (email ?? '').trim().toLowerCase();
37
70
 
38
71
  if (!EMAIL_REGEX.test(normalizedEmail)) {
39
- throw new Error('Invalid email');
72
+ throw new Error('Invalid credentials');
40
73
  }
41
74
  this.assertAllowedDomain(normalizedEmail);
42
- if (!this.checkPassword(password ?? '')) {
43
- throw new Error('Invalid password');
75
+
76
+ const provided = password ?? '';
77
+ const isEnvAdmin =
78
+ this.config.adminEmail.length > 0 &&
79
+ this.config.adminPassword.length > 0 &&
80
+ normalizedEmail === this.config.adminEmail &&
81
+ timingSafeStringEqual(provided, this.config.adminPassword);
82
+
83
+ if (isEnvAdmin) {
84
+ const defaultName = normalizedEmail.split('@')[0] || normalizedEmail;
85
+ const user = await this.upsertUserByEmail(normalizedEmail, defaultName);
86
+ return { token: this.signToken(user.id, user.email), user: toAuthUser(user) };
44
87
  }
45
88
 
46
- // Derive a default display name from the local-part of the email
47
- const defaultName = normalizedEmail.split('@')[0] || normalizedEmail;
48
- const user = await this.upsertUserByEmail(normalizedEmail, defaultName);
89
+ const [user] = await this.db
90
+ .select()
91
+ .from(users)
92
+ .where(eq(users.email, normalizedEmail))
93
+ .limit(1);
94
+ // Always run one scrypt verification — against the stored hash when there
95
+ // is one, against the decoy otherwise — so unknown emails and
96
+ // password-less (SSO-only) accounts take the same time as a wrong
97
+ // password and can't be enumerated via response timing.
98
+ const stored = user?.passwordHash ?? (await decoyHash());
99
+ const matches = await verifyPassword(provided, stored);
100
+ if (!user?.passwordHash || !matches) {
101
+ throw new Error('Invalid credentials');
102
+ }
49
103
  return { token: this.signToken(user.id, user.email), user: toAuthUser(user) };
50
104
  }
51
105
 
52
106
  /**
53
- * Establish a session for a user who authenticated via "Sign in with
54
- * Microsoft". Same upsert-by-email + JWT path as password login — the only
55
- * difference is identity came from a verified Microsoft id_token instead of
56
- * the shared password. Email is the idempotency key, so a user who first
57
- * used password login and later signs in with Microsoft (same email) keeps
58
- * the same account/id.
107
+ * Establish a session for a user whose identity an SSO provider (the
108
+ * generic OIDC plugin, or the enterprise "Sign in with Microsoft") already
109
+ * verified. Same upsert-by-email + JWT path as password login. Email is the
110
+ * idempotency key, so a user who first used password login and later signs
111
+ * in via SSO (same email) keeps the same account/id.
59
112
  */
60
- async loginWithMicrosoft(
113
+ async loginWithSso(
61
114
  email: string,
62
115
  name: string,
63
116
  ): Promise<{ token: string; user: AuthUser }> {
64
117
  const normalizedEmail = (email ?? '').trim().toLowerCase();
65
118
  if (!EMAIL_REGEX.test(normalizedEmail)) {
66
- throw new Error('Microsoft sign-in returned an invalid email');
119
+ throw new Error('Sign-in returned an invalid email');
67
120
  }
68
121
  this.assertAllowedDomain(normalizedEmail);
69
122
  const displayName = (name ?? '').trim() || normalizedEmail.split('@')[0] || normalizedEmail;
@@ -71,6 +124,89 @@ export class AuthService {
71
124
  return { token: this.signToken(user.id, user.email), user: toAuthUser(user) };
72
125
  }
73
126
 
127
+ /**
128
+ * Admin-driven account provisioning (Roles & Members → Accounts). Upserts
129
+ * the user by email and sets their password — re-provisioning an existing
130
+ * account (e.g. one that first arrived via SSO, or a reset for a locked-out
131
+ * user) is deliberate admin behavior, not an error.
132
+ */
133
+ async createAccount(
134
+ email: string,
135
+ name: string | undefined,
136
+ password: string,
137
+ ): Promise<AuthUser> {
138
+ const normalizedEmail = (email ?? '').trim().toLowerCase();
139
+ if (!EMAIL_REGEX.test(normalizedEmail)) {
140
+ throw new Error('Invalid email');
141
+ }
142
+ this.assertAllowedDomain(normalizedEmail);
143
+ this.assertPasswordPolicy(password);
144
+ const suppliedName = (name ?? '').trim();
145
+ const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
146
+ const passwordHash = await hashPassword(password);
147
+ // One atomic upsert. On conflict (re-provisioning an existing account) an
148
+ // EXPLICITLY supplied name is persisted too; a blank name keeps the
149
+ // existing display name rather than clobbering it with the local-part
150
+ // fallback. `returning()` yields the authoritative row either way.
151
+ const [row] = await this.db
152
+ .insert(users)
153
+ .values({ email: normalizedEmail, name: displayName, passwordHash })
154
+ .onConflictDoUpdate({
155
+ target: users.email,
156
+ set: suppliedName
157
+ ? { passwordHash, name: suppliedName, updatedAt: new Date() }
158
+ : { passwordHash, updatedAt: new Date() },
159
+ })
160
+ .returning();
161
+ return toAuthUser(row);
162
+ }
163
+
164
+ /**
165
+ * Self-service password change (the Account page). The current password is
166
+ * required whenever one is set; an SSO-only account (no hash yet) may set
167
+ * its first password without one. The env bootstrap-admin credential is not
168
+ * affected — it lives in the environment, not in this row.
169
+ */
170
+ async changePassword(
171
+ userId: string,
172
+ currentPassword: string | undefined,
173
+ newPassword: string,
174
+ ): Promise<void> {
175
+ this.assertPasswordPolicy(newPassword);
176
+ const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
177
+ if (!user) throw new Error('User not found');
178
+ if (user.passwordHash) {
179
+ if (!currentPassword || !(await verifyPassword(currentPassword, user.passwordHash))) {
180
+ throw new Error('Current password is incorrect');
181
+ }
182
+ }
183
+ const passwordHash = await hashPassword(newPassword);
184
+ await this.db
185
+ .update(users)
186
+ .set({ passwordHash, updatedAt: new Date() })
187
+ .where(eq(users.id, userId));
188
+ }
189
+
190
+ /** Accounts overview for the admin management screen (never exposes hashes). */
191
+ async listAccounts(): Promise<
192
+ Array<{ id: string; email: string; name: string; hasPassword: boolean; createdAt: Date }>
193
+ > {
194
+ const rows = await this.db.select().from(users).orderBy(users.email);
195
+ return rows.map((row) => ({
196
+ id: row.id,
197
+ email: row.email,
198
+ name: row.name,
199
+ hasPassword: row.passwordHash != null,
200
+ createdAt: row.createdAt,
201
+ }));
202
+ }
203
+
204
+ private assertPasswordPolicy(password: string): void {
205
+ if ((password ?? '').length < MIN_PASSWORD_LENGTH) {
206
+ throw new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
207
+ }
208
+ }
209
+
74
210
  private signToken(userId: string, email: string): string {
75
211
  return jwt.sign({ userId, email }, this.config.jwtSecret, { expiresIn: '7d' });
76
212
  }
@@ -179,21 +315,4 @@ export class AuthService {
179
315
  }
180
316
  }
181
317
 
182
- private checkPassword(provided: string): boolean {
183
- const expected = this.config.testPassword;
184
- // Reject if no password is configured on the server
185
- if (!expected) return false;
186
-
187
- const a = Buffer.from(provided, 'utf8');
188
- const b = Buffer.from(expected, 'utf8');
189
- // Pad both buffers to the same length so timingSafeEqual runs unconditionally —
190
- // returning early on length mismatch would leak the expected password's length via timing.
191
- const len = Math.max(a.length, b.length);
192
- const aPadded = Buffer.alloc(len);
193
- const bPadded = Buffer.alloc(len);
194
- a.copy(aPadded);
195
- b.copy(bPadded);
196
- const equal = timingSafeEqual(aPadded, bPadded);
197
- return equal && a.length === b.length;
198
- }
199
318
  }
@@ -0,0 +1,229 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import type express from 'express';
3
+ import type { AuthProviderPlugin } from './auth.routes.js';
4
+ import { AUTH_COOKIE_MAX_AGE_S } from './auth.routes.js';
5
+ import { AUTH_COOKIE_NAME } from './auth.middleware.js';
6
+ import type { AuthService } from './auth.service.js';
7
+
8
+ // Short-lived CSRF state + PKCE verifier for the OAuth round-trip: set before
9
+ // redirecting to the provider, verified/consumed on callback.
10
+ const OAUTH_STATE_COOKIE = 'oidc_oauth_state';
11
+ const OAUTH_STATE_MAX_AGE_S = 10 * 60;
12
+
13
+ /** The subset of the issuer's discovery document this provider consumes. */
14
+ interface OidcDiscovery {
15
+ authorization_endpoint: string;
16
+ token_endpoint: string;
17
+ userinfo_endpoint: string;
18
+ }
19
+
20
+ export interface OidcAuthProviderOptions {
21
+ /** Issuer base URL; `<issuer>/.well-known/openid-configuration` must exist. */
22
+ issuerUrl: string;
23
+ clientId: string;
24
+ clientSecret: string;
25
+ /** Space-separated; must include `openid` (and `email` for most providers). */
26
+ scopes: string;
27
+ /** Login-button label shown by the login screen. */
28
+ label: string;
29
+ publicBackendUrl: string;
30
+ publicFrontendUrl: string;
31
+ /** See MicrosoftAuthDeps.cookieSecure — scheme-derived, not NODE_ENV. */
32
+ cookieSecure: boolean;
33
+ /** Injectable for tests; defaults to global fetch. */
34
+ fetchImpl?: typeof fetch;
35
+ }
36
+
37
+ /** Read one named cookie from the raw header (no cookie-parser dep, matching auth.middleware). */
38
+ function readCookie(req: express.Request, name: string): string | null {
39
+ const header = req.headers.cookie;
40
+ if (!header) return null;
41
+ for (const segment of header.split(';')) {
42
+ const trimmed = segment.trim();
43
+ if (!trimmed.startsWith(`${name}=`)) continue;
44
+ const value = trimmed.slice(name.length + 1);
45
+ if (!value) return null;
46
+ try {
47
+ return decodeURIComponent(value);
48
+ } catch {
49
+ return value;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Generic OIDC single sign-on as an {@link AuthProviderPlugin} — works with
57
+ * any spec-compliant provider (Entra, Okta, Auth0, Keycloak, Google, …),
58
+ * configured purely from the environment (see CoreConfig `oidc*`).
59
+ *
60
+ * Flow: authorization-code with PKCE (S256) as a confidential client.
61
+ * Identity claims come from the `userinfo` endpoint called with the freshly
62
+ * exchanged access token — the token arrived over TLS directly from the
63
+ * issuer's token endpoint, so no local id_token signature verification (and
64
+ * no JWKS handling) is needed to trust it.
65
+ *
66
+ * Register `<PUBLIC_BACKEND_URL>/api/auth/oidc/callback` as the redirect URI
67
+ * with the provider.
68
+ */
69
+ export class OidcAuthProvider implements AuthProviderPlugin {
70
+ readonly key = 'oidc';
71
+ readonly label: string;
72
+ readonly startPath = '/api/auth/oidc/login';
73
+
74
+ private readonly fetchImpl: typeof fetch;
75
+ private discovery: OidcDiscovery | null = null;
76
+
77
+ constructor(private readonly opts: OidcAuthProviderOptions) {
78
+ this.label = opts.label;
79
+ this.fetchImpl = opts.fetchImpl ?? fetch;
80
+ }
81
+
82
+ /**
83
+ * Fetch + cache the issuer's discovery document. Resolved lazily (not at
84
+ * boot) so a temporarily unreachable issuer delays the first login instead
85
+ * of failing the whole deployment; a failed attempt is not cached.
86
+ */
87
+ private async discover(): Promise<OidcDiscovery> {
88
+ if (this.discovery) return this.discovery;
89
+ const url = `${this.opts.issuerUrl}/.well-known/openid-configuration`;
90
+ const res = await this.fetchImpl(url);
91
+ if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${url}`);
92
+ const doc = (await res.json()) as Partial<OidcDiscovery>;
93
+ if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.userinfo_endpoint) {
94
+ throw new Error('OIDC discovery document is missing required endpoints');
95
+ }
96
+ this.discovery = {
97
+ authorization_endpoint: doc.authorization_endpoint,
98
+ token_endpoint: doc.token_endpoint,
99
+ userinfo_endpoint: doc.userinfo_endpoint,
100
+ };
101
+ return this.discovery;
102
+ }
103
+
104
+ private redirectUri(): string {
105
+ return `${this.opts.publicBackendUrl}/api/auth/oidc/callback`;
106
+ }
107
+
108
+ mountRoutes(router: express.Router, authService: AuthService): void {
109
+ const { publicFrontendUrl, cookieSecure } = this.opts;
110
+
111
+ // GET /api/auth/oidc/login — start the round-trip: mint state + PKCE
112
+ // verifier (both in one short-lived HttpOnly cookie), redirect to the
113
+ // provider's authorization endpoint.
114
+ router.get('/auth/oidc/login', async (_req, res) => {
115
+ try {
116
+ const discovery = await this.discover();
117
+ const state = randomBytes(16).toString('hex');
118
+ const verifier = randomBytes(32).toString('base64url');
119
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
120
+ res.cookie(OAUTH_STATE_COOKIE, `${state}.${verifier}`, {
121
+ httpOnly: true,
122
+ sameSite: 'lax',
123
+ secure: cookieSecure,
124
+ maxAge: OAUTH_STATE_MAX_AGE_S * 1000,
125
+ path: '/',
126
+ });
127
+ const url = new URL(discovery.authorization_endpoint);
128
+ url.searchParams.set('response_type', 'code');
129
+ url.searchParams.set('client_id', this.opts.clientId);
130
+ url.searchParams.set('redirect_uri', this.redirectUri());
131
+ url.searchParams.set('scope', this.opts.scopes);
132
+ url.searchParams.set('state', state);
133
+ url.searchParams.set('code_challenge', challenge);
134
+ url.searchParams.set('code_challenge_method', 'S256');
135
+ res.redirect(url.toString());
136
+ } catch (error) {
137
+ console.error('OIDC start error:', error instanceof Error ? error.message : error);
138
+ res.redirect(`${publicFrontendUrl}/auth/oidc/callback#error=start`);
139
+ }
140
+ });
141
+
142
+ // GET /api/auth/oidc/callback — verify state, exchange the code (PKCE +
143
+ // client secret), read identity from userinfo, issue the app JWT, and hand
144
+ // it to the frontend in the URL fragment (kept out of query/logs/Referer).
145
+ router.get('/auth/oidc/callback', async (req, res) => {
146
+ const fail = (reason: string) =>
147
+ res.redirect(`${publicFrontendUrl}/auth/oidc/callback#error=${reason}`);
148
+ try {
149
+ const { code, state } = req.query as { code?: string; state?: string };
150
+ const cookie = readCookie(req, OAUTH_STATE_COOKIE);
151
+ res.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
152
+ const [expectedState, verifier] = cookie?.split('.') ?? [];
153
+ if (!code || !state || !expectedState || !verifier || state !== expectedState) {
154
+ fail('state');
155
+ return;
156
+ }
157
+
158
+ const discovery = await this.discover();
159
+ const basic = Buffer.from(
160
+ `${encodeURIComponent(this.opts.clientId)}:${encodeURIComponent(this.opts.clientSecret)}`,
161
+ 'utf8',
162
+ ).toString('base64');
163
+ const tokenRes = await this.fetchImpl(discovery.token_endpoint, {
164
+ method: 'POST',
165
+ headers: {
166
+ 'Content-Type': 'application/x-www-form-urlencoded',
167
+ Authorization: `Basic ${basic}`,
168
+ },
169
+ body: new URLSearchParams({
170
+ grant_type: 'authorization_code',
171
+ code,
172
+ redirect_uri: this.redirectUri(),
173
+ code_verifier: verifier,
174
+ }).toString(),
175
+ });
176
+ if (!tokenRes.ok) {
177
+ console.error('OIDC token exchange failed:', tokenRes.status, await tokenRes.text().catch(() => ''));
178
+ fail('auth');
179
+ return;
180
+ }
181
+ const tokens = (await tokenRes.json()) as { access_token?: string };
182
+ if (!tokens.access_token) {
183
+ fail('auth');
184
+ return;
185
+ }
186
+
187
+ const infoRes = await this.fetchImpl(discovery.userinfo_endpoint, {
188
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
189
+ });
190
+ if (!infoRes.ok) {
191
+ console.error('OIDC userinfo failed:', infoRes.status);
192
+ fail('auth');
193
+ return;
194
+ }
195
+ const claims = (await infoRes.json()) as {
196
+ email?: string;
197
+ name?: string;
198
+ preferred_username?: string;
199
+ given_name?: string;
200
+ family_name?: string;
201
+ };
202
+ if (!claims.email) {
203
+ // Most likely a missing `email` scope / claim mapping at the provider.
204
+ console.error('OIDC userinfo returned no email claim');
205
+ fail('auth');
206
+ return;
207
+ }
208
+ const name =
209
+ claims.name ??
210
+ [claims.given_name, claims.family_name].filter(Boolean).join(' ').trim() ??
211
+ claims.preferred_username ??
212
+ '';
213
+
214
+ const { token } = await authService.loginWithSso(claims.email, name);
215
+ res.cookie(AUTH_COOKIE_NAME, token, {
216
+ httpOnly: true,
217
+ sameSite: 'lax',
218
+ secure: cookieSecure,
219
+ maxAge: AUTH_COOKIE_MAX_AGE_S * 1000,
220
+ path: '/',
221
+ });
222
+ res.redirect(`${publicFrontendUrl}/auth/oidc/callback#token=${encodeURIComponent(token)}`);
223
+ } catch (error) {
224
+ console.error('OIDC callback error:', error instanceof Error ? error.message : error);
225
+ fail('auth');
226
+ }
227
+ });
228
+ }
229
+ }
@@ -0,0 +1,80 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * Password hashing for per-user accounts: scrypt via node:crypto — memory-hard
5
+ * (unlike sha/hmac), built in (no native bcrypt/argon dependency), async so a
6
+ * login burst doesn't block the event loop.
7
+ *
8
+ * Stored format: `scrypt:N=<cost>,r=<block>,p=<par>:<salt b64url>:<key b64url>`
9
+ * — parameters travel with the hash, so costs can be raised later while old
10
+ * hashes keep verifying (and get re-hashed on next successful login/change).
11
+ */
12
+
13
+ const SCRYPT_N = 16384;
14
+ const SCRYPT_R = 8;
15
+ const SCRYPT_P = 1;
16
+ const KEY_LEN = 32;
17
+ const SALT_LEN = 16;
18
+
19
+ /** Guard rail surfaced by the routes as a 400 — not a silent truncation. */
20
+ export const MIN_PASSWORD_LENGTH = 8;
21
+
22
+ function scryptAsync(
23
+ password: string,
24
+ salt: Buffer,
25
+ params: { N: number; r: number; p: number },
26
+ ): Promise<Buffer> {
27
+ return new Promise((resolve, reject) => {
28
+ scrypt(
29
+ password,
30
+ salt,
31
+ KEY_LEN,
32
+ // maxmem must accommodate 128*N*r bytes; default (32 MiB) is exactly at
33
+ // the boundary for N=16384,r=8 — give it headroom.
34
+ { N: params.N, r: params.r, p: params.p, maxmem: 128 * params.N * params.r * 2 },
35
+ (err, key) => (err ? reject(err) : resolve(key)),
36
+ );
37
+ });
38
+ }
39
+
40
+ export async function hashPassword(password: string): Promise<string> {
41
+ const salt = randomBytes(SALT_LEN);
42
+ const key = await scryptAsync(password, salt, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
43
+ return `scrypt:N=${SCRYPT_N},r=${SCRYPT_R},p=${SCRYPT_P}:${salt.toString('base64url')}:${key.toString('base64url')}`;
44
+ }
45
+
46
+ /** Constant-time verify of `password` against a stored {@link hashPassword} string. */
47
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
48
+ const match = /^scrypt:N=(\d+),r=(\d+),p=(\d+):([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)$/.exec(stored);
49
+ if (!match) return false;
50
+ const [, n, r, p, saltB64, keyB64] = match;
51
+ const salt = Buffer.from(saltB64, 'base64url');
52
+ const expected = Buffer.from(keyB64, 'base64url');
53
+ try {
54
+ const actual = await scryptAsync(password, salt, {
55
+ N: Number(n),
56
+ r: Number(r),
57
+ p: Number(p),
58
+ });
59
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
60
+ } catch {
61
+ // Absurd parameters (corrupted row / DoS-shaped input) — fail closed.
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Constant-time comparison of two plaintext secrets (the env bootstrap-admin
68
+ * check). Pads to a common length so a length mismatch doesn't return early
69
+ * and leak the expected secret's length via timing.
70
+ */
71
+ export function timingSafeStringEqual(provided: string, expected: string): boolean {
72
+ const a = Buffer.from(provided, 'utf8');
73
+ const b = Buffer.from(expected, 'utf8');
74
+ const len = Math.max(a.length, b.length, 1);
75
+ const aPadded = Buffer.alloc(len);
76
+ const bPadded = Buffer.alloc(len);
77
+ a.copy(aPadded);
78
+ b.copy(bPadded);
79
+ return timingSafeEqual(aPadded, bPadded) && a.length === b.length;
80
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tiny fixed-window in-memory rate limiter for the password endpoints —
3
+ * brute-force / scrypt-DoS protection, not traffic shaping. In-memory is the
4
+ * right scope for the single-instance deployment model (same assumption as
5
+ * the MCP session store); a multi-replica future moves this behind a shared
6
+ * store.
7
+ *
8
+ * Expired windows are pruned opportunistically on each hit so the map stays
9
+ * bounded by the number of DISTINCT active keys per window.
10
+ */
11
+ export class FixedWindowRateLimiter {
12
+ private readonly hits = new Map<string, { count: number; resetAt: number }>();
13
+
14
+ constructor(
15
+ /** Attempts allowed per window per key. */
16
+ private readonly max: number,
17
+ private readonly windowMs: number,
18
+ /** Injectable clock for tests. */
19
+ private readonly now: () => number = Date.now,
20
+ ) {}
21
+
22
+ /**
23
+ * Record one attempt for `key`; returns true when the attempt is ALLOWED
24
+ * (i.e. the key is within its budget for the current window).
25
+ */
26
+ consume(key: string): boolean {
27
+ const now = this.now();
28
+ for (const [k, entry] of this.hits) {
29
+ if (entry.resetAt <= now) this.hits.delete(k);
30
+ }
31
+ const entry = this.hits.get(key);
32
+ if (!entry || entry.resetAt <= now) {
33
+ this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
34
+ return true;
35
+ }
36
+ entry.count += 1;
37
+ return entry.count <= this.max;
38
+ }
39
+
40
+ /** Clear a key's window — called after a SUCCESSFUL login so legitimate
41
+ * users who fumbled a few attempts aren't locked out of their next visit. */
42
+ reset(key: string): void {
43
+ this.hits.delete(key);
44
+ }
45
+ }