tr-plugin-auth 0.0.0-stage → 1.0.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/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +266 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1687 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createGoogleOAuth } from '../google-oauth.mjs';
|
|
3
|
+
import { listPasskeys, beginAuthentication, finishAuthentication } from '../webauthn-store.mjs';
|
|
4
|
+
import { getSecret } from '../secrets-store.mjs';
|
|
5
|
+
export function createAuthRouter({ auth, logger, BRAIN_DIR, googleOAuth }) {
|
|
6
|
+
const { requireAuth, requireScope, authRateLimiter, isLocalRequest, loadSecurityConfig,
|
|
7
|
+
loginHandler, logoutHandler, changePasswordHandler, signSessionToken,
|
|
8
|
+
dashboardAuthState, sessionCookieOptions } = auth;
|
|
9
|
+
function brainDir() {
|
|
10
|
+
return process.env.TR_SECRETS_BRAIN || BRAIN_DIR;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const google = googleOAuth || createGoogleOAuth({ brainDir: brainDir() });
|
|
14
|
+
const browserCookie = 'tr_google_oauth';
|
|
15
|
+
const browserCookieOptions = req => ({ httpOnly: true, secure: req.secure === true, sameSite: 'lax', path: '/auth/google', maxAge: 600000 });
|
|
16
|
+
|
|
17
|
+
async function getGoogleCredentials() {
|
|
18
|
+
const idResult = await getSecret(brainDir(), 'GOOGLE_CLIENT_ID');
|
|
19
|
+
const secretResult = await getSecret(brainDir(), 'GOOGLE_CLIENT_SECRET');
|
|
20
|
+
return { clientId: idResult.found ? idResult.value : null, clientSecret: secretResult.found ? secretResult.value : null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const authRouter = express.Router();
|
|
26
|
+
authRouter.use('/auth', authRateLimiter());
|
|
27
|
+
|
|
28
|
+
// ─── Consolidated Auth Routes ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
authRouter.post('/auth/login', loginHandler);
|
|
31
|
+
authRouter.post('/auth/logout', logoutHandler);
|
|
32
|
+
authRouter.post('/auth/change-password', requireAuth, requireScope('config:write'), changePasswordHandler);
|
|
33
|
+
authRouter.get('/auth/me', requireAuth, (req, res) => res.json({ authenticated: true }));
|
|
34
|
+
|
|
35
|
+
authRouter.get("/auth/status", (req, res) => {
|
|
36
|
+
const config = loadSecurityConfig();
|
|
37
|
+
const configured = dashboardAuthState().configured;
|
|
38
|
+
res.json({ configured });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Discovery endpoint for all active authentication providers.
|
|
43
|
+
*/
|
|
44
|
+
authRouter.get('/auth/providers', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const config = loadSecurityConfig();
|
|
47
|
+
const passkeys = listPasskeys(brainDir());
|
|
48
|
+
const { clientId } = await getGoogleCredentials();
|
|
49
|
+
res.json({
|
|
50
|
+
password: { configured: dashboardAuthState().configured },
|
|
51
|
+
passkey: {
|
|
52
|
+
enabled: true,
|
|
53
|
+
has_passkeys: passkeys.length > 0,
|
|
54
|
+
count: passkeys.length,
|
|
55
|
+
},
|
|
56
|
+
google: {
|
|
57
|
+
configured: Boolean(clientId),
|
|
58
|
+
client_id: clientId ? `${clientId.slice(0, 12)}…` : null,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.warn('auth', `Failed to query auth providers: ${err.message}`);
|
|
63
|
+
res.status(500).json({ error: 'Failed to inspect authentication providers' });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate WebAuthn authentication options for Passkey login.
|
|
69
|
+
*/
|
|
70
|
+
authRouter.post('/auth/passkey/options', async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const options = await beginAuthentication(brainDir(), req);
|
|
73
|
+
res.json(options);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.warn('auth', `Passkey login options generation failed: ${err.message}`);
|
|
76
|
+
res.status(400).json({ error: err.message });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify WebAuthn response and issue dashboard session cookie upon success.
|
|
82
|
+
*/
|
|
83
|
+
authRouter.post('/auth/passkey/verify', async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const body = req.body || {};
|
|
86
|
+
const response = body.response || body;
|
|
87
|
+
if (!response?.id) {
|
|
88
|
+
return res.status(400).json({ error: 'WebAuthn assertion response required' });
|
|
89
|
+
}
|
|
90
|
+
const result = await finishAuthentication(brainDir(), req, response);
|
|
91
|
+
if (!result?.verified) {
|
|
92
|
+
return res.status(401).json({ error: 'Passkey verification failed' });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const config = loadSecurityConfig();
|
|
96
|
+
const ttlHours = config.dashboard?.session_ttl_hours || 24;
|
|
97
|
+
const token = signSessionToken(
|
|
98
|
+
{ user: 'admin', auth_type: 'passkey', credential_id: result.credentialId },
|
|
99
|
+
{ ttlSeconds: ttlHours * 3600 },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
|
|
103
|
+
logger.info('auth', 'Passkey login successful', { credentialId: result.credentialId });
|
|
104
|
+
res.json({ success: true, user: 'admin', method: 'passkey' });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.warn('auth', `Passkey assertion verification failed: ${err.message}`);
|
|
107
|
+
res.status(401).json({ error: err.message || 'Passkey verification failed' });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Browser initiation owns state/cookie custody; CLI opens this endpoint. */
|
|
112
|
+
authRouter.get('/auth/google', (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const attempt = google.begin();
|
|
115
|
+
res.set('Cache-Control', 'no-store');
|
|
116
|
+
res.cookie(browserCookie, attempt.browser, browserCookieOptions(req));
|
|
117
|
+
if (req.query.format === 'json' || req.headers.accept?.includes('application/json')) return res.json({ url: attempt.url, redirectUri: attempt.redirectUri });
|
|
118
|
+
return res.redirect(attempt.url);
|
|
119
|
+
} catch {
|
|
120
|
+
return res.status(400).json({ error: 'Configure Google credentials and a trusted callback URI through auth' });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
authRouter.get('/auth/google/callback', async (req, res) => {
|
|
125
|
+
res.set('Cache-Control', 'no-store');
|
|
126
|
+
const { maxAge, ...clearOptions } = browserCookieOptions(req);
|
|
127
|
+
res.clearCookie(browserCookie, clearOptions);
|
|
128
|
+
try {
|
|
129
|
+
if (req.query.error) return res.status(400).json({ error: 'Google authorization was declined' });
|
|
130
|
+
const user = await google.complete({ code: req.query.code, state: req.query.state, browser: req.cookies?.[browserCookie] });
|
|
131
|
+
const config = loadSecurityConfig();
|
|
132
|
+
const ttlHours = config.dashboard?.session_ttl_hours || 24;
|
|
133
|
+
const token = signSessionToken({ user: user.email, google_sub: user.sub, auth_type: 'google_oauth2', name: user.name, email: user.email }, { ttlSeconds: ttlHours * 3600 });
|
|
134
|
+
res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
|
|
135
|
+
if (req.query.format === 'json' || req.headers.accept?.includes('application/json')) return res.json({ success: true, user: user.email, method: 'google_oauth2' });
|
|
136
|
+
return res.redirect('/');
|
|
137
|
+
} catch {
|
|
138
|
+
logger.warn('auth', 'Google OAuth callback verification failed');
|
|
139
|
+
return res.status(401).json({ error: 'Google authentication failed; restart sign-in' });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
authRouter.get('/auth/google/accounts', requireAuth, requireScope('config:read'), (_req, res) => {
|
|
144
|
+
try { return res.json({ accounts: google.status() }); }
|
|
145
|
+
catch { return res.status(500).json({ error: 'Unable to read connection metadata' }); }
|
|
146
|
+
});
|
|
147
|
+
authRouter.post('/auth/google/accounts/:id/refresh', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
148
|
+
try { await google.access(req.params.id); return res.json({ accounts: google.status() }); }
|
|
149
|
+
catch { return res.status(409).json({ error: 'Unable to refresh connection; inspect status or reconnect' }); }
|
|
150
|
+
});
|
|
151
|
+
authRouter.post('/auth/google/accounts/:id/disconnect', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
152
|
+
try { return res.json(await google.disconnect(req.params.id)); }
|
|
153
|
+
catch { return res.status(409).json({ error: 'Unable to revoke connection; no disconnect confirmed' }); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* First-run: claim an unconfigured brain by setting its dashboard password.
|
|
158
|
+
*
|
|
159
|
+
* Unauthenticated by necessity -- there is no credential to present yet. That
|
|
160
|
+
* makes "who may call this" the only control, and it must be the machine the
|
|
161
|
+
* brain runs on.
|
|
162
|
+
*
|
|
163
|
+
* This was safe by accident for as long as a brain with no security.yml bound
|
|
164
|
+
* loopback only. Once such a brain binds its mesh address, every device on the
|
|
165
|
+
* tailnet can reach this route, and the first one to call it owns the brain --
|
|
166
|
+
* including a phone that later gets lost, or any node someone else was invited
|
|
167
|
+
* onto. A first-run window is exactly when there is nothing else to stop them.
|
|
168
|
+
*
|
|
169
|
+
* Headless hosts set the password over the CLI, which is already the documented
|
|
170
|
+
* path for a machine with no browser on it.
|
|
171
|
+
*/
|
|
172
|
+
authRouter.post("/auth/setup", async (req, res) => {
|
|
173
|
+
const config = loadSecurityConfig();
|
|
174
|
+
const alreadyConfigured = dashboardAuthState().configured;
|
|
175
|
+
if (alreadyConfigured) {
|
|
176
|
+
return res.status(400).json({ error: "Dashboard password is already configured" });
|
|
177
|
+
}
|
|
178
|
+
if (!isLocalRequest(req)) {
|
|
179
|
+
logger.warn(
|
|
180
|
+
'auth',
|
|
181
|
+
`Refused remote first-run setup from ${req.ip || req.socket?.remoteAddress || 'unknown'} `
|
|
182
|
+
+ '— an unconfigured brain may only be claimed from the machine it runs on.',
|
|
183
|
+
);
|
|
184
|
+
return res.status(403).json({
|
|
185
|
+
error:
|
|
186
|
+
'First-run setup must come from the machine running this brain. '
|
|
187
|
+
+ 'On a headless host, set the password with the CLI instead: '
|
|
188
|
+
+ 'npx total-recall reset-password',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// A malicious web page can POST to loopback or use DNS rebinding. The
|
|
192
|
+
// browser's Origin must identify loopback or an explicit operator allowlist.
|
|
193
|
+
const origin = req.headers.origin;
|
|
194
|
+
if (origin || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
195
|
+
let allowed = false;
|
|
196
|
+
try {
|
|
197
|
+
const url = new URL(origin);
|
|
198
|
+
allowed = ['http:', 'https:'].includes(url.protocol) &&
|
|
199
|
+
(['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || (config.network?.allowed_origins || []).includes(origin));
|
|
200
|
+
} catch { /* absent, opaque or malformed browser origin */ }
|
|
201
|
+
if (!allowed) return res.status(403).json({ error: 'First-run setup requires a trusted browser origin' });
|
|
202
|
+
}
|
|
203
|
+
return changePasswordHandler(req, res);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return authRouter;
|
|
207
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
/** Owner API; host injects the selected key store and authentication boundary. */
|
|
3
|
+
export function createKeysRouter({ store, auth, notFound, badRequest, serverError }) {
|
|
4
|
+
const { issueKey, revokeKey, loadKeys, loadRoles, setRole, listKeyUsage, keyHasScope } = store;
|
|
5
|
+
const { requireAuth, requireScope } = auth;
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
router.get('/api/keys', requireAuth, requireScope('keys:read'), (req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
const keys = loadKeys().map(({ token_hash, ...k }) => k);
|
|
11
|
+
res.json({ keys });
|
|
12
|
+
} catch (err) {
|
|
13
|
+
serverError(res, err);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.post('/api/keys', requireAuth, requireScope('keys:write'), (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const { name, scopes, expires_at, role, kind } = req.body || {};
|
|
20
|
+
if (!name) return badRequest(res, 'name is required');
|
|
21
|
+
const key = issueKey(name, { scopes, expires_at, role, kind, issuer: req.auth?.type === 'pat' ? req.auth : undefined });
|
|
22
|
+
res.set('Cache-Control', 'no-store');
|
|
23
|
+
res.status(201).json({
|
|
24
|
+
id: key.id,
|
|
25
|
+
name: key.name,
|
|
26
|
+
token: key.token, // only time it's returned in plaintext
|
|
27
|
+
token_prefix: key.token_prefix,
|
|
28
|
+
scopes: key.scopes,
|
|
29
|
+
role: key.role,
|
|
30
|
+
expires_at: key.expires_at,
|
|
31
|
+
created_at: key.created_at,
|
|
32
|
+
_warning: 'Save this token — it will not be shown again.',
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
serverError(res, err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.get('/api/key-roles', requireAuth, requireScope('keys:read'), (_req, res) => {
|
|
40
|
+
try { res.json({ roles: loadRoles() }); } catch (err) { serverError(res, err); }
|
|
41
|
+
});
|
|
42
|
+
router.put('/api/key-roles/:name', requireAuth, requireScope('keys:write'), (req, res) => {
|
|
43
|
+
if (req.auth?.type !== 'dashboard_session' && !keyHasScope(req.auth, '*')) return res.status(403).json({ error: 'Role policy requires administrator permissions' });
|
|
44
|
+
try { res.json(setRole(req.params.name, req.body?.scopes)); } catch (err) { badRequest(res, err.message); }
|
|
45
|
+
});
|
|
46
|
+
router.get('/api/keys/:id/usage', requireAuth, requireScope('keys:read'), (req, res) => {
|
|
47
|
+
try { res.json({ events: listKeyUsage(req.params.id) }); } catch (err) { serverError(res, err); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.delete('/api/keys/:id', requireAuth, requireScope('keys:write'), (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const key = revokeKey(req.params.id);
|
|
53
|
+
if (!key) return notFound(res, `Key not found: ${req.params.id}`);
|
|
54
|
+
res.json({ revoked: true, id: key.id, name: key.name });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
serverError(res, err);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return router;
|
|
61
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { importHostModule } from '../host-runtime.mjs';
|
|
2
|
+
import { createSecretsRouter } from './secrets.mjs';
|
|
3
|
+
const auth = await importHostModule('src/server/auth.mjs');
|
|
4
|
+
const shared = await importHostModule('src/server/routes/_shared.mjs');
|
|
5
|
+
export default createSecretsRouter({ ...auth, ...shared });
|