@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import type { Server } from 'node:http';
|
|
4
|
+
import { OidcAuthProvider } from '../oidc-auth-provider.js';
|
|
5
|
+
import type { AuthService } from '../auth.service.js';
|
|
6
|
+
|
|
7
|
+
const ISSUER = 'https://idp.example.com';
|
|
8
|
+
const DISCOVERY = {
|
|
9
|
+
authorization_endpoint: `${ISSUER}/authorize`,
|
|
10
|
+
token_endpoint: `${ISSUER}/token`,
|
|
11
|
+
userinfo_endpoint: `${ISSUER}/userinfo`,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function makeProvider(fetchImpl: typeof fetch, authService: AuthService) {
|
|
15
|
+
const provider = new OidcAuthProvider({
|
|
16
|
+
issuerUrl: ISSUER,
|
|
17
|
+
clientId: 'client-1',
|
|
18
|
+
clientSecret: 'secret-1',
|
|
19
|
+
scopes: 'openid profile email',
|
|
20
|
+
label: 'Sign in with Example',
|
|
21
|
+
publicBackendUrl: 'http://localhost:3001',
|
|
22
|
+
publicFrontendUrl: 'http://localhost:5173',
|
|
23
|
+
cookieSecure: false,
|
|
24
|
+
fetchImpl,
|
|
25
|
+
});
|
|
26
|
+
const router = express.Router();
|
|
27
|
+
provider.mountRoutes(router, authService);
|
|
28
|
+
const app = express();
|
|
29
|
+
app.use('/api', router);
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** fetch stub that serves discovery, token and userinfo; records requests. */
|
|
34
|
+
function makeIdpFetch(overrides: { userinfo?: Record<string, unknown> } = {}) {
|
|
35
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
36
|
+
const impl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
37
|
+
const url = String(input);
|
|
38
|
+
calls.push({ url, init });
|
|
39
|
+
if (url.endsWith('/.well-known/openid-configuration')) {
|
|
40
|
+
return new Response(JSON.stringify(DISCOVERY), { status: 200 });
|
|
41
|
+
}
|
|
42
|
+
if (url === DISCOVERY.token_endpoint) {
|
|
43
|
+
return new Response(JSON.stringify({ access_token: 'at-123' }), { status: 200 });
|
|
44
|
+
}
|
|
45
|
+
if (url === DISCOVERY.userinfo_endpoint) {
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify(overrides.userinfo ?? { email: 'carol@example.com', name: 'Carol' }),
|
|
48
|
+
{ status: 200 },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return new Response('not found', { status: 404 });
|
|
52
|
+
}) as unknown as typeof fetch;
|
|
53
|
+
return { impl, calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let server: Server;
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
server?.close();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function listen(app: express.Express): Promise<string> {
|
|
62
|
+
await new Promise<void>((resolve) => {
|
|
63
|
+
server = app.listen(0, resolve);
|
|
64
|
+
});
|
|
65
|
+
const address = server.address();
|
|
66
|
+
if (typeof address === 'string' || !address) throw new Error('no port');
|
|
67
|
+
return `http://127.0.0.1:${address.port}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const authService = {
|
|
71
|
+
loginWithSso: vi.fn(async (email: string) => ({
|
|
72
|
+
token: 'jwt-for-' + email,
|
|
73
|
+
user: { id: 'u', email, name: 'Carol' },
|
|
74
|
+
})),
|
|
75
|
+
} as unknown as AuthService;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.mocked(authService.loginWithSso).mockClear();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('OidcAuthProvider', () => {
|
|
82
|
+
it('advertises key/label/startPath for the login screen', () => {
|
|
83
|
+
const provider = new OidcAuthProvider({
|
|
84
|
+
issuerUrl: ISSUER,
|
|
85
|
+
clientId: 'c',
|
|
86
|
+
clientSecret: 's',
|
|
87
|
+
scopes: 'openid',
|
|
88
|
+
label: 'Sign in with Example',
|
|
89
|
+
publicBackendUrl: 'http://localhost:3001',
|
|
90
|
+
publicFrontendUrl: 'http://localhost:5173',
|
|
91
|
+
cookieSecure: false,
|
|
92
|
+
});
|
|
93
|
+
expect(provider.key).toBe('oidc');
|
|
94
|
+
expect(provider.label).toBe('Sign in with Example');
|
|
95
|
+
expect(provider.startPath).toBe('/api/auth/oidc/login');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('login redirects to the authorization endpoint with PKCE + state, setting the state cookie', async () => {
|
|
99
|
+
const idp = makeIdpFetch();
|
|
100
|
+
const base = await listen(makeProvider(idp.impl, authService));
|
|
101
|
+
const res = await fetch(`${base}/api/auth/oidc/login`, { redirect: 'manual' });
|
|
102
|
+
expect(res.status).toBe(302);
|
|
103
|
+
const location = new URL(res.headers.get('location')!);
|
|
104
|
+
expect(location.origin + location.pathname).toBe(DISCOVERY.authorization_endpoint);
|
|
105
|
+
expect(location.searchParams.get('client_id')).toBe('client-1');
|
|
106
|
+
expect(location.searchParams.get('response_type')).toBe('code');
|
|
107
|
+
expect(location.searchParams.get('redirect_uri')).toBe(
|
|
108
|
+
'http://localhost:3001/api/auth/oidc/callback',
|
|
109
|
+
);
|
|
110
|
+
expect(location.searchParams.get('code_challenge_method')).toBe('S256');
|
|
111
|
+
expect(location.searchParams.get('state')).toMatch(/^[0-9a-f]{32}$/);
|
|
112
|
+
expect(res.headers.get('set-cookie')).toContain('oidc_oauth_state=');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('callback exchanges the code, reads userinfo, and redirects with the app JWT in the fragment', async () => {
|
|
116
|
+
const idp = makeIdpFetch();
|
|
117
|
+
const base = await listen(makeProvider(idp.impl, authService));
|
|
118
|
+
|
|
119
|
+
const start = await fetch(`${base}/api/auth/oidc/login`, { redirect: 'manual' });
|
|
120
|
+
const state = new URL(start.headers.get('location')!).searchParams.get('state')!;
|
|
121
|
+
const cookie = start.headers.get('set-cookie')!.split(';')[0];
|
|
122
|
+
|
|
123
|
+
const cb = await fetch(
|
|
124
|
+
`${base}/api/auth/oidc/callback?code=code-1&state=${state}`,
|
|
125
|
+
{ redirect: 'manual', headers: { cookie } },
|
|
126
|
+
);
|
|
127
|
+
expect(cb.status).toBe(302);
|
|
128
|
+
expect(cb.headers.get('location')).toBe(
|
|
129
|
+
'http://localhost:5173/auth/oidc/callback#token=' +
|
|
130
|
+
encodeURIComponent('jwt-for-carol@example.com'),
|
|
131
|
+
);
|
|
132
|
+
// Session cookie set alongside the fragment hand-off.
|
|
133
|
+
expect(cb.headers.get('set-cookie')).toContain('bevel_token=');
|
|
134
|
+
|
|
135
|
+
// Token exchange used PKCE (verifier from the cookie) + Basic client auth.
|
|
136
|
+
const tokenCall = idp.calls.find((c) => c.url === DISCOVERY.token_endpoint)!;
|
|
137
|
+
const body = String(tokenCall.init?.body);
|
|
138
|
+
expect(body).toContain('grant_type=authorization_code');
|
|
139
|
+
expect(body).toContain('code_verifier=');
|
|
140
|
+
expect((tokenCall.init?.headers as Record<string, string>).Authorization).toMatch(/^Basic /);
|
|
141
|
+
// userinfo was called with the freshly exchanged access token.
|
|
142
|
+
const infoCall = idp.calls.find((c) => c.url === DISCOVERY.userinfo_endpoint)!;
|
|
143
|
+
expect((infoCall.init?.headers as Record<string, string>).Authorization).toBe('Bearer at-123');
|
|
144
|
+
expect(authService.loginWithSso).toHaveBeenCalledWith('carol@example.com', 'Carol');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects a state mismatch without touching the token endpoint', async () => {
|
|
148
|
+
const idp = makeIdpFetch();
|
|
149
|
+
const base = await listen(makeProvider(idp.impl, authService));
|
|
150
|
+
const start = await fetch(`${base}/api/auth/oidc/login`, { redirect: 'manual' });
|
|
151
|
+
const cookie = start.headers.get('set-cookie')!.split(';')[0];
|
|
152
|
+
const cb = await fetch(`${base}/api/auth/oidc/callback?code=code-1&state=forged`, {
|
|
153
|
+
redirect: 'manual',
|
|
154
|
+
headers: { cookie },
|
|
155
|
+
});
|
|
156
|
+
expect(cb.headers.get('location')).toBe('http://localhost:5173/auth/oidc/callback#error=state');
|
|
157
|
+
expect(idp.calls.some((c) => c.url === DISCOVERY.token_endpoint)).toBe(false);
|
|
158
|
+
expect(authService.loginWithSso).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('fails with #error=auth when userinfo has no email claim', async () => {
|
|
162
|
+
const idp = makeIdpFetch({ userinfo: { name: 'No Email' } });
|
|
163
|
+
const base = await listen(makeProvider(idp.impl, authService));
|
|
164
|
+
const start = await fetch(`${base}/api/auth/oidc/login`, { redirect: 'manual' });
|
|
165
|
+
const state = new URL(start.headers.get('location')!).searchParams.get('state')!;
|
|
166
|
+
const cookie = start.headers.get('set-cookie')!.split(';')[0];
|
|
167
|
+
const cb = await fetch(`${base}/api/auth/oidc/callback?code=c&state=${state}`, {
|
|
168
|
+
redirect: 'manual',
|
|
169
|
+
headers: { cookie },
|
|
170
|
+
});
|
|
171
|
+
expect(cb.headers.get('location')).toBe('http://localhost:5173/auth/oidc/callback#error=auth');
|
|
172
|
+
expect(authService.loginWithSso).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
hashPassword,
|
|
4
|
+
verifyPassword,
|
|
5
|
+
timingSafeStringEqual,
|
|
6
|
+
MIN_PASSWORD_LENGTH,
|
|
7
|
+
} from '../password-hash.js';
|
|
8
|
+
|
|
9
|
+
describe('password-hash', () => {
|
|
10
|
+
it('round-trips a password', async () => {
|
|
11
|
+
const stored = await hashPassword('correct horse battery staple');
|
|
12
|
+
expect(stored.startsWith('scrypt:N=')).toBe(true);
|
|
13
|
+
expect(await verifyPassword('correct horse battery staple', stored)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects a wrong password', async () => {
|
|
17
|
+
const stored = await hashPassword('correct horse battery staple');
|
|
18
|
+
expect(await verifyPassword('incorrect horse', stored)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('salts: same password twice → different stored strings, both verify', async () => {
|
|
22
|
+
const a = await hashPassword('same-password');
|
|
23
|
+
const b = await hashPassword('same-password');
|
|
24
|
+
expect(a).not.toBe(b);
|
|
25
|
+
expect(await verifyPassword('same-password', a)).toBe(true);
|
|
26
|
+
expect(await verifyPassword('same-password', b)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('fails closed on malformed / foreign stored values', async () => {
|
|
30
|
+
expect(await verifyPassword('x', '')).toBe(false);
|
|
31
|
+
expect(await verifyPassword('x', 'bcrypt$whatever')).toBe(false);
|
|
32
|
+
expect(await verifyPassword('x', 'scrypt:N=abc,r=8,p=1:AA:BB')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('verifies params from the stored string (future cost bumps keep old hashes valid)', async () => {
|
|
36
|
+
// A hash produced at lower cost still verifies — parameters are read from
|
|
37
|
+
// the string, not assumed from current constants.
|
|
38
|
+
const stored = await hashPassword('pw');
|
|
39
|
+
const weaker = stored.replace(/N=\d+/, 'N=16384'); // same params today; string form is authoritative
|
|
40
|
+
expect(await verifyPassword('pw', weaker)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('timingSafeStringEqual matches only exact strings', () => {
|
|
44
|
+
expect(timingSafeStringEqual('secret', 'secret')).toBe(true);
|
|
45
|
+
expect(timingSafeStringEqual('secret', 'secret2')).toBe(false);
|
|
46
|
+
expect(timingSafeStringEqual('', '')).toBe(true);
|
|
47
|
+
expect(timingSafeStringEqual('a', '')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('exposes a sane minimum length for the routes to enforce', () => {
|
|
51
|
+
expect(MIN_PASSWORD_LENGTH).toBeGreaterThanOrEqual(8);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FixedWindowRateLimiter } from '../rate-limit.js';
|
|
3
|
+
|
|
4
|
+
describe('FixedWindowRateLimiter', () => {
|
|
5
|
+
it('allows up to max attempts per window, then refuses', () => {
|
|
6
|
+
let now = 0;
|
|
7
|
+
const limiter = new FixedWindowRateLimiter(3, 1000, () => now);
|
|
8
|
+
expect(limiter.consume('k')).toBe(true);
|
|
9
|
+
expect(limiter.consume('k')).toBe(true);
|
|
10
|
+
expect(limiter.consume('k')).toBe(true);
|
|
11
|
+
expect(limiter.consume('k')).toBe(false);
|
|
12
|
+
// A different key has its own budget.
|
|
13
|
+
expect(limiter.consume('other')).toBe(true);
|
|
14
|
+
// Window rollover restores the budget.
|
|
15
|
+
now = 1001;
|
|
16
|
+
expect(limiter.consume('k')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reset() clears a key immediately', () => {
|
|
20
|
+
const limiter = new FixedWindowRateLimiter(1, 60_000, () => 0);
|
|
21
|
+
expect(limiter.consume('k')).toBe(true);
|
|
22
|
+
expect(limiter.consume('k')).toBe(false);
|
|
23
|
+
limiter.reset('k');
|
|
24
|
+
expect(limiter.consume('k')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('prunes expired windows so the map stays bounded', () => {
|
|
28
|
+
let now = 0;
|
|
29
|
+
const limiter = new FixedWindowRateLimiter(1, 1000, () => now);
|
|
30
|
+
for (let i = 0; i < 100; i++) limiter.consume(`k${i}`);
|
|
31
|
+
now = 2000;
|
|
32
|
+
limiter.consume('fresh');
|
|
33
|
+
// Internal map pruned to just the fresh key.
|
|
34
|
+
expect((limiter as unknown as { hits: Map<string, unknown> }).hits.size).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import type { AuthService } from './auth.service.js';
|
|
3
|
+
import type { IAdminAccessService } from '../admin/admin.interface.js';
|
|
4
|
+
import './auth.middleware.js'; // Express Request augmentation
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Admin account management (Roles & Members → Accounts): list accounts and
|
|
8
|
+
* create/reset password accounts. Gated per request by
|
|
9
|
+
* {@link IAdminAccessService} — the Admin role in `roles.yaml`, plus the env
|
|
10
|
+
* bootstrap admin (always-admin, see AdminAccessService).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately namespaced `/admin/accounts` (not `/admin/users`): the
|
|
13
|
+
* enterprise overlay mounts its own richer user administration (listing with
|
|
14
|
+
* activity, GDPR erasure) at `/admin/users`, and the two must not shadow each
|
|
15
|
+
* other across mount phases.
|
|
16
|
+
*/
|
|
17
|
+
export function createAccountRoutes(
|
|
18
|
+
authService: AuthService,
|
|
19
|
+
adminAccess: IAdminAccessService,
|
|
20
|
+
): express.Router {
|
|
21
|
+
const router = express.Router();
|
|
22
|
+
|
|
23
|
+
const requireAdmin: express.RequestHandler = async (req, res, next) => {
|
|
24
|
+
if (!(await adminAccess.isAdmin(req.userEmail))) {
|
|
25
|
+
res.status(403).json({ error: 'Admins only' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
next();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// GET /api/admin/accounts — id, email, name, whether a password is set.
|
|
32
|
+
router.get('/admin/accounts', requireAdmin, async (_req, res) => {
|
|
33
|
+
res.json({ accounts: await authService.listAccounts() });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// POST /api/admin/accounts { email, name?, password } — create an account
|
|
37
|
+
// (or reset the password of an existing one; upsert-by-email is deliberate,
|
|
38
|
+
// see AuthService.createAccount).
|
|
39
|
+
router.post('/admin/accounts', requireAdmin, async (req, res) => {
|
|
40
|
+
const { email, name, password } = req.body as {
|
|
41
|
+
email?: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
password?: string;
|
|
44
|
+
};
|
|
45
|
+
if (!email || !password) {
|
|
46
|
+
res.status(400).json({ error: 'email and password are required' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const user = await authService.createAccount(email, name, password);
|
|
51
|
+
res.status(201).json(user);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
54
|
+
res.status(400).json({ error: msg });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return router;
|
|
59
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import type { AuthService } from './auth.service.js';
|
|
3
3
|
import { AUTH_COOKIE_NAME } from './auth.middleware.js'; // also imports Express Request augmentation
|
|
4
|
+
import { FixedWindowRateLimiter } from './rate-limit.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Max age of the JWT cookie (seconds). Matches the JWT's own `expiresIn:
|
|
@@ -11,16 +12,22 @@ import { AUTH_COOKIE_NAME } from './auth.middleware.js'; // also imports Express
|
|
|
11
12
|
export const AUTH_COOKIE_MAX_AGE_S = 7 * 24 * 60 * 60;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* A pluggable SSO login method. Core ships password login
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* A pluggable SSO login method. Core ships password login and (when
|
|
16
|
+
* configured) the generic OIDC provider; overlays may pass additional plugins
|
|
17
|
+
* (e.g. the enterprise "Sign in with Microsoft", owned by its sharepoint
|
|
18
|
+
* module). A plugin mounts its own routes under `/auth/<key>/…` and is
|
|
19
|
+
* advertised to the login screen by the capability probe, which renders one
|
|
20
|
+
* button per provider from `label` + `startPath`. A plugin is only present
|
|
21
|
+
* when its machinery is configured AND the instance hasn't switched it off —
|
|
22
|
+
* presence means enabled.
|
|
20
23
|
*/
|
|
21
24
|
export interface AuthProviderPlugin {
|
|
22
|
-
/** Probe key + route namespace (e.g. '
|
|
25
|
+
/** Probe key + route namespace (e.g. 'oidc' → /auth/oidc/…). */
|
|
23
26
|
readonly key: string;
|
|
27
|
+
/** Login-button label (e.g. "Sign in with Microsoft"). */
|
|
28
|
+
readonly label: string;
|
|
29
|
+
/** Browser navigation target that starts the flow (e.g. '/api/auth/oidc/login'). */
|
|
30
|
+
readonly startPath: string;
|
|
24
31
|
/** Mount the provider's routes (login redirect, callback) on the auth router. */
|
|
25
32
|
mountRoutes(router: express.Router, authService: AuthService): void;
|
|
26
33
|
}
|
|
@@ -33,7 +40,20 @@ export function createAuthRoutes(
|
|
|
33
40
|
): express.Router {
|
|
34
41
|
const router = express.Router();
|
|
35
42
|
|
|
36
|
-
//
|
|
43
|
+
// Brute-force guards on the password endpoints (scrypt also makes each
|
|
44
|
+
// attempt expensive server-side, so these double as DoS protection):
|
|
45
|
+
// - login: a tight per-(ip, email) budget plus a wider per-ip budget so a
|
|
46
|
+
// single address can't spray many emails; the pair key resets on a
|
|
47
|
+
// successful login.
|
|
48
|
+
// - change-password: per authenticated account (a stolen session must not
|
|
49
|
+
// get unlimited guesses at the current password).
|
|
50
|
+
const loginPairLimiter = new FixedWindowRateLimiter(10, 15 * 60_000);
|
|
51
|
+
const loginIpLimiter = new FixedWindowRateLimiter(50, 15 * 60_000);
|
|
52
|
+
const changePasswordLimiter = new FixedWindowRateLimiter(10, 15 * 60_000);
|
|
53
|
+
const clientIp = (req: express.Request) => req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
54
|
+
|
|
55
|
+
// POST /api/auth/login — email + password login (unprotected): the env
|
|
56
|
+
// bootstrap admin or a per-user account password (see AuthService).
|
|
37
57
|
router.post('/auth/login', async (req, res) => {
|
|
38
58
|
if (!passwordLoginEnabled) {
|
|
39
59
|
res.status(403).json({ error: 'Password login is disabled' });
|
|
@@ -44,9 +64,19 @@ export function createAuthRoutes(
|
|
|
44
64
|
res.status(400).json({ error: 'email and password are required' });
|
|
45
65
|
return;
|
|
46
66
|
}
|
|
67
|
+
const ip = clientIp(req);
|
|
68
|
+
const pairKey = `${ip}|${email.trim().toLowerCase()}`;
|
|
69
|
+
if (!loginPairLimiter.consume(pairKey) || !loginIpLimiter.consume(ip)) {
|
|
70
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
47
73
|
|
|
48
74
|
try {
|
|
49
75
|
const result = await authService.loginWithPassword(email, password);
|
|
76
|
+
// A real login clears the pair budget — a user who fumbled a few
|
|
77
|
+
// attempts isn't rate-limited on their next visit. (The wider per-ip
|
|
78
|
+
// window intentionally keeps counting.)
|
|
79
|
+
loginPairLimiter.reset(pairKey);
|
|
50
80
|
// Also set the JWT as an HttpOnly cookie so transports that can't
|
|
51
81
|
// carry headers (EventSource for SSE, image tags) authenticate
|
|
52
82
|
// automatically. JSON callers continue to use the Bearer header
|
|
@@ -68,16 +98,44 @@ export function createAuthRoutes(
|
|
|
68
98
|
});
|
|
69
99
|
|
|
70
100
|
// GET /api/auth/providers — unprotected capability probe so the login screen
|
|
71
|
-
// knows which login methods to render
|
|
101
|
+
// knows which login methods to render: the password form, plus one button
|
|
102
|
+
// per SSO provider (label + the path that starts its flow).
|
|
72
103
|
router.get('/auth/providers', (_req, res) => {
|
|
73
104
|
res.json({
|
|
74
105
|
password: passwordLoginEnabled,
|
|
75
|
-
|
|
106
|
+
sso: providers.map((p) => ({ key: p.key, label: p.label, startPath: p.startPath })),
|
|
76
107
|
});
|
|
77
108
|
});
|
|
78
109
|
|
|
79
110
|
for (const provider of providers) provider.mountRoutes(router, authService);
|
|
80
111
|
|
|
112
|
+
// POST /api/auth/change-password — self-service (protected). Requires the
|
|
113
|
+
// current password whenever one is set; an SSO-only account sets its first
|
|
114
|
+
// password without one.
|
|
115
|
+
router.post('/auth/change-password', authMiddleware, async (req, res) => {
|
|
116
|
+
const { currentPassword, newPassword } = req.body as {
|
|
117
|
+
currentPassword?: string;
|
|
118
|
+
newPassword?: string;
|
|
119
|
+
};
|
|
120
|
+
if (!newPassword) {
|
|
121
|
+
res.status(400).json({ error: 'newPassword is required' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!changePasswordLimiter.consume(req.userId!)) {
|
|
125
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await authService.changePassword(req.userId!, currentPassword, newPassword);
|
|
130
|
+
res.json({ ok: true });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
133
|
+
// Policy violations and a wrong current password are caller errors, not
|
|
134
|
+
// server faults — and the message is safe to show verbatim.
|
|
135
|
+
res.status(400).json({ error: msg });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
81
139
|
// GET /api/auth/me — validate stored JWT, return user info (protected)
|
|
82
140
|
router.get('/auth/me', authMiddleware, async (req, res) => {
|
|
83
141
|
try {
|