local-auth-routes.mjs
165 lines 5.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Phase 8 P1b-b — local login + bootstrap HTTP routes (§6.1, §6.2).
3 */
4
5 import express from 'express';
6 import {
7 authenticateLocalUser,
8 credentialStoreHasAdmin,
9 hasAnyCredential,
10 } from './local-auth.mjs';
11 import { consumeSetupToken } from './local-auth-bootstrap.mjs';
12 import { appendLocalAuthAudit, requestAuditMeta } from './local-auth-audit.mjs';
13 import { validatePassphraseStrength } from './breached-passwords.mjs';
14
15 /**
16 * Register offline-locked local auth routes on an Express app.
17 * @param {import('express').Express} app
18 * @param {{
19 * dataDir: string,
20 * sessionSecret: string,
21 * jwtExpiry: string,
22 * offlineLockedActive: boolean,
23 * adminUserIdsSet?: Set<string>,
24 * issueRefreshCookie?: (res: import('express').Response, req: import('express').Request, sub: string) => Promise<void>,
25 * basePath?: string,
26 * }} opts
27 */
28 export function registerLocalAuthRoutes(app, opts) {
29 const {
30 dataDir,
31 sessionSecret,
32 jwtExpiry,
33 offlineLockedActive,
34 adminUserIdsSet = new Set(),
35 issueRefreshCookie,
36 basePath = '/api/v1/auth/local',
37 } = opts;
38
39 if (!offlineLockedActive) return;
40
41 app.post(`${basePath}/login`, express.json(), async (req, res) => {
42 const { username, passphrase } = req.body || {};
43 const { ip, ua } = requestAuditMeta(req);
44
45 if (!username || !passphrase) {
46 return res.status(401).json({ error: 'Invalid credentials', code: 'INVALID_CREDENTIALS' });
47 }
48
49 if (!credentialStoreHasAdmin(dataDir)) {
50 return res.status(503).json({
51 error: 'Offline-locked not bootstrapped',
52 code: 'OFFLINE_LOCKED_NOT_BOOTSTRAPPED',
53 });
54 }
55
56 const result = await authenticateLocalUser(dataDir, username, passphrase, {
57 sessionSecret,
58 jwtExpiry,
59 offlineLockedActive,
60 adminUserIdsSet,
61 });
62
63 if (!result.ok) {
64 if (result.code === 'RATE_LIMITED') {
65 return res.status(429).json({
66 error: 'Rate limit exceeded',
67 code: 'RATE_LIMITED',
68 retryAfterSeconds: result.retryAfterSeconds || 60,
69 });
70 }
71 if (result.code === 'ACCOUNT_LOCKED') {
72 appendLocalAuthAudit(dataDir, 'local_auth.account_locked', {
73 username_attempted: username,
74 ip,
75 ts: new Date().toISOString(),
76 lockedUntil: new Date(Date.now() + (result.retryAfterSeconds || 900) * 1000).toISOString(),
77 });
78 return res.status(401).json({
79 error: 'Account locked',
80 code: 'ACCOUNT_LOCKED',
81 retryAfterSeconds: result.retryAfterSeconds || 900,
82 });
83 }
84 if (result.code === 'OFFLINE_LOCKED_NOT_BOOTSTRAPPED') {
85 return res.status(503).json({
86 error: 'Offline-locked not bootstrapped',
87 code: 'OFFLINE_LOCKED_NOT_BOOTSTRAPPED',
88 });
89 }
90 appendLocalAuthAudit(dataDir, 'local_auth.login_failure', {
91 username_attempted: username,
92 ip,
93 ua,
94 ts: new Date().toISOString(),
95 reason: 'invalid_credentials',
96 });
97 return res.status(401).json({ error: 'Invalid credentials', code: 'INVALID_CREDENTIALS' });
98 }
99
100 appendLocalAuthAudit(dataDir, 'local_auth.login_success', {
101 sub: result.sub,
102 username: result.credential.username,
103 ip,
104 ua,
105 ts: new Date().toISOString(),
106 });
107
108 if (issueRefreshCookie) {
109 try {
110 await issueRefreshCookie(res, req, result.sub);
111 } catch (_) {
112 /* refresh cookie failure must not block login */
113 }
114 }
115
116 return res.json({ token: result.token, expiresIn: jwtExpiry });
117 });
118
119 app.post(`${basePath}/bootstrap`, express.json(), async (req, res) => {
120 const { setupToken, username, passphrase } = req.body || {};
121 const { ip } = requestAuditMeta(req);
122
123 if (credentialStoreHasAdmin(dataDir)) {
124 return res.status(409).json({ error: 'Already bootstrapped', code: 'ALREADY_BOOTSTRAPPED' });
125 }
126
127 const strength = validatePassphraseStrength(passphrase || '');
128 if (!strength.ok) {
129 return res.status(400).json({ error: 'Passphrase too weak', code: 'WEAK_PASSPHRASE' });
130 }
131
132 const result = await consumeSetupToken(dataDir, setupToken, username, passphrase, { ip });
133 if (!result.ok) {
134 if (result.code === 'ALREADY_BOOTSTRAPPED') {
135 return res.status(409).json({ error: 'Already bootstrapped', code: 'ALREADY_BOOTSTRAPPED' });
136 }
137 if (result.code === 'BOOTSTRAP_TOKEN_CONSUMED') {
138 return res.status(410).json({ error: 'Bootstrap token consumed', code: 'BOOTSTRAP_TOKEN_CONSUMED' });
139 }
140 if (result.code === 'BOOTSTRAP_TOKEN_EXPIRED') {
141 return res.status(410).json({ error: 'Bootstrap token expired', code: 'BOOTSTRAP_TOKEN_EXPIRED' });
142 }
143 if (result.code === 'RATE_LIMITED') {
144 return res.status(429).json({ error: 'Rate limit exceeded', code: 'RATE_LIMITED', retryAfterSeconds: 60 });
145 }
146 return res.status(410).json({ error: 'Bootstrap token expired', code: 'BOOTSTRAP_TOKEN_EXPIRED' });
147 }
148
149 return res.json({ ok: true, userId: result.userId });
150 });
151 }
152
153 /**
154 * Providers endpoint shape when offline-locked (§6.3).
155 * @param {boolean} offlineLockedActive
156 * @returns {import('express').RequestHandler}
157 */
158 export function localAuthProvidersHandler(offlineLockedActive) {
159 return (_req, res, next) => {
160 if (!offlineLockedActive) return next();
161 return res.json({ google: false, github: false, local: true });
162 };
163 }
164
165 export { credentialStoreHasAdmin, hasAnyCredential };
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago