@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +13 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +35 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +45 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +66 -2
- package/dist/core-config.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +17 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +14 -1
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +17 -0
- package/dist/modules/auth/account.routes.d.ts.map +1 -0
- package/dist/modules/auth/account.routes.js +47 -0
- package/dist/modules/auth/account.routes.js.map +1 -0
- package/dist/modules/auth/auth.routes.d.ts +13 -7
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +51 -3
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +44 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +129 -32
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts +51 -0
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -0
- package/dist/modules/auth/oidc-auth-provider.js +189 -0
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -0
- package/dist/modules/auth/password-hash.d.ts +12 -0
- package/dist/modules/auth/password-hash.d.ts.map +1 -0
- package/dist/modules/auth/password-hash.js +67 -0
- package/dist/modules/auth/password-hash.js.map +1 -0
- package/dist/modules/auth/rate-limit.d.ts +32 -0
- package/dist/modules/auth/rate-limit.d.ts.map +1 -0
- package/dist/modules/auth/rate-limit.js +49 -0
- package/dist/modules/auth/rate-limit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +6 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +12 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/migrations/0001_user_password_hash.sql +4 -0
- package/migrations/meta/_journal.json +20 -13
- package/package.json +2 -2
- package/src/core/create-core-server.ts +352 -338
- package/src/core/create-core-services.ts +542 -501
- package/src/core-config.ts +65 -2
- package/src/modules/admin/admin-access.service.ts +10 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
- package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
- package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
- package/src/modules/auth/account.routes.ts +59 -0
- package/src/modules/auth/auth.routes.ts +68 -10
- package/src/modules/auth/auth.service.ts +151 -32
- package/src/modules/auth/oidc-auth-provider.ts +229 -0
- package/src/modules/auth/password-hash.ts +80 -0
- package/src/modules/auth/rate-limit.ts +45 -0
- package/src/modules/database/core-schema.ts +437 -431
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +25 -1
- 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
|
|
72
|
+
throw new Error('Invalid credentials');
|
|
40
73
|
}
|
|
41
74
|
this.assertAllowedDomain(normalizedEmail);
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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('
|
|
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
|
+
}
|