Add passkey login for protected UI (#845)
* feat(auth): add passkey login for protected UI * fix: polish passkey setup and correct WebAuthn user IDs * feat: improve passkey login management * build: align passkey deps with upstream main * refactor: move ui auth out of opencode module --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
37cf7d9c79
commit
75a10ea66c
@@ -40,6 +40,8 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bun-pty": "^0.4.5",
|
||||
|
||||
@@ -7,7 +7,7 @@ import net from 'net';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
||||
import { createUiAuth } from './lib/ui-auth/ui-auth.js';
|
||||
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
|
||||
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
|
||||
import { createTunnelProviderRegistry } from './lib/tunnels/registry.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenCode Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting.
|
||||
This module provides OpenCode server integration utilities for the web server runtime, including configuration management and provider authentication.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
|
||||
@@ -40,7 +40,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
|
||||
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
|
||||
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
|
||||
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
|
||||
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI session authentication runtime (outside OpenCode module).
|
||||
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: UI passkey storage and WebAuthn registration/authentication helpers (outside OpenCode module).
|
||||
|
||||
## Public exports (auth.js)
|
||||
- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`.
|
||||
@@ -67,15 +68,6 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom).
|
||||
- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management.
|
||||
|
||||
## Public exports (ui-auth.js)
|
||||
- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods:
|
||||
- `enabled`: Boolean indicating if auth is configured.
|
||||
- `requireAuth(req, res, next)`: Express middleware to enforce authentication.
|
||||
- `handleSessionStatus(req, res)`: Returns authentication status.
|
||||
- `handleSessionCreate(req, res)`: Handles login with rate limiting.
|
||||
- `ensureSessionToken(req, res)`: Returns or creates session token.
|
||||
- `dispose()`: Cleans up timers and state.
|
||||
|
||||
## Public exports (routes.js)
|
||||
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
|
||||
- `GET /api/config/settings`
|
||||
@@ -230,11 +222,19 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /health`
|
||||
- `POST /api/system/shutdown`
|
||||
- `GET /api/system/info`
|
||||
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
|
||||
- `GET /auth/session`
|
||||
- `POST /auth/session`
|
||||
- `GET /connect`
|
||||
- `app.use('/api', ...)` auth/tunnel guard
|
||||
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
|
||||
- `GET /auth/session`
|
||||
- `POST /auth/session`
|
||||
- `GET /auth/passkey/status`
|
||||
- `POST /auth/passkey/authenticate/options`
|
||||
- `POST /auth/passkey/authenticate/verify`
|
||||
- `POST /auth/passkey/register/options`
|
||||
- `POST /auth/passkey/register/verify`
|
||||
- `GET /api/passkeys`
|
||||
- `DELETE /api/passkeys/:id`
|
||||
- `POST /api/auth/reset`
|
||||
- `GET /connect`
|
||||
- `app.use('/api', ...)` auth/tunnel guard
|
||||
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
|
||||
- `GET /api/config/themes`
|
||||
- `POST /api/config/reload`
|
||||
|
||||
+4
-1
@@ -60,7 +60,10 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
|
||||
const uiAuthController = createUiAuth({ password: uiPassword });
|
||||
const uiAuthController = createUiAuth({
|
||||
password: uiPassword,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
if (uiAuthController.enabled) {
|
||||
console.log('UI password protection enabled for browser sessions');
|
||||
}
|
||||
|
||||
@@ -67,6 +67,100 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return uiAuthController.handleSessionCreate(req, res);
|
||||
});
|
||||
|
||||
app.get('/auth/passkey/status', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null, tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyStatus(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/authenticate/options', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyAuthenticationOptions(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/authenticate/verify', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyAuthenticationVerify(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/register/options', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/register/verify', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/passkeys', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyList(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/passkeys/:id', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRevoke(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handleResetAuth(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
try {
|
||||
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||
|
||||
@@ -55,7 +55,7 @@ export {
|
||||
OPENCODE_DATA_DIR,
|
||||
} from './auth.js';
|
||||
|
||||
export { createUiAuth } from './ui-auth.js';
|
||||
export { createUiAuth } from '../ui-auth/ui-auth.js';
|
||||
|
||||
export {
|
||||
listMcpConfigs,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# UI Auth Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module owns OpenChamber UI authentication for browser access, including password session auth, WebAuthn passkeys, and trusted-device session handling.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers.
|
||||
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers.
|
||||
|
||||
## Public exports (ui-auth.js)
|
||||
- `createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })`: creates UI auth controller with methods:
|
||||
- `enabled`
|
||||
- `requireAuth(req, res, next)`
|
||||
- `handleSessionStatus(req, res)`
|
||||
- `handleSessionCreate(req, res)`
|
||||
- `handlePasskeyStatus(req, res)`
|
||||
- `handlePasskeyRegistrationOptions(req, res)`
|
||||
- `handlePasskeyRegistrationVerify(req, res)`
|
||||
- `handlePasskeyAuthenticationOptions(req, res)`
|
||||
- `handlePasskeyAuthenticationVerify(req, res)`
|
||||
- `handlePasskeyList(req, res)`
|
||||
- `handlePasskeyRevoke(req, res)`
|
||||
- `handleResetAuth(req, res)`
|
||||
- `ensureSessionToken(req, res)`
|
||||
- `dispose()`
|
||||
|
||||
## Public exports (ui-passkeys.js)
|
||||
- `createUiPasskeys({ passwordBinding, readSettingsFromDiskMigrated, storeFile, rpName, challengeTtlMs })`: creates passkey runtime with methods:
|
||||
- `enabled`
|
||||
- `getStatus(req)`
|
||||
- `listPasskeys(req)`
|
||||
- `revokePasskey(req, passkeyId)`
|
||||
- `clearAllPasskeys()`
|
||||
- `beginRegistration(req, { label })`
|
||||
- `finishRegistration(payload)`
|
||||
- `beginAuthentication(req)`
|
||||
- `finishAuthentication(payload)`
|
||||
- `dispose()`
|
||||
+176
-13
@@ -3,9 +3,11 @@ import { SignJWT, jwtVerify } from 'jose';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createUiPasskeys } from './ui-passkeys.js';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const TRUSTED_DEVICE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
||||
@@ -232,7 +234,11 @@ const parseCookies = (cookieHeader) => {
|
||||
return acc;
|
||||
}
|
||||
const value = rest.join('=').trim();
|
||||
acc[key] = decodeURIComponent(value || '');
|
||||
try {
|
||||
acc[key] = decodeURIComponent(value || '');
|
||||
} catch {
|
||||
acc[key] = value || '';
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
@@ -274,6 +280,8 @@ const normalizePassword = (candidate) => {
|
||||
return candidate.normalize().trim();
|
||||
};
|
||||
|
||||
const isTrustedDeviceRequest = (value) => value === true;
|
||||
|
||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
@@ -305,17 +313,30 @@ function getOrCreateJwtSecret() {
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
function persistJwtSecret(secret) {
|
||||
if (process.env.OPENCODE_JWT_SECRET) {
|
||||
const error = new Error('Global sign-out is unavailable while OPENCODE_JWT_SECRET is set');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export const createUiAuth = ({
|
||||
password,
|
||||
cookieName = SESSION_COOKIE_NAME,
|
||||
sessionTtlMs = SESSION_TTL_MS,
|
||||
readSettingsFromDiskMigrated,
|
||||
} = {}) => {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
|
||||
if (!normalizedPassword) {
|
||||
const setSessionCookie = (req, res, token) => {
|
||||
const setSessionCookie = (req, res, token, ttlMs = sessionTtlMs) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||
const maxAgeSeconds = Math.floor(ttlMs / 1000);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: encodeURIComponent(token),
|
||||
@@ -331,7 +352,7 @@ export const createUiAuth = ({
|
||||
return cookies[cookieName];
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
setSessionCookie(req, res, token);
|
||||
setSessionCookie(req, res, token, sessionTtlMs);
|
||||
return token;
|
||||
};
|
||||
|
||||
@@ -344,6 +365,30 @@ export const createUiAuth = ({
|
||||
handleSessionCreate: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handlePasskeyStatus: (_req, res) => {
|
||||
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
|
||||
},
|
||||
handlePasskeyRegistrationOptions: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handlePasskeyRegistrationVerify: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handlePasskeyAuthenticationOptions: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handlePasskeyAuthenticationVerify: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handlePasskeyList: (_req, res) => {
|
||||
res.json({ passkeys: [] });
|
||||
},
|
||||
handlePasskeyRevoke: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handleResetAuth: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
ensureSessionToken,
|
||||
dispose: () => {
|
||||
|
||||
@@ -353,7 +398,28 @@ export const createUiAuth = ({
|
||||
|
||||
const salt = crypto.randomBytes(16);
|
||||
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
||||
const JWT_SECRET = getOrCreateJwtSecret();
|
||||
let jwtSecret = getOrCreateJwtSecret();
|
||||
let passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
|
||||
const resolveSessionTtlMs = (trustDevice) => (trustDevice ? TRUSTED_DEVICE_SESSION_TTL_MS : sessionTtlMs);
|
||||
let passkeyController = createUiPasskeys({
|
||||
passwordBinding,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
|
||||
const rebuildPasskeyController = () => {
|
||||
passkeyController.dispose();
|
||||
passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
|
||||
passkeyController = createUiPasskeys({
|
||||
passwordBinding,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
};
|
||||
|
||||
const rotateJwtSecret = () => {
|
||||
const nextSecret = crypto.randomBytes(32).toString('hex');
|
||||
jwtSecret = persistJwtSecret(nextSecret);
|
||||
rebuildPasskeyController();
|
||||
};
|
||||
|
||||
const getTokenFromRequest = (req) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
@@ -363,9 +429,9 @@ export const createUiAuth = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const setSessionCookie = (req, res, token) => {
|
||||
const setSessionCookie = (req, res, token, ttlMs) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||
const maxAgeSeconds = Math.floor(ttlMs / 1000);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: encodeURIComponent(token),
|
||||
@@ -407,20 +473,21 @@ export const createUiAuth = ({
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await jwtVerify(token, JWT_SECRET);
|
||||
await jwtVerify(token, jwtSecret);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const issueSession = async (req, res) => {
|
||||
const issueSession = async (req, res, { trustDevice = false } = {}) => {
|
||||
const ttlMs = resolveSessionTtlMs(trustDevice);
|
||||
const token = await new SignJWT({ type: 'ui-session' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(sessionTtlMs / 1000 + 's')
|
||||
.sign(JWT_SECRET);
|
||||
setSessionCookie(req, res, token);
|
||||
.setExpirationTime(ttlMs / 1000 + 's')
|
||||
.sign(jwtSecret);
|
||||
setSessionCookie(req, res, token, ttlMs);
|
||||
return token;
|
||||
};
|
||||
|
||||
@@ -484,16 +551,104 @@ export const createUiAuth = ({
|
||||
|
||||
await clearRateLimit(req);
|
||||
|
||||
await issueSession(req, res);
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
};
|
||||
|
||||
const respondPasskeyError = (res, error) => {
|
||||
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
|
||||
res.status(statusCode).json({ error: error?.message || 'Passkey request failed' });
|
||||
};
|
||||
|
||||
const handlePasskeyStatus = (req, res) => {
|
||||
try {
|
||||
res.json(passkeyController.getStatus(req));
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyRegistrationOptions = async (req, res) => {
|
||||
try {
|
||||
const label = typeof req.body?.label === 'string' ? req.body.label : '';
|
||||
const options = await passkeyController.beginRegistration(req, { label });
|
||||
res.json(options);
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyRegistrationVerify = async (req, res) => {
|
||||
try {
|
||||
const result = await passkeyController.finishRegistration(req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyAuthenticationOptions = async (req, res) => {
|
||||
try {
|
||||
const options = await passkeyController.beginAuthentication(req);
|
||||
res.json(options);
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyAuthenticationVerify = async (req, res) => {
|
||||
try {
|
||||
await passkeyController.finishAuthentication(req.body);
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyList = (req, res) => {
|
||||
try {
|
||||
res.json({ passkeys: passkeyController.listPasskeys(req) });
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyRevoke = (req, res) => {
|
||||
try {
|
||||
const result = passkeyController.revokePasskey(req, req.params?.id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetAuth = (req, res) => {
|
||||
try {
|
||||
const passkeyResult = passkeyController.clearAllPasskeys();
|
||||
rotateJwtSecret();
|
||||
clearSessionCookie(req, res);
|
||||
res.json({
|
||||
cleared: true,
|
||||
clearedPasskeys: passkeyResult.clearedCount,
|
||||
signedOutEverywhere: true,
|
||||
});
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
loginRateLimiter.clear();
|
||||
if (rateLimitCleanupTimer) {
|
||||
clearInterval(rateLimitCleanupTimer);
|
||||
rateLimitCleanupTimer = null;
|
||||
}
|
||||
passkeyController.dispose();
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -501,6 +656,14 @@ export const createUiAuth = ({
|
||||
requireAuth,
|
||||
handleSessionStatus,
|
||||
handleSessionCreate,
|
||||
handlePasskeyStatus,
|
||||
handlePasskeyRegistrationOptions,
|
||||
handlePasskeyRegistrationVerify,
|
||||
handlePasskeyAuthenticationOptions,
|
||||
handlePasskeyAuthenticationVerify,
|
||||
handlePasskeyList,
|
||||
handlePasskeyRevoke,
|
||||
handleResetAuth,
|
||||
ensureSessionToken: async (req, _res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
return (await isSessionValid(token)) ? token : null;
|
||||
@@ -0,0 +1,545 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
const DEFAULT_STORE_VERSION = 1;
|
||||
const DEFAULT_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_RP_NAME = 'OpenChamber';
|
||||
|
||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
|
||||
const PASSKEY_STORE_FILE = path.join(OPENCHAMBER_DATA_DIR, 'ui-passkeys.json');
|
||||
|
||||
const createUserId = () => crypto.randomBytes(32).toString('base64url');
|
||||
|
||||
const decodeUserId = (value) => {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Uint8Array.from(Buffer.from(value, 'base64url'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLabel = (value, fallback) => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
return normalized ? normalized.slice(0, 120) : fallback;
|
||||
};
|
||||
|
||||
const normalizeHost = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
const end = trimmed.indexOf(']');
|
||||
return end >= 0 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
return (colonIndex >= 0 ? trimmed.slice(0, colonIndex) : trimmed).toLowerCase();
|
||||
};
|
||||
|
||||
const isLocalRpId = (rpID) => rpID === 'localhost' || rpID === '127.0.0.1' || rpID === '::1';
|
||||
|
||||
const getCurrentRequestOrigin = (req) => {
|
||||
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
||||
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
||||
: '';
|
||||
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
|
||||
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
|
||||
? req.headers['x-forwarded-host'].split(',')[0].trim()
|
||||
: '';
|
||||
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
||||
|
||||
if (!host) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const getCurrentRpId = (req) => {
|
||||
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
|
||||
? req.headers['x-forwarded-host'].split(',')[0].trim()
|
||||
: '';
|
||||
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
||||
return normalizeHost(host || req.hostname || '');
|
||||
};
|
||||
|
||||
const parseStoredPasskey = (record) => {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof record.id !== 'string' || typeof record.publicKey !== 'string' || typeof record.rpID !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
publicKey: record.publicKey,
|
||||
counter: typeof record.counter === 'number' && Number.isFinite(record.counter) ? record.counter : 0,
|
||||
transports: Array.isArray(record.transports)
|
||||
? record.transports.filter((value) => typeof value === 'string')
|
||||
: [],
|
||||
deviceType: typeof record.deviceType === 'string' ? record.deviceType : 'singleDevice',
|
||||
backedUp: record.backedUp === true,
|
||||
createdAt: typeof record.createdAt === 'number' ? record.createdAt : Date.now(),
|
||||
lastUsedAt: typeof record.lastUsedAt === 'number' ? record.lastUsedAt : null,
|
||||
label: normalizeLabel(record.label, 'Unnamed device'),
|
||||
rpID: record.rpID,
|
||||
};
|
||||
};
|
||||
|
||||
export const createUiPasskeys = ({
|
||||
passwordBinding,
|
||||
readSettingsFromDiskMigrated,
|
||||
storeFile = PASSKEY_STORE_FILE,
|
||||
rpName = DEFAULT_RP_NAME,
|
||||
challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
|
||||
} = {}) => {
|
||||
const registrationChallenges = new Map();
|
||||
const authenticationChallenges = new Map();
|
||||
|
||||
const ensureStoreDirectory = () => {
|
||||
fs.mkdirSync(path.dirname(storeFile), { recursive: true });
|
||||
};
|
||||
|
||||
const persistStore = (store) => {
|
||||
ensureStoreDirectory();
|
||||
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2));
|
||||
};
|
||||
|
||||
const createEmptyStore = () => ({
|
||||
version: DEFAULT_STORE_VERSION,
|
||||
userID: createUserId(),
|
||||
passwordBinding,
|
||||
passkeys: [],
|
||||
});
|
||||
|
||||
const loadStore = () => {
|
||||
let store = createEmptyStore();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(storeFile)) {
|
||||
const raw = fs.readFileSync(storeFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
store = {
|
||||
version: DEFAULT_STORE_VERSION,
|
||||
userID: decodeUserId(parsed?.userID) ? parsed.userID : store.userID,
|
||||
passwordBinding: typeof parsed?.passwordBinding === 'string' ? parsed.passwordBinding : '',
|
||||
passkeys: Array.isArray(parsed?.passkeys) ? parsed.passkeys.map(parseStoredPasskey).filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[UI Passkeys] Failed to read passkey store:', error?.message || error);
|
||||
}
|
||||
|
||||
if (!passwordBinding) {
|
||||
if (store.passkeys.length > 0 || store.passwordBinding) {
|
||||
store = { ...store, passkeys: [], passwordBinding: '' };
|
||||
persistStore(store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
if (store.passwordBinding !== passwordBinding) {
|
||||
store = {
|
||||
version: DEFAULT_STORE_VERSION,
|
||||
userID: store.userID || createUserId(),
|
||||
passwordBinding,
|
||||
passkeys: [],
|
||||
};
|
||||
persistStore(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(storeFile)) {
|
||||
persistStore(store);
|
||||
}
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
const cleanupChallengeMap = (map) => {
|
||||
const now = Date.now();
|
||||
for (const [requestId, record] of map.entries()) {
|
||||
if (!record || now >= record.expiresAt) {
|
||||
map.delete(requestId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildOriginCandidates = async (req) => {
|
||||
const origins = new Set();
|
||||
const currentOrigin = getCurrentRequestOrigin(req);
|
||||
if (currentOrigin) {
|
||||
origins.add(currentOrigin);
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated?.();
|
||||
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
|
||||
origins.add(new URL(settings.publicOrigin.trim()).origin);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return Array.from(origins);
|
||||
};
|
||||
|
||||
const assertEnabled = () => {
|
||||
if (!passwordBinding) {
|
||||
const error = new Error('Passkeys require UI password protection to be enabled');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getPasskeysForRpId = (store, rpID) => store.passkeys.filter((passkey) => passkey.rpID === rpID);
|
||||
|
||||
const getStatus = (req) => {
|
||||
const store = loadStore();
|
||||
const rpID = getCurrentRpId(req);
|
||||
return {
|
||||
enabled: Boolean(passwordBinding),
|
||||
hasPasskeys: Boolean(rpID) && getPasskeysForRpId(store, rpID).length > 0,
|
||||
passkeyCount: Boolean(rpID) ? getPasskeysForRpId(store, rpID).length : 0,
|
||||
rpID,
|
||||
};
|
||||
};
|
||||
|
||||
const listPasskeys = (req) => {
|
||||
assertEnabled();
|
||||
|
||||
const store = loadStore();
|
||||
const rpID = getCurrentRpId(req);
|
||||
if (!rpID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getPasskeysForRpId(store, rpID).map((passkey) => ({
|
||||
id: passkey.id,
|
||||
label: passkey.label,
|
||||
createdAt: passkey.createdAt,
|
||||
lastUsedAt: passkey.lastUsedAt,
|
||||
deviceType: passkey.deviceType,
|
||||
backedUp: passkey.backedUp,
|
||||
}));
|
||||
};
|
||||
|
||||
const revokePasskey = (req, passkeyId) => {
|
||||
assertEnabled();
|
||||
|
||||
const normalizedPasskeyId = typeof passkeyId === 'string' ? passkeyId.trim() : '';
|
||||
if (!normalizedPasskeyId) {
|
||||
const error = new Error('Passkey ID is required');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const store = loadStore();
|
||||
const rpID = getCurrentRpId(req);
|
||||
const existingPasskey = store.passkeys.find((passkey) => passkey.id === normalizedPasskeyId && passkey.rpID === rpID);
|
||||
|
||||
if (!existingPasskey) {
|
||||
const error = new Error('Passkey not found for this host');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextPasskeys = store.passkeys.filter((passkey) => !(passkey.id === normalizedPasskeyId && passkey.rpID === rpID));
|
||||
persistStore({
|
||||
...store,
|
||||
passwordBinding,
|
||||
passkeys: nextPasskeys,
|
||||
});
|
||||
|
||||
return {
|
||||
revoked: true,
|
||||
passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === rpID).length,
|
||||
};
|
||||
};
|
||||
|
||||
const clearAllPasskeys = () => {
|
||||
assertEnabled();
|
||||
|
||||
const store = loadStore();
|
||||
const clearedCount = store.passkeys.length;
|
||||
persistStore({
|
||||
...store,
|
||||
userID: crypto.randomBytes(32).toString('base64url'),
|
||||
passwordBinding,
|
||||
passkeys: [],
|
||||
});
|
||||
|
||||
return {
|
||||
cleared: true,
|
||||
clearedCount,
|
||||
};
|
||||
};
|
||||
|
||||
const beginRegistration = async (req, { label } = {}) => {
|
||||
assertEnabled();
|
||||
cleanupChallengeMap(registrationChallenges);
|
||||
|
||||
const rpID = getCurrentRpId(req);
|
||||
if (!rpID) {
|
||||
const error = new Error('Unable to resolve a valid passkey host for this request');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentOrigin = getCurrentRequestOrigin(req);
|
||||
if (!currentOrigin) {
|
||||
const error = new Error('Unable to resolve a valid passkey origin for this request');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const store = loadStore();
|
||||
const userID = decodeUserId(store.userID);
|
||||
if (!userID) {
|
||||
const error = new Error('Passkey storage is invalid. Please try again.');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID,
|
||||
userName: 'openchamber-ui',
|
||||
userDisplayName: 'OpenChamber UI',
|
||||
attestationType: 'none',
|
||||
excludeCredentials: getPasskeysForRpId(store, rpID).map((passkey) => ({
|
||||
id: passkey.id,
|
||||
transports: passkey.transports,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
|
||||
const requestId = crypto.randomBytes(16).toString('base64url');
|
||||
registrationChallenges.set(requestId, {
|
||||
challenge: options.challenge,
|
||||
expectedOrigins: await buildOriginCandidates(req),
|
||||
expectedRPIDs: [rpID],
|
||||
rpID,
|
||||
label: normalizeLabel(label, 'This device'),
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + challengeTtlMs,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
optionsJSON: options,
|
||||
};
|
||||
};
|
||||
|
||||
const finishRegistration = async (payload) => {
|
||||
assertEnabled();
|
||||
cleanupChallengeMap(registrationChallenges);
|
||||
|
||||
const store = loadStore();
|
||||
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
|
||||
const response = payload?.response;
|
||||
|
||||
const matchingRecord = requestId ? registrationChallenges.get(requestId) : null;
|
||||
if (!matchingRecord) {
|
||||
const error = new Error('Passkey setup has expired. Please try again.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
registrationChallenges.delete(requestId);
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: matchingRecord.challenge,
|
||||
expectedOrigin: matchingRecord.expectedOrigins,
|
||||
expectedRPID: matchingRecord.expectedRPIDs,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
const error = new Error('Passkey registration could not be verified');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
credential,
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
} = verification.registrationInfo;
|
||||
|
||||
const nextPasskeys = store.passkeys.filter((passkey) => passkey.id !== credential.id);
|
||||
nextPasskeys.push({
|
||||
id: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
|
||||
counter: credential.counter,
|
||||
transports: Array.isArray(credential.transports) ? credential.transports.filter((value) => typeof value === 'string') : [],
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: null,
|
||||
label: matchingRecord.label,
|
||||
rpID: matchingRecord.rpID,
|
||||
});
|
||||
|
||||
persistStore({
|
||||
...store,
|
||||
passwordBinding,
|
||||
passkeys: nextPasskeys,
|
||||
});
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === matchingRecord.rpID).length,
|
||||
};
|
||||
};
|
||||
|
||||
const beginAuthentication = async (req) => {
|
||||
assertEnabled();
|
||||
cleanupChallengeMap(authenticationChallenges);
|
||||
|
||||
const store = loadStore();
|
||||
const rpID = getCurrentRpId(req);
|
||||
const passkeys = getPasskeysForRpId(store, rpID);
|
||||
|
||||
if (!rpID || passkeys.length === 0) {
|
||||
const error = new Error('No passkeys are registered for this host yet');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: 'required',
|
||||
allowCredentials: passkeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
transports: passkey.transports,
|
||||
})),
|
||||
});
|
||||
|
||||
const requestId = crypto.randomBytes(16).toString('base64url');
|
||||
authenticationChallenges.set(requestId, {
|
||||
challenge: options.challenge,
|
||||
expectedOrigins: await buildOriginCandidates(req),
|
||||
expectedRPIDs: [rpID],
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + challengeTtlMs,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
optionsJSON: options,
|
||||
};
|
||||
};
|
||||
|
||||
const finishAuthentication = async (payload) => {
|
||||
assertEnabled();
|
||||
cleanupChallengeMap(authenticationChallenges);
|
||||
|
||||
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
|
||||
const response = payload?.response;
|
||||
const store = loadStore();
|
||||
const passkey = store.passkeys.find((item) => item.id === response?.id);
|
||||
|
||||
if (!passkey) {
|
||||
const error = new Error('That passkey is not registered for this OpenChamber instance');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const matchingRecord = requestId ? authenticationChallenges.get(requestId) : null;
|
||||
if (!matchingRecord) {
|
||||
const error = new Error('Passkey sign-in has expired. Please try again.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
authenticationChallenges.delete(requestId);
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: matchingRecord.challenge,
|
||||
expectedOrigin: matchingRecord.expectedOrigins,
|
||||
expectedRPID: matchingRecord.expectedRPIDs,
|
||||
credential: {
|
||||
id: passkey.id,
|
||||
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.authenticationInfo) {
|
||||
const error = new Error('Passkey sign-in could not be verified');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextPasskeys = store.passkeys.map((item) => (
|
||||
item.id === passkey.id
|
||||
? {
|
||||
...item,
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: Date.now(),
|
||||
}
|
||||
: item
|
||||
));
|
||||
|
||||
persistStore({
|
||||
...store,
|
||||
passwordBinding,
|
||||
passkeys: nextPasskeys,
|
||||
});
|
||||
|
||||
return { verified: true };
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
registrationChallenges.clear();
|
||||
authenticationChallenges.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: Boolean(passwordBinding),
|
||||
getStatus,
|
||||
listPasskeys,
|
||||
revokePasskey,
|
||||
clearAllPasskeys,
|
||||
beginRegistration,
|
||||
finishRegistration,
|
||||
beginAuthentication,
|
||||
finishAuthentication,
|
||||
dispose,
|
||||
isLocalRpId,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user