@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
@@ -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 only; the
15
- * composition root passes provider plugins (e.g. Microsoft OIDC, owned by the
16
- * sharepoint module) which mount their own routes under `/auth/<key>/…` and
17
- * are advertised to the login screen under `key` by the capability probe.
18
- * A plugin is only passed in when its machinery is configured AND the
19
- * instance hasn't switched it off — presence means enabled.
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. 'microsoft' → /auth/microsoft/…). */
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
- // POST /api/auth/login — email + shared-password login (unprotected)
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. SSO providers appear under their key.
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
- ...Object.fromEntries(providers.map((p) => [p.key, true])),
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 {