openvibers/openvibe.network Commits
Passkeys across related origins: publish the list and broker product logins (#850)
Changes
@@@ -2,16 +2,28 @@Enrollment uses the existing default-off `mfa_enrollment` owner setting and `NETWORK_MFA_PILOT=1` pilot (configured owner and `ovprobe_merge_keep`). Leave general enrollment off pending the account-screen review. Existing keys remain usable and protect the password fallback even when enrollment is off. Existing sessions and site data are unchanged.--The relying party is **openvibe.network**, with the exact allowed origin **https://openvibe.network**. A sign-in begun on another OpenVibe site redirects to Network as before; the browser uses its Network passkey there and the existing OAuth/PKCE continuation returns the person to the originating site. Credentials are not registered independently at product domains. Local cryptographic tests simulate the Network origin; a browser ceremony requires the HTTPS Network page.++The relying party is **openvibe.network**. On Network itself the exact allowed origin is **https://openvibe.network**. Product origins are allowed through WebAuthn Related Origin Requests (below); credentials are never registered independently at product domains, and enrollment/removal stay on Network. Local cryptographic tests simulate both origins; a browser ceremony requires the HTTPS page.The pinned MIT packages are `@simplewebauthn/server` 14.1.0 (Node >=22) and `@simplewebauthn/browser` 13.2.2. Browser modules are served locally at `/vendor/passkeys/`; there is no third-party script or attestation service. We require discoverable credentials, user presence and user verification and request no attestation. Each account has a random opaque WebAuthn user handle. Up to ten keys have names, creation/last-use dates and backup metadata. The login page supports native conditional mediation and an explicit button.--All new API routes under `/api/auth/passkeys` have a 30/minute address limit, JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.++All new API routes under `/api/auth/passkeys` have a 30/minute address limit (the server-to-server related routes have a 120/minute per-client limit), JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.Adding/removing a key requires the current password and, when any factor is already registered, a current authenticator/recovery code or a fresh passkey proof. The latter is single-use, lasts five minutes and is bound to the exact signed-in session. Adding/removing a key invalidates outstanding management challenges and remembered-browser cookies, without revoking existing sessions. Adding an authenticator to a passkey-only account also requires factor proof. First-factor registration issues ten hashed recovery codes once; existing authenticator recovery codes are retained. Password sign-in can use a passkey or an unused recovery code as its second factor. Removing the last factor removes its remaining recovery codes.`0067_passkeys.sql` is expand-only. ADR-033 exports credential metadata, omitting public keys, opaque handles, challenges, bindings and hashes. Account deletion removes both new tables; session-wide revocation removes account-bound pending ceremonies. No codes, private keys or assertion bodies are logged. This part does not add the Part 3 session/step-up claims or notifications.--Checks: `ov test passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.++## Related origin requests (cross-site passkeys)++++`GET https://openvibe.network/.well-known/webauthn` publishes `{ "origins": [...] }` with `Content-Type: application/json`. The list is generated from the live service manifests' `publicOrigin` (`exposure.publicSite: service`) plus a short reviewed alias list; customer/tenant hosts (`*.openvibe.website`, Host/Link subdomains), infrastructure hosts (`api.`, `auth.`, `ingest.`, `s3.`, `themes.`) and http/non-default ports are never listed. All approved `openvibe.*` registrable domains share the `openvibe` label; `openre.stream` and `open.shoes` are two more, inside the browsers' five-label limit. `server/webauthn-origins.js` is the single source.++++A participating product's own server brokers the ceremony with its registered OAuth client (confidential clients only; public apps and node principals are refused):++++- `POST /api/auth/passkeys/related/options` → `{ state, options }`. The exact `origin` must be both the client's registered redirect origin and on the published list, and a random browser binding (32+ URL-safe bytes, kept in the product's own `__Host-` cookie) is required. Purpose is `login` only.++- `POST /api/auth/passkeys/related/verify` → `{ result }`. Network verifies the assertion against the exact signed `origin` and RP ID `openvibe.network`, requires user verification and the account's own discoverable user handle, consumes the challenge atomically, applies the existing counter/lockout/account-state checks, and stores a single-use result bound to that client and origin.++- `POST /api/auth/passkeys/related/redeem` → the product's ordinary OAuth token pair (same issuer, verifier and audience rules as `/oauth/token`). The result is consumed once and cannot be redeemed by another client or origin. No Network session cookie or browser-held Network token is involved.++++Refusals tested in `test/passkeys-related.test.js`: unpublished origin, an origin not registered to the calling client, a registered but unpublished origin, wrong client secret/public client, wrong purpose, missing/short browser binding, wrong binding, borrowed state on another origin, expiry, challenge and result replay, concurrent redemption (one winner), wrong client/origin redemption, wrong RP, non-UV flags, wrong user handle, a Network-origin assertion in the product flow, a merged user and a pending-password ceremony for another account. The iOS/Android-style virtual authenticator is the existing software ES256 helper; a browser ceremony is verified in HTTPS Chrome with a virtual authenticator during rollout.++++Checks: `ov test passkeys-related passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.For Fable: account passkey registration/name, fresh-authentication controls, one-time recovery display, key list/removal, sign-in passkey button, and native conditional UI entry point. All additions use plain kit markup with no new CSS.nnew file mode 100644@@@ -0,0 +1,192 @@++'use strict';++/**++ * Cross-site passkeys: WebAuthn Related Origin Requests (openvibe.network/.well-known/webauthn).++ *++ * A person on openvibe.blog runs `navigator.credentials.get` with Network's rpID; the browser checks++ * Network's well-known list and allows it for that exact origin. The ceremony is brokered by the++ * product's own server, never by the browser calling Network:++ *++ * POST /api/auth/passkeys/related/options (OAuth client) → { state, options }++ * POST /api/auth/passkeys/related/verify (OAuth client) → { result } one use++ * POST /api/auth/passkeys/related/redeem (OAuth client) → tokens for the product's session++ *++ * The client authenticates with its registered OAuth secret. Its exact redirect origin and the++ * published related-origin list must both contain the origin, which is never taken from a browser++ * header. The challenge is stored with a hash of (client, origin, browser binding); the product keeps++ * the browser binding in its own `__Host-` cookie. Network verifies the exact signed origin and RP ID,++ * consumes the challenge atomically, and keeps the existing user/presence/verification/counter/++ * lockout checks. The result is single-use and redeemable only by the same client and origin; it turns++ * into the product's ordinary audience-scoped session through /oauth/token's issuer, never a Network++ * session cookie or a browser-held Network bearer token.++ */++const crypto = require('crypto');++const express = require('express');++const rateLimit = require('express-rate-limit');++const webauthn = require('@simplewebauthn/server');++const mfa = require('./mfa');++const dock = require('./dock-tokens');++const devTokens = require('../developer/tokens');++const origins = require('../webauthn-origins');++++const RP = 'openvibe.network';++const BINDING_RE = /^[A-Za-z0-9_-]{32,128}$/;++const hash = mfa.hash;++const liveUser = u => u && !u.is_banned && !u.deleted_at && !u.merged_into;++++function sameSecret(a, b) {++ const x = Buffer.from(String(a || ''));++ const y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++}++++function credentials(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Basic ')) {++ let decoded;++ try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch { return null; }++ const at = decoded.indexOf(':');++ if (at < 0) return null;++ return { id: decoded.slice(0, at), secret: decoded.slice(at + 1) };++ }++ return { id: req.body && req.body.client_id, secret: req.body && req.body.client_secret };++}++++/** The confidential first-party OAuth client that is calling, or null. */++async function clientFrom(req, db) {++ const given = credentials(req);++ if (!given || typeof given.id !== 'string' || !given.id || given.id.length > 200) return null;++ if (devTokens.isAppClient(given.id) || /^lnk_/.test(given.id)) return null;++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(given.id);++ if (!client || dock.isPublicClient(client)) return null;++ return sameSecret(client.client_secret, given.secret) ? client : null;++}++++/** The exact https origins this client may return to (its registered redirect URIs). */++function clientOrigins(client) {++ let list;++ try { list = JSON.parse(client.redirect_uris || '[]'); } catch { return []; }++ const out = new Set();++ for (const value of Array.isArray(list) ? list : []) {++ try {++ const u = new URL(String(value));++ if (u.protocol === 'https:' && !u.username && !u.password && (!u.port || u.port === '443') && u.pathname !== '') out.add(u.origin);++ } catch { /* not an origin */ }++ }++ return [...out];++}++++function originAllowed(client, origin) {++ return !!origin && origins.isApprovedOrigin(origin) && clientOrigins(client).includes(origin);++}++++const challengeBinding = (clientId, origin, browserBinding) => hash(`ov-related-passkey\n${clientId}\n${origin}\n${browserBinding}`);++const resultBinding = (clientId, origin) => hash(`ov-related-result\n${clientId}\n${origin}`);++++const invalid = res => res.status(401).json({ error: 'invalid_client' });++const refused = res => res.status(401).json({ error: 'passkey_or_state_invalid' });++++function router() {++ const r = express.Router();++ // The product server is the caller; the browser never reaches these routes. Per-client, not per address: many++ // products share Network's loopback address.++ r.use(rateLimit({ windowMs: 60000, max: 120, standardHeaders: true, legacyHeaders: false, keyGenerator: req => String((req.body && req.body.client_id) || req.ip) }));++++ r.post('/options', async (req, res) => {++ try {++ const db = req.app.locals.db;++ const client = await clientFrom(req, db);++ if (!client) return invalid(res);++ const origin = origins.originOf(req.body && req.body.origin);++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });++ if (String((req.body && req.body.purpose) || 'login') !== 'login') return res.status(400).json({ error: 'unsupported_purpose' });++ const binding = String((req.body && req.body.binding) || '');++ if (!BINDING_RE.test(binding)) return res.status(400).json({ error: 'binding_required' });++ const options = await webauthn.generateAuthenticationOptions({ rpID: RP, userVerification: 'required' });++ const state = await require('./passkeys').save(db, 'related-login', options.challenge, null, challengeBinding(client.client_id, origin, binding));++ return res.json({ state, options });++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }++ });++++ r.post('/verify', async (req, res) => {++ try {++ const db = req.app.locals.db;++ const client = await clientFrom(req, db);++ if (!client) return invalid(res);++ const body = req.body || {};++ const origin = origins.originOf(body.origin);++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });++ const binding = String(body.binding || '');++ if (!BINDING_RE.test(binding) || typeof body.state !== 'string' || !body.state || !body.response || typeof body.response.id !== 'string') {++ return res.status(400).json({ error: 'invalid_request' });++ }++ const bind = challengeBinding(client.client_id, origin, binding);++ const result = await db.tx(async () => {++ const challenge = await require('./passkeys').take(db, body.state, 'related-login', bind);++ if (!challenge) return null;++ const key = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(body.response.id);++ if (!key) return null;++ const user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(key.user_id);++ if (!liveUser(user)) return null;++ const factor = await mfa.factor(db, user.id, true);++ if (!factor || Number(factor.locked_until) > Date.now()) return null;++ // Re-read after the account lock: removal and concurrent assertions serialize here.++ const current = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(key.id);++ if (!current) return null;++ let checked;++ try {++ checked = await webauthn.verifyAuthenticationResponse({++ response: body.response,++ expectedChallenge: challenge.challenge,++ expectedOrigin: origin, // the exact signed origin, never a header++ expectedRPID: RP,++ requireUserVerification: true,++ credential: { id: current.id, publicKey: new Uint8Array(Buffer.from(current.public_key, 'base64url')), counter: Number(current.counter) },++ });++ } catch { checked = { verified: false }; }++ // A discoverable sign-in must present the account's own user handle.++ const handle = body.response.response && body.response.response.userHandle;++ if (!checked.verified || !handle || handle !== factor.passkey_handle) {++ const failures = Number(factor.locked_until) > 0 ? 1 : factor.failures + 1;++ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? Date.now() + 300000 : 0, user.id);++ return null;++ }++ await db.prepare('UPDATE user_passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?').run(checked.authenticationInfo.newCounter, checked.authenticationInfo.credentialBackedUp, Date.now(), key.id);++ await db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(user.id);++ return require('./passkeys').save(db, 'related-result', '', user, resultBinding(client.client_id, origin));++ });++ if (!result) return refused(res);++ return res.json({ result });++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }++ });++++ r.post('/redeem', async (req, res) => {++ try {++ const db = req.app.locals.db;++ const client = await clientFrom(req, db);++ if (!client) return invalid(res);++ const body = req.body || {};++ const origin = origins.originOf(body.origin);++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });++ if (typeof body.result !== 'string' || !body.result) return res.status(400).json({ error: 'invalid_request' });++ const user = await db.tx(async () => {++ const challenge = await require('./passkeys').take(db, body.result, 'related-result', resultBinding(client.client_id, origin));++ if (!challenge || !challenge.user_id) return null;++ const row = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(challenge.user_id);++ if (!liveUser(row) || challenge.credential_hash !== hash(row.password_hash)) return null;++ return row;++ });++ if (!user) return refused(res);++ const oauth = require('./oauth-routes');++ const pair = await oauth.issueTokenPair(db, req.app.locals.config, req, user, client, {}, 'profile');++ oauth.recordLinkedService(db, user, client);++ return res.json({++ access_token: pair.accessToken, refresh_token: pair.refreshToken, token_type: 'Bearer', expires_in: pair.expiresIn,++ user: { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color },++ });++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }++ });++++ return r;++}++++module.exports = { router, clientOrigins, originAllowed, RP };@@@ -36,7 +36,8 @@ async function authorize(db, req) {}function router({ requireAuth, signToken, sanitizeUser }) {const r = express.Router();-- r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false }));++ // The server-to-server related routes have their own per-client limit (one address for every product).++ r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false, skip: req => String(req.path).startsWith('/related/') }));r.use((req, res, next) => {res.set('Cache-Control', 'no-store');if (req.method !== 'GET' && !req.is('application/json')) return res.status(415).json({ error: 'JSON required' });@@@ -103,6 +104,9 @@ function router({ requireAuth, signToken, sanitizeUser }) {res.json({ token, user: sanitizeUser(result.user) });}r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));++ // Cross-site passkeys (Related Origin Requests): a product's server, authenticated as its OAuth++ // client, gets and verifies one ceremony. Mounted before requireAuth: the client is the caller.++ r.use('/related', require('./passkeys-related').router());r.post('/login/verify', route((req, res, db) => verify(req, res, db, 'login')));r.use(requireAuth);r.get('/', route(async (req, res, db) => res.json({ available: await mfa.allowed(db, req.user), keys: (await list(db, req.user.id)).map(({ id, name, created_at, last_used_at, device_type, backed_up }) => ({ id, name, created_at, last_used_at, device_type, backed_up })) })));@@@ -177,4 +181,4 @@ async function invalidate(db, userId) {await db.prepare('UPDATE user_mfa SET generation = ?, pending_secret_ciphertext = NULL, pending_until = 0 WHERE user_id = ?').run(random(), userId);for (const t of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(userId);}--module.exports = { router, list, authorize };++module.exports = { router, list, authorize, save, take, hash };@@@ -1190,6 +1190,12 @@ require('./auth/app-links').mount(app);const fedcm = require('./auth/fedcm');const ctx = () => ({ db, publicKey, privateKey, config });app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));++ // WebAuthn Related Origin Requests: the exact product origins allowed to use Network's passkeys.++ app.get('/.well-known/webauthn', (_req, res) => {++ res.set('Content-Type', 'application/json; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=300');++ res.send(JSON.stringify({ origins: require('./webauthn-origins').origins() }));++ });// OpenID Connect discovery at the issuer's root (and RFC 8414's name for it): server/auth/oidc.jsrequire('./auth/oidc').mount(app);app.use('/fedcm', fedcm.createFedcmRoutes(ctx));@@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\function isPublicDiscoveryPath(p) {const path = String(p || '');if (path === '/.well-known/openvibe') return true;++ if (path === '/.well-known/webauthn') return true; // the browser's Related Origin Requests checkif (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget readsnnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * The origins a browser may use Network's passkeys from (WebAuthn Related Origin Requests,++ * https://openvibe.network/.well-known/webauthn). The list is generated from the service manifests:++ * a service that declares a public UI origin is an operator-owned product origin. Infrastructure++ * hosts (api., auth., ingest., s3., themes.) and the staff aliases are not public UI origins, and a++ * customer-controlled subdomain (a Website or Host tenant) is never added: the manifests carry the++ * product's own host, not its tenants. A small reviewed alias list adds the operator-owned UI hosts++ * that are product aliases rather than a manifest's publicOrigin.++ *++ * The rpID is openvibe.network and the credential store is unchanged. Listing an origin only lets a++ * browser run a ceremony there; the Network still verifies the exact origin and the OAuth client's++ * registered redirect origin before it issues or accepts a challenge.++ */++const contracts = require('openvibe-contracts');++++// Operator-owned UI aliases that are not a manifest publicOrigin (reviewed 2026-11-11).++const ALIASES = Object.freeze([++ 'https://openvibe.my', // Inventory's mounted account pages++ 'https://openvibe.link', // Link's account pages++ 'https://openvibe.party', // Chat's party rooms++ 'https://openvibe.download', // Media Hub's download site++ 'https://openvibe.pics', // Media Hub's pictures site++ 'https://openvibe.homes', // Rent's homes site++]);++const TENANT_SUFFIX = '.openvibe.website';++let cached = null;++++function originOf(value) {++ let u;++ try { u = new URL(String(value || '')); } catch { return null; }++ if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || u.pathname !== '/') return null;++ if (u.port && u.port !== '443') return null;++ return `https://${u.hostname}`;++}++++function generatedOrigins(manifests) {++ const out = new Set();++ for (const m of manifests || []) {++ const exposure = (m && m.exposure) || {};++ if (exposure.state !== 'live' || exposure.publicSite !== 'service') continue;++ const origin = originOf(m.publicOrigin);++ if (!origin) continue;++ const host = new URL(origin).hostname;++ // Customer sites on the Website product are subdomains, and its own apex is cookie-free tenant content.++ if (host === 'openvibe.website' || host.endsWith(TENANT_SUFFIX)) continue;++ out.add(origin);++ }++ for (const alias of ALIASES) out.add(alias);++ return [...out].sort();++}++++/** Every exact https origin allowed to run a Network passkey ceremony. Frozen for the process. */++function origins() {++ if (!cached) cached = Object.freeze(generatedOrigins(contracts.services && contracts.services.manifests));++ return cached;++}++++function isApprovedOrigin(origin) {++ return typeof origin === 'string' && origins().includes(origin);++}++++module.exports = { origins, isApprovedOrigin, originOf, generatedOrigins };nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++// Cross-site passkeys (Related Origin Requests): real ES256 assertions for a product origin, the++// published origin list, and every refusal the design names. Verification is never stubbed.++const assert = require('assert');++const crypto = require('crypto');++const express = require('express');++const bcrypt = require('bcryptjs');++const cookieParser = require('cookie-parser');++const { getDb } = require('../server/db/database');++const auth = require('../server/auth/routes');++const { authenticator } = require('./helpers/passkey-authenticator');++const webauthnOrigins = require('../server/webauthn-origins');++++const basic = (id, secret) => 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64');++++(async () => {++ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');++ const db = getDb();++ const pw = 'test-only-password';++ const aliceId = 91, bobId = 92;++ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)')++ .run(aliceId, 'related_alice', bcrypt.hashSync(pw, 4), bobId, 'related_bob', bcrypt.hashSync(pw, 4));++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++ const app = express(); app.use(express.json()); app.use(cookieParser()); Object.assign(app.locals, { db, config, ...keys }); app.use('/api/auth', auth);++ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.on('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;++ const alice = await db.prepare('SELECT * FROM users WHERE id = ?').get(aliceId);++ const at = auth.signToken(alice, keys.privateKey, config);++ const call = async (path, body, authHeader, cookies) => {++ const res = await fetch(base + path, {++ method: body === undefined ? 'GET' : 'POST',++ headers: { 'content-type': 'application/json', ...(authHeader ? { authorization: authHeader } : {}), ...(cookies ? { cookie: cookies } : {}) },++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),++ });++ return { status: res.status, body: await res.json(), cookies: res.headers.getSetCookie() };++ };++ const cookieOf = (r, name = '__Host-ov_passkey') => r.cookies.find(s => s.startsWith(name + '='))?.split(';')[0];++ const related = (path, body, creds = basic('blog-related', 'blog-secret')) => call('/passkeys/related/' + path, body, creds);++ const origin = 'https://openvibe.blog';++ const binding = crypto.randomBytes(32).toString('base64url');++ const reset = async (id) => db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(id);++ const assertionFor = async (device, state, overrides = {}, bind = binding) => {++ const r = await related('options', { origin, binding: bind, purpose: 'login' });++ assert.equal(r.status, 200, 'related options');++ const opts = r.body.options;++ return related('verify', { origin, binding: bind, state: r.body.state, response: device.assert(opts, { origin, rp: 'openvibe.network', ...overrides }) });++ };++ try {++ // ── the published list ─────────────────────────────────────++ const list = webauthnOrigins.origins();++ for (const want of ['https://openvibe.network', 'https://openvibe.blog', 'https://openvibe.live', 'https://openvibe.my', 'https://openre.stream', 'https://open.shoes']) {++ assert.ok(list.includes(want), `well-known lists ${want}`);++ }++ for (const no of ['https://openvibe.website', 'https://maps.openvibe.tools', 'https://api.openvibe.network', 'https://s3.openvibe.media', 'https://evil.example']) {++ assert.ok(!list.includes(no), `well-known excludes ${no}`);++ }++ assert.ok(list.every(o => /^https:\/\/[a-z0-9.-]+$/.test(o)), 'only exact https origins');++ assert.ok(webauthnOrigins.isApprovedOrigin(origin));++ const generated = webauthnOrigins.generatedOrigins([++ { id: 'a', publicOrigin: 'https://openvibe.a', exposure: { state: 'live', publicSite: 'service' } },++ { id: 'b', publicOrigin: 'https://openvibe.website', exposure: { state: 'live', publicSite: 'service' } },++ { id: 'c', publicOrigin: 'https://openvibe.c', exposure: { state: 'internal', publicSite: 'service' } },++ { id: 'd', publicOrigin: 'http://openvibe.d', exposure: { state: 'live', publicSite: 'service' } },++ ]);++ assert.ok(generated.includes('https://openvibe.a'));++ for (const no of ['https://openvibe.website', 'https://openvibe.c', 'http://openvibe.d']) assert.ok(!generated.includes(no), no);++++ // ── fixtures: two confidential clients and one public, one unregistered origin ──++ const addClient = (id, secret, redirect, isPublic = 0) => db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public) VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, redirect_uris = EXCLUDED.redirect_uris, is_public = EXCLUDED.is_public")++ .run(id, secret, id, JSON.stringify(redirect), isPublic);++ await addClient('blog-related', 'blog-secret', ['https://openvibe.blog/auth/callback']);++ await addClient('blog-other', 'other-secret', ['https://openvibe.blog/auth/callback']);++ await addClient('live-related', 'live-secret', ['https://openvibe.live/auth/callback']);++ await addClient('unknown-origin', 'unknown-secret', ['https://evil.example/auth/callback']);++ await addClient('public-related', '', ['https://openvibe.blog/auth/callback'], 1);++ await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId));++++ // ── enroll a real credential for Alice through the existing Network flow ──++ await db.prepare("INSERT INTO site_settings (key, value) VALUES ('mfa_enrollment', 'all') ON CONFLICT (key) DO UPDATE SET value = 'all'").run();++ const device = authenticator(), bobDevice = authenticator();++ for (const [who, token, dev] of [[alice, at, device], [await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), auth.signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), keys.privateKey, config), bobDevice]]) {++ const start = await call('/passkeys/register/options', { password: pw }, 'Bearer ' + token); assert.equal(start.status, 200);++ const done = await call('/passkeys/register/verify', { state: start.body.state, response: dev.register(start.body.options), name: 'Related test key' }, 'Bearer ' + token);++ assert.equal(done.status, 200, 'real registration for the related tests');++ }++++ // ── a real assertion for the product origin, then its single-use redeem ──++ const first = await related('options', { origin, binding, purpose: 'login' });++ assert.equal(first.status, 200); assert.equal(first.body.options.rpId, 'openvibe.network');++ const verified = await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) });++ if (verified.status !== 200) console.error('related verify', verified.status, JSON.stringify(verified.body)); assert.equal(verified.status, 200, 'real ES256 assertion for https://openvibe.blog'); assert.match(verified.body.result, /^[A-Za-z0-9_-]{20,}$/);++ const redeemed = await related('redeem', { origin, result: verified.body.result });++ assert.equal(redeemed.status, 200, 'single-use result redeems'); assert.ok(redeemed.body.access_token && redeemed.body.refresh_token);++ const claims = JSON.parse(Buffer.from(redeemed.body.access_token.split('.')[1], 'base64url').toString());++ assert.equal(claims.sub, aliceId); assert.equal(claims.client_id, 'blog-related'); assert.ok([].concat(claims.aud).includes('openvibe.network'));++ assert.equal(redeemed.body.user.username, 'related_alice');++ assert.equal((await related('redeem', { origin, result: verified.body.result })).status, 401, 'result replay');++ assert.equal((await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) })).status, 401, 'challenge replay');++++ // ── concurrent redemption: exactly one winner ──++ const secondOptions = await related('options', { origin, binding });++ const secondVerified = await related('verify', { origin, binding, state: secondOptions.body.state, response: device.assert(secondOptions.body.options, { origin }) });++ assert.equal(secondVerified.status, 200);++ const race = await Promise.all([1, 2].map(() => related('redeem', { origin, result: secondVerified.body.result })));++ assert.deepEqual(race.map(r => r.status).sort(), [200, 401], 'atomic result redemption');++++ // ── refusals ───────────────────────────────────────────────++ assert.equal((await related('options', { origin, binding }, basic('blog-related', 'wrong'))).status, 401, 'wrong client secret');++ assert.equal((await related('options', { origin, binding }, basic('public-related', ''))).status, 401, 'public client refused');++ assert.equal((await related('options', { origin: 'https://evil.example', binding })).status, 403, 'unpublished origin');++ assert.equal((await related('options', { origin: 'https://openvibe.live', binding })).status, 403, 'origin not registered to this client');++ assert.equal((await related('options', { origin, binding }, basic('unknown-origin', 'unknown-secret'))).status, 403, 'registered but unpublished origin');++ assert.equal((await related('options', { origin, binding, purpose: 'reauth' })).status, 400, 'only a login ceremony');++ assert.equal((await related('options', { origin, binding: '' })).status, 400, 'browser binding required');++ assert.equal((await related('verify', { origin, binding: 'x'.repeat(43), state: 'nope', response: {} })).status, 400, 'malformed body refused');++ let o = await related('options', { origin, binding });++ assert.equal((await related('verify', { origin, binding: crypto.randomBytes(32).toString('base64url'), state: o.body.state, response: device.assert(o.body.options, { origin }) })).status, 401, 'wrong browser binding');++ assert.equal((await related('verify', { origin: 'https://openvibe.live', binding, state: o.body.state, response: {} })).status, 403, 'borrowed state on another origin');++ for (const bad of [{ origin, rp: 'evil.example' }, { origin, flags: 0x41 }, { origin, wrongHandle: true }]) {++ o = await related('options', { origin, binding });++ const refused = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, bad) });++ assert.equal(refused.status, 401, `refused assertion ${JSON.stringify(Object.keys(bad))}`);++ }++ o = await related('options', { origin, binding });++ const expired = { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin }) };++ await db.prepare("UPDATE passkey_challenges SET expires_at = 0 WHERE purpose = 'related-login'").run();++ assert.equal((await related('verify', expired)).status, 401, 'expired challenge');++ const third = await related('options', { origin, binding });++ const thirdVerified = await related('verify', { origin, binding, state: third.body.state, response: device.assert(third.body.options, { origin }) });++ assert.equal(thirdVerified.status, 200);++ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('blog-other', 'other-secret'))).status, 401, 'another client cannot redeem');++ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('live-related', 'live-secret'))).status, 403, 'a client of another origin cannot even ask');++ assert.equal((await related('redeem', { origin: 'https://openvibe.live', result: thirdVerified.body.result })).status, 403, 'another origin cannot redeem');++++ // ── account state: revoked/merged, and a pending password for someone else ──++ await reset(aliceId); await reset(bobId);++ const pending = cookieOf(await call('/login', { username: 'related_alice', password: pw }), '__Host-ov_mfa_pending');++ assert.ok(pending, 'Alice has a pending password challenge');++ o = await related('options', { origin, binding });++ const bobVerified = await related('verify', { origin, binding, state: o.body.state, response: bobDevice.assert(o.body.options, { origin }) });++ assert.equal(bobVerified.status, 200);++ const bobRedeemed = await related('redeem', { origin, result: bobVerified.body.result });++ assert.equal(bobRedeemed.status, 200);++ assert.equal(JSON.parse(Buffer.from(bobRedeemed.body.access_token.split('.')[1], 'base64url').toString()).sub, bobId, 'a pending password for Alice cannot switch the passkey account');++ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(aliceId, aliceId);++ assert.equal((await assertionFor(device)).status, 401, 'merged user refused');++ await db.prepare('UPDATE users SET merged_into = NULL WHERE id = ?').run(aliceId);++ await reset(aliceId);++ const after = await assertionFor(device);++ assert.equal(after.status, 200, 'a merged flag removed lets the account use its key again');++++ // The real verifier checked the signed origin: a valid Network-origin assertion is refused for this flow.++ o = await related('options', { origin, binding });++ const networkOrigin = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin: 'https://openvibe.network' }) });++ assert.equal(networkOrigin.status, 401, 'Network-origin assertion is not a product-origin assertion');++++ console.log('Related passkeys: published/read-only origins, real ES256 at https://openvibe.blog, RP/origin/UV/user-handle refusals, expiry, replay, concurrent redemption, client/origin binding, merged users and pending-password isolation passed');++ } finally { server.close(); await globalThis.__ovNetworkTestDbClose?.(); }++})().catch(err => {++ console.error('Related passkey tests failed (assertion details redacted)');++ console.error(String(err.stack).split('\n').filter(line => /^\s+at /.test(line)).slice(0, 4).join('\n'));++ process.exitCode = 1;++});
@@ -2,16 +2,28 @@
@@ -2,16 +2,28 @@
Enrollment uses the existing default-off `mfa_enrollment` owner setting and `NETWORK_MFA_PILOT=1` pilot (configured owner and `ovprobe_merge_keep`). Leave general enrollment off pending the account-screen review. Existing keys remain usable and protect the password fallback even when enrollment is off. Existing sessions and site data are unchanged.
Enrollment uses the existing default-off `mfa_enrollment` owner setting and `NETWORK_MFA_PILOT=1` pilot (configured owner and `ovprobe_merge_keep`). Leave general enrollment off pending the account-screen review. Existing keys remain usable and protect the password fallback even when enrollment is off. Existing sessions and site data are unchanged.
-The relying party is **openvibe.network**, with the exact allowed origin **https://openvibe.network**. A sign-in begun on another OpenVibe site redirects to Network as before; the browser uses its Network passkey there and the existing OAuth/PKCE continuation returns the person to the originating site. Credentials are not registered independently at product domains. Local cryptographic tests simulate the Network origin; a browser ceremony requires the HTTPS Network page.
+The relying party is **openvibe.network**. On Network itself the exact allowed origin is **https://openvibe.network**. Product origins are allowed through WebAuthn Related Origin Requests (below); credentials are never registered independently at product domains, and enrollment/removal stay on Network. Local cryptographic tests simulate both origins; a browser ceremony requires the HTTPS page.
The pinned MIT packages are `@simplewebauthn/server` 14.1.0 (Node >=22) and `@simplewebauthn/browser` 13.2.2. Browser modules are served locally at `/vendor/passkeys/`; there is no third-party script or attestation service. We require discoverable credentials, user presence and user verification and request no attestation. Each account has a random opaque WebAuthn user handle. Up to ten keys have names, creation/last-use dates and backup metadata. The login page supports native conditional mediation and an explicit button.
The pinned MIT packages are `@simplewebauthn/server` 14.1.0 (Node >=22) and `@simplewebauthn/browser` 13.2.2. Browser modules are served locally at `/vendor/passkeys/`; there is no third-party script or attestation service. We require discoverable credentials, user presence and user verification and request no attestation. Each account has a random opaque WebAuthn user handle. Up to ten keys have names, creation/last-use dates and backup metadata. The login page supports native conditional mediation and an explicit button.
-All new API routes under `/api/auth/passkeys` have a 30/minute address limit, JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.
+All new API routes under `/api/auth/passkeys` have a 30/minute address limit (the server-to-server related routes have a 120/minute per-client limit), JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.
Adding/removing a key requires the current password and, when any factor is already registered, a current authenticator/recovery code or a fresh passkey proof. The latter is single-use, lasts five minutes and is bound to the exact signed-in session. Adding/removing a key invalidates outstanding management challenges and remembered-browser cookies, without revoking existing sessions. Adding an authenticator to a passkey-only account also requires factor proof. First-factor registration issues ten hashed recovery codes once; existing authenticator recovery codes are retained. Password sign-in can use a passkey or an unused recovery code as its second factor. Removing the last factor removes its remaining recovery codes.
Adding/removing a key requires the current password and, when any factor is already registered, a current authenticator/recovery code or a fresh passkey proof. The latter is single-use, lasts five minutes and is bound to the exact signed-in session. Adding/removing a key invalidates outstanding management challenges and remembered-browser cookies, without revoking existing sessions. Adding an authenticator to a passkey-only account also requires factor proof. First-factor registration issues ten hashed recovery codes once; existing authenticator recovery codes are retained. Password sign-in can use a passkey or an unused recovery code as its second factor. Removing the last factor removes its remaining recovery codes.
`0067_passkeys.sql` is expand-only. ADR-033 exports credential metadata, omitting public keys, opaque handles, challenges, bindings and hashes. Account deletion removes both new tables; session-wide revocation removes account-bound pending ceremonies. No codes, private keys or assertion bodies are logged. This part does not add the Part 3 session/step-up claims or notifications.
`0067_passkeys.sql` is expand-only. ADR-033 exports credential metadata, omitting public keys, opaque handles, challenges, bindings and hashes. Account deletion removes both new tables; session-wide revocation removes account-bound pending ceremonies. No codes, private keys or assertion bodies are logged. This part does not add the Part 3 session/step-up claims or notifications.
-Checks: `ov test passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.
+## Related origin requests (cross-site passkeys)
+
+`GET https://openvibe.network/.well-known/webauthn` publishes `{ "origins": [...] }` with `Content-Type: application/json`. The list is generated from the live service manifests' `publicOrigin` (`exposure.publicSite: service`) plus a short reviewed alias list; customer/tenant hosts (`*.openvibe.website`, Host/Link subdomains), infrastructure hosts (`api.`, `auth.`, `ingest.`, `s3.`, `themes.`) and http/non-default ports are never listed. All approved `openvibe.*` registrable domains share the `openvibe` label; `openre.stream` and `open.shoes` are two more, inside the browsers' five-label limit. `server/webauthn-origins.js` is the single source.
+
+A participating product's own server brokers the ceremony with its registered OAuth client (confidential clients only; public apps and node principals are refused):
+
+- `POST /api/auth/passkeys/related/options` → `{ state, options }`. The exact `origin` must be both the client's registered redirect origin and on the published list, and a random browser binding (32+ URL-safe bytes, kept in the product's own `__Host-` cookie) is required. Purpose is `login` only.
+- `POST /api/auth/passkeys/related/verify` → `{ result }`. Network verifies the assertion against the exact signed `origin` and RP ID `openvibe.network`, requires user verification and the account's own discoverable user handle, consumes the challenge atomically, applies the existing counter/lockout/account-state checks, and stores a single-use result bound to that client and origin.
+- `POST /api/auth/passkeys/related/redeem` → the product's ordinary OAuth token pair (same issuer, verifier and audience rules as `/oauth/token`). The result is consumed once and cannot be redeemed by another client or origin. No Network session cookie or browser-held Network token is involved.
+
+Refusals tested in `test/passkeys-related.test.js`: unpublished origin, an origin not registered to the calling client, a registered but unpublished origin, wrong client secret/public client, wrong purpose, missing/short browser binding, wrong binding, borrowed state on another origin, expiry, challenge and result replay, concurrent redemption (one winner), wrong client/origin redemption, wrong RP, non-UV flags, wrong user handle, a Network-origin assertion in the product flow, a merged user and a pending-password ceremony for another account. The iOS/Android-style virtual authenticator is the existing software ES256 helper; a browser ceremony is verified in HTTPS Chrome with a virtual authenticator during rollout.
+
+Checks: `ov test passkeys-related passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.
For Fable: account passkey registration/name, fresh-authentication controls, one-time recovery display, key list/removal, sign-in passkey button, and native conditional UI entry point. All additions use plain kit markup with no new CSS.
For Fable: account passkey registration/name, fresh-authentication controls, one-time recovery display, key list/removal, sign-in passkey button, and native conditional UI entry point. All additions use plain kit markup with no new CSS.
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+'use strict';
+/**
+ * Cross-site passkeys: WebAuthn Related Origin Requests (openvibe.network/.well-known/webauthn).
+ *
+ * A person on openvibe.blog runs `navigator.credentials.get` with Network's rpID; the browser checks
+ * Network's well-known list and allows it for that exact origin. The ceremony is brokered by the
+ * product's own server, never by the browser calling Network:
+ *
+ * POST /api/auth/passkeys/related/options (OAuth client) → { state, options }
+ * POST /api/auth/passkeys/related/verify (OAuth client) → { result } one use
+ * POST /api/auth/passkeys/related/redeem (OAuth client) → tokens for the product's session
+ *
+ * The client authenticates with its registered OAuth secret. Its exact redirect origin and the
+ * published related-origin list must both contain the origin, which is never taken from a browser
+ * header. The challenge is stored with a hash of (client, origin, browser binding); the product keeps
+ * the browser binding in its own `__Host-` cookie. Network verifies the exact signed origin and RP ID,
+ * consumes the challenge atomically, and keeps the existing user/presence/verification/counter/
+ * lockout checks. The result is single-use and redeemable only by the same client and origin; it turns
+ * into the product's ordinary audience-scoped session through /oauth/token's issuer, never a Network
+ * session cookie or a browser-held Network bearer token.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const webauthn = require('@simplewebauthn/server');
+const mfa = require('./mfa');
+const dock = require('./dock-tokens');
+const devTokens = require('../developer/tokens');
+const origins = require('../webauthn-origins');
+
+const RP = 'openvibe.network';
+const BINDING_RE = /^[A-Za-z0-9_-]{32,128}$/;
+const hash = mfa.hash;
+const liveUser = u => u && !u.is_banned && !u.deleted_at && !u.merged_into;
+
+function sameSecret(a, b) {
+ const x = Buffer.from(String(a || ''));
+ const y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+}
+
+function credentials(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Basic ')) {
+ let decoded;
+ try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch { return null; }
+ const at = decoded.indexOf(':');
+ if (at < 0) return null;
+ return { id: decoded.slice(0, at), secret: decoded.slice(at + 1) };
+ }
+ return { id: req.body && req.body.client_id, secret: req.body && req.body.client_secret };
+}
+
+/** The confidential first-party OAuth client that is calling, or null. */
+async function clientFrom(req, db) {
+ const given = credentials(req);
+ if (!given || typeof given.id !== 'string' || !given.id || given.id.length > 200) return null;
+ if (devTokens.isAppClient(given.id) || /^lnk_/.test(given.id)) return null;
+ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(given.id);
+ if (!client || dock.isPublicClient(client)) return null;
+ return sameSecret(client.client_secret, given.secret) ? client : null;
+}
+
+/** The exact https origins this client may return to (its registered redirect URIs). */
+function clientOrigins(client) {
+ let list;
+ try { list = JSON.parse(client.redirect_uris || '[]'); } catch { return []; }
+ const out = new Set();
+ for (const value of Array.isArray(list) ? list : []) {
+ try {
+ const u = new URL(String(value));
+ if (u.protocol === 'https:' && !u.username && !u.password && (!u.port || u.port === '443') && u.pathname !== '') out.add(u.origin);
+ } catch { /* not an origin */ }
+ }
+ return [...out];
+}
+
+function originAllowed(client, origin) {
+ return !!origin && origins.isApprovedOrigin(origin) && clientOrigins(client).includes(origin);
+}
+
+const challengeBinding = (clientId, origin, browserBinding) => hash(`ov-related-passkey\n${clientId}\n${origin}\n${browserBinding}`);
+const resultBinding = (clientId, origin) => hash(`ov-related-result\n${clientId}\n${origin}`);
+
+const invalid = res => res.status(401).json({ error: 'invalid_client' });
+const refused = res => res.status(401).json({ error: 'passkey_or_state_invalid' });
+
+function router() {
+ const r = express.Router();
+ // The product server is the caller; the browser never reaches these routes. Per-client, not per address: many
+ // products share Network's loopback address.
+ r.use(rateLimit({ windowMs: 60000, max: 120, standardHeaders: true, legacyHeaders: false, keyGenerator: req => String((req.body && req.body.client_id) || req.ip) }));
+
+ r.post('/options', async (req, res) => {
+ try {
+ const db = req.app.locals.db;
+ const client = await clientFrom(req, db);
+ if (!client) return invalid(res);
+ const origin = origins.originOf(req.body && req.body.origin);
+ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
+ if (String((req.body && req.body.purpose) || 'login') !== 'login') return res.status(400).json({ error: 'unsupported_purpose' });
+ const binding = String((req.body && req.body.binding) || '');
+ if (!BINDING_RE.test(binding)) return res.status(400).json({ error: 'binding_required' });
+ const options = await webauthn.generateAuthenticationOptions({ rpID: RP, userVerification: 'required' });
+ const state = await require('./passkeys').save(db, 'related-login', options.challenge, null, challengeBinding(client.client_id, origin, binding));
+ return res.json({ state, options });
+ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
+ });
+
+ r.post('/verify', async (req, res) => {
+ try {
+ const db = req.app.locals.db;
+ const client = await clientFrom(req, db);
+ if (!client) return invalid(res);
+ const body = req.body || {};
+ const origin = origins.originOf(body.origin);
+ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
+ const binding = String(body.binding || '');
+ if (!BINDING_RE.test(binding) || typeof body.state !== 'string' || !body.state || !body.response || typeof body.response.id !== 'string') {
+ return res.status(400).json({ error: 'invalid_request' });
+ }
+ const bind = challengeBinding(client.client_id, origin, binding);
+ const result = await db.tx(async () => {
+ const challenge = await require('./passkeys').take(db, body.state, 'related-login', bind);
+ if (!challenge) return null;
+ const key = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(body.response.id);
+ if (!key) return null;
+ const user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(key.user_id);
+ if (!liveUser(user)) return null;
+ const factor = await mfa.factor(db, user.id, true);
+ if (!factor || Number(factor.locked_until) > Date.now()) return null;
+ // Re-read after the account lock: removal and concurrent assertions serialize here.
+ const current = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(key.id);
+ if (!current) return null;
+ let checked;
+ try {
+ checked = await webauthn.verifyAuthenticationResponse({
+ response: body.response,
+ expectedChallenge: challenge.challenge,
+ expectedOrigin: origin, // the exact signed origin, never a header
+ expectedRPID: RP,
+ requireUserVerification: true,
+ credential: { id: current.id, publicKey: new Uint8Array(Buffer.from(current.public_key, 'base64url')), counter: Number(current.counter) },
+ });
+ } catch { checked = { verified: false }; }
+ // A discoverable sign-in must present the account's own user handle.
+ const handle = body.response.response && body.response.response.userHandle;
+ if (!checked.verified || !handle || handle !== factor.passkey_handle) {
+ const failures = Number(factor.locked_until) > 0 ? 1 : factor.failures + 1;
+ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? Date.now() + 300000 : 0, user.id);
+ return null;
+ }
+ await db.prepare('UPDATE user_passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?').run(checked.authenticationInfo.newCounter, checked.authenticationInfo.credentialBackedUp, Date.now(), key.id);
+ await db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(user.id);
+ return require('./passkeys').save(db, 'related-result', '', user, resultBinding(client.client_id, origin));
+ });
+ if (!result) return refused(res);
+ return res.json({ result });
+ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
+ });
+
+ r.post('/redeem', async (req, res) => {
+ try {
+ const db = req.app.locals.db;
+ const client = await clientFrom(req, db);
+ if (!client) return invalid(res);
+ const body = req.body || {};
+ const origin = origins.originOf(body.origin);
+ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
+ if (typeof body.result !== 'string' || !body.result) return res.status(400).json({ error: 'invalid_request' });
+ const user = await db.tx(async () => {
+ const challenge = await require('./passkeys').take(db, body.result, 'related-result', resultBinding(client.client_id, origin));
+ if (!challenge || !challenge.user_id) return null;
+ const row = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(challenge.user_id);
+ if (!liveUser(row) || challenge.credential_hash !== hash(row.password_hash)) return null;
+ return row;
+ });
+ if (!user) return refused(res);
+ const oauth = require('./oauth-routes');
+ const pair = await oauth.issueTokenPair(db, req.app.locals.config, req, user, client, {}, 'profile');
+ oauth.recordLinkedService(db, user, client);
+ return res.json({
+ access_token: pair.accessToken, refresh_token: pair.refreshToken, token_type: 'Bearer', expires_in: pair.expiresIn,
+ user: { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color },
+ });
+ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
+ });
+
+ return r;
+}
+
+module.exports = { router, clientOrigins, originAllowed, RP };
@@ -36,7 +36,8 @@ async function authorize(db, req) {
@@ -36,7 +36,8 @@ async function authorize(db, req) {
}
}
function router({ requireAuth, signToken, sanitizeUser }) {
function router({ requireAuth, signToken, sanitizeUser }) {
const r = express.Router();
const r = express.Router();
- r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false }));
+ // The server-to-server related routes have their own per-client limit (one address for every product).
+ r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false, skip: req => String(req.path).startsWith('/related/') }));
r.use((req, res, next) => {
r.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
if (req.method !== 'GET' && !req.is('application/json')) return res.status(415).json({ error: 'JSON required' });
if (req.method !== 'GET' && !req.is('application/json')) return res.status(415).json({ error: 'JSON required' });
@@ -103,6 +104,9 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -103,6 +104,9 @@ function router({ requireAuth, signToken, sanitizeUser }) {
res.json({ token, user: sanitizeUser(result.user) });
res.json({ token, user: sanitizeUser(result.user) });
}
}
r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));
r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));
+ // Cross-site passkeys (Related Origin Requests): a product's server, authenticated as its OAuth
+ // client, gets and verifies one ceremony. Mounted before requireAuth: the client is the caller.
+ r.use('/related', require('./passkeys-related').router());
r.post('/login/verify', route((req, res, db) => verify(req, res, db, 'login')));
r.post('/login/verify', route((req, res, db) => verify(req, res, db, 'login')));
r.use(requireAuth);
r.use(requireAuth);
r.get('/', route(async (req, res, db) => res.json({ available: await mfa.allowed(db, req.user), keys: (await list(db, req.user.id)).map(({ id, name, created_at, last_used_at, device_type, backed_up }) => ({ id, name, created_at, last_used_at, device_type, backed_up })) })));
r.get('/', route(async (req, res, db) => res.json({ available: await mfa.allowed(db, req.user), keys: (await list(db, req.user.id)).map(({ id, name, created_at, last_used_at, device_type, backed_up }) => ({ id, name, created_at, last_used_at, device_type, backed_up })) })));
@@ -177,4 +181,4 @@ async function invalidate(db, userId) {
@@ -177,4 +181,4 @@ async function invalidate(db, userId) {
await db.prepare('UPDATE user_mfa SET generation = ?, pending_secret_ciphertext = NULL, pending_until = 0 WHERE user_id = ?').run(random(), userId);
await db.prepare('UPDATE user_mfa SET generation = ?, pending_secret_ciphertext = NULL, pending_until = 0 WHERE user_id = ?').run(random(), userId);
for (const t of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(userId);
for (const t of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(userId);
}
}
-module.exports = { router, list, authorize };
+module.exports = { router, list, authorize, save, take, hash };
@@ -1190,6 +1190,12 @@ require('./auth/app-links').mount(app);
@@ -1190,6 +1190,12 @@ require('./auth/app-links').mount(app);
const fedcm = require('./auth/fedcm');
const fedcm = require('./auth/fedcm');
const ctx = () => ({ db, publicKey, privateKey, config });
const ctx = () => ({ db, publicKey, privateKey, config });
app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
+ // WebAuthn Related Origin Requests: the exact product origins allowed to use Network's passkeys.
+ app.get('/.well-known/webauthn', (_req, res) => {
+ res.set('Content-Type', 'application/json; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=300');
+ res.send(JSON.stringify({ origins: require('./webauthn-origins').origins() }));
+ });
// OpenID Connect discovery at the issuer's root (and RFC 8414's name for it): server/auth/oidc.js
// OpenID Connect discovery at the issuer's root (and RFC 8414's name for it): server/auth/oidc.js
require('./auth/oidc').mount(app);
require('./auth/oidc').mount(app);
app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
function isPublicDiscoveryPath(p) {
function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
if (path === '/.well-known/openvibe') return true;
+ if (path === '/.well-known/webauthn') return true; // the browser's Related Origin Requests check
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * The origins a browser may use Network's passkeys from (WebAuthn Related Origin Requests,
+ * https://openvibe.network/.well-known/webauthn). The list is generated from the service manifests:
+ * a service that declares a public UI origin is an operator-owned product origin. Infrastructure
+ * hosts (api., auth., ingest., s3., themes.) and the staff aliases are not public UI origins, and a
+ * customer-controlled subdomain (a Website or Host tenant) is never added: the manifests carry the
+ * product's own host, not its tenants. A small reviewed alias list adds the operator-owned UI hosts
+ * that are product aliases rather than a manifest's publicOrigin.
+ *
+ * The rpID is openvibe.network and the credential store is unchanged. Listing an origin only lets a
+ * browser run a ceremony there; the Network still verifies the exact origin and the OAuth client's
+ * registered redirect origin before it issues or accepts a challenge.
+ */
+const contracts = require('openvibe-contracts');
+
+// Operator-owned UI aliases that are not a manifest publicOrigin (reviewed 2026-11-11).
+const ALIASES = Object.freeze([
+ 'https://openvibe.my', // Inventory's mounted account pages
+ 'https://openvibe.link', // Link's account pages
+ 'https://openvibe.party', // Chat's party rooms
+ 'https://openvibe.download', // Media Hub's download site
+ 'https://openvibe.pics', // Media Hub's pictures site
+ 'https://openvibe.homes', // Rent's homes site
+]);
+const TENANT_SUFFIX = '.openvibe.website';
+let cached = null;
+
+function originOf(value) {
+ let u;
+ try { u = new URL(String(value || '')); } catch { return null; }
+ if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || u.pathname !== '/') return null;
+ if (u.port && u.port !== '443') return null;
+ return `https://${u.hostname}`;
+}
+
+function generatedOrigins(manifests) {
+ const out = new Set();
+ for (const m of manifests || []) {
+ const exposure = (m && m.exposure) || {};
+ if (exposure.state !== 'live' || exposure.publicSite !== 'service') continue;
+ const origin = originOf(m.publicOrigin);
+ if (!origin) continue;
+ const host = new URL(origin).hostname;
+ // Customer sites on the Website product are subdomains, and its own apex is cookie-free tenant content.
+ if (host === 'openvibe.website' || host.endsWith(TENANT_SUFFIX)) continue;
+ out.add(origin);
+ }
+ for (const alias of ALIASES) out.add(alias);
+ return [...out].sort();
+}
+
+/** Every exact https origin allowed to run a Network passkey ceremony. Frozen for the process. */
+function origins() {
+ if (!cached) cached = Object.freeze(generatedOrigins(contracts.services && contracts.services.manifests));
+ return cached;
+}
+
+function isApprovedOrigin(origin) {
+ return typeof origin === 'string' && origins().includes(origin);
+}
+
+module.exports = { origins, isApprovedOrigin, originOf, generatedOrigins };
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+// Cross-site passkeys (Related Origin Requests): real ES256 assertions for a product origin, the
+// published origin list, and every refusal the design names. Verification is never stubbed.
+const assert = require('assert');
+const crypto = require('crypto');
+const express = require('express');
+const bcrypt = require('bcryptjs');
+const cookieParser = require('cookie-parser');
+const { getDb } = require('../server/db/database');
+const auth = require('../server/auth/routes');
+const { authenticator } = require('./helpers/passkey-authenticator');
+const webauthnOrigins = require('../server/webauthn-origins');
+
+const basic = (id, secret) => 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64');
+
+(async () => {
+ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');
+ const db = getDb();
+ const pw = 'test-only-password';
+ const aliceId = 91, bobId = 92;
+ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)')
+ .run(aliceId, 'related_alice', bcrypt.hashSync(pw, 4), bobId, 'related_bob', bcrypt.hashSync(pw, 4));
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+ const app = express(); app.use(express.json()); app.use(cookieParser()); Object.assign(app.locals, { db, config, ...keys }); app.use('/api/auth', auth);
+ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.on('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
+ const alice = await db.prepare('SELECT * FROM users WHERE id = ?').get(aliceId);
+ const at = auth.signToken(alice, keys.privateKey, config);
+ const call = async (path, body, authHeader, cookies) => {
+ const res = await fetch(base + path, {
+ method: body === undefined ? 'GET' : 'POST',
+ headers: { 'content-type': 'application/json', ...(authHeader ? { authorization: authHeader } : {}), ...(cookies ? { cookie: cookies } : {}) },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ return { status: res.status, body: await res.json(), cookies: res.headers.getSetCookie() };
+ };
+ const cookieOf = (r, name = '__Host-ov_passkey') => r.cookies.find(s => s.startsWith(name + '='))?.split(';')[0];
+ const related = (path, body, creds = basic('blog-related', 'blog-secret')) => call('/passkeys/related/' + path, body, creds);
+ const origin = 'https://openvibe.blog';
+ const binding = crypto.randomBytes(32).toString('base64url');
+ const reset = async (id) => db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(id);
+ const assertionFor = async (device, state, overrides = {}, bind = binding) => {
+ const r = await related('options', { origin, binding: bind, purpose: 'login' });
+ assert.equal(r.status, 200, 'related options');
+ const opts = r.body.options;
+ return related('verify', { origin, binding: bind, state: r.body.state, response: device.assert(opts, { origin, rp: 'openvibe.network', ...overrides }) });
+ };
+ try {
+ // ── the published list ─────────────────────────────────────
+ const list = webauthnOrigins.origins();
+ for (const want of ['https://openvibe.network', 'https://openvibe.blog', 'https://openvibe.live', 'https://openvibe.my', 'https://openre.stream', 'https://open.shoes']) {
+ assert.ok(list.includes(want), `well-known lists ${want}`);
+ }
+ for (const no of ['https://openvibe.website', 'https://maps.openvibe.tools', 'https://api.openvibe.network', 'https://s3.openvibe.media', 'https://evil.example']) {
+ assert.ok(!list.includes(no), `well-known excludes ${no}`);
+ }
+ assert.ok(list.every(o => /^https:\/\/[a-z0-9.-]+$/.test(o)), 'only exact https origins');
+ assert.ok(webauthnOrigins.isApprovedOrigin(origin));
+ const generated = webauthnOrigins.generatedOrigins([
+ { id: 'a', publicOrigin: 'https://openvibe.a', exposure: { state: 'live', publicSite: 'service' } },
+ { id: 'b', publicOrigin: 'https://openvibe.website', exposure: { state: 'live', publicSite: 'service' } },
+ { id: 'c', publicOrigin: 'https://openvibe.c', exposure: { state: 'internal', publicSite: 'service' } },
+ { id: 'd', publicOrigin: 'http://openvibe.d', exposure: { state: 'live', publicSite: 'service' } },
+ ]);
+ assert.ok(generated.includes('https://openvibe.a'));
+ for (const no of ['https://openvibe.website', 'https://openvibe.c', 'http://openvibe.d']) assert.ok(!generated.includes(no), no);
+
+ // ── fixtures: two confidential clients and one public, one unregistered origin ──
+ const addClient = (id, secret, redirect, isPublic = 0) => db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public) VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, redirect_uris = EXCLUDED.redirect_uris, is_public = EXCLUDED.is_public")
+ .run(id, secret, id, JSON.stringify(redirect), isPublic);
+ await addClient('blog-related', 'blog-secret', ['https://openvibe.blog/auth/callback']);
+ await addClient('blog-other', 'other-secret', ['https://openvibe.blog/auth/callback']);
+ await addClient('live-related', 'live-secret', ['https://openvibe.live/auth/callback']);
+ await addClient('unknown-origin', 'unknown-secret', ['https://evil.example/auth/callback']);
+ await addClient('public-related', '', ['https://openvibe.blog/auth/callback'], 1);
+ await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId));
+
+ // ── enroll a real credential for Alice through the existing Network flow ──
+ await db.prepare("INSERT INTO site_settings (key, value) VALUES ('mfa_enrollment', 'all') ON CONFLICT (key) DO UPDATE SET value = 'all'").run();
+ const device = authenticator(), bobDevice = authenticator();
+ for (const [who, token, dev] of [[alice, at, device], [await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), auth.signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), keys.privateKey, config), bobDevice]]) {
+ const start = await call('/passkeys/register/options', { password: pw }, 'Bearer ' + token); assert.equal(start.status, 200);
+ const done = await call('/passkeys/register/verify', { state: start.body.state, response: dev.register(start.body.options), name: 'Related test key' }, 'Bearer ' + token);
+ assert.equal(done.status, 200, 'real registration for the related tests');
+ }
+
+ // ── a real assertion for the product origin, then its single-use redeem ──
+ const first = await related('options', { origin, binding, purpose: 'login' });
+ assert.equal(first.status, 200); assert.equal(first.body.options.rpId, 'openvibe.network');
+ const verified = await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) });
+ if (verified.status !== 200) console.error('related verify', verified.status, JSON.stringify(verified.body)); assert.equal(verified.status, 200, 'real ES256 assertion for https://openvibe.blog'); assert.match(verified.body.result, /^[A-Za-z0-9_-]{20,}$/);
+ const redeemed = await related('redeem', { origin, result: verified.body.result });
+ assert.equal(redeemed.status, 200, 'single-use result redeems'); assert.ok(redeemed.body.access_token && redeemed.body.refresh_token);
+ const claims = JSON.parse(Buffer.from(redeemed.body.access_token.split('.')[1], 'base64url').toString());
+ assert.equal(claims.sub, aliceId); assert.equal(claims.client_id, 'blog-related'); assert.ok([].concat(claims.aud).includes('openvibe.network'));
+ assert.equal(redeemed.body.user.username, 'related_alice');
+ assert.equal((await related('redeem', { origin, result: verified.body.result })).status, 401, 'result replay');
+ assert.equal((await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) })).status, 401, 'challenge replay');
+
+ // ── concurrent redemption: exactly one winner ──
+ const secondOptions = await related('options', { origin, binding });
+ const secondVerified = await related('verify', { origin, binding, state: secondOptions.body.state, response: device.assert(secondOptions.body.options, { origin }) });
+ assert.equal(secondVerified.status, 200);
+ const race = await Promise.all([1, 2].map(() => related('redeem', { origin, result: secondVerified.body.result })));
+ assert.deepEqual(race.map(r => r.status).sort(), [200, 401], 'atomic result redemption');
+
+ // ── refusals ───────────────────────────────────────────────
+ assert.equal((await related('options', { origin, binding }, basic('blog-related', 'wrong'))).status, 401, 'wrong client secret');
+ assert.equal((await related('options', { origin, binding }, basic('public-related', ''))).status, 401, 'public client refused');
+ assert.equal((await related('options', { origin: 'https://evil.example', binding })).status, 403, 'unpublished origin');
+ assert.equal((await related('options', { origin: 'https://openvibe.live', binding })).status, 403, 'origin not registered to this client');
+ assert.equal((await related('options', { origin, binding }, basic('unknown-origin', 'unknown-secret'))).status, 403, 'registered but unpublished origin');
+ assert.equal((await related('options', { origin, binding, purpose: 'reauth' })).status, 400, 'only a login ceremony');
+ assert.equal((await related('options', { origin, binding: '' })).status, 400, 'browser binding required');
+ assert.equal((await related('verify', { origin, binding: 'x'.repeat(43), state: 'nope', response: {} })).status, 400, 'malformed body refused');
+ let o = await related('options', { origin, binding });
+ assert.equal((await related('verify', { origin, binding: crypto.randomBytes(32).toString('base64url'), state: o.body.state, response: device.assert(o.body.options, { origin }) })).status, 401, 'wrong browser binding');
+ assert.equal((await related('verify', { origin: 'https://openvibe.live', binding, state: o.body.state, response: {} })).status, 403, 'borrowed state on another origin');
+ for (const bad of [{ origin, rp: 'evil.example' }, { origin, flags: 0x41 }, { origin, wrongHandle: true }]) {
+ o = await related('options', { origin, binding });
+ const refused = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, bad) });
+ assert.equal(refused.status, 401, `refused assertion ${JSON.stringify(Object.keys(bad))}`);
+ }
+ o = await related('options', { origin, binding });
+ const expired = { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin }) };
+ await db.prepare("UPDATE passkey_challenges SET expires_at = 0 WHERE purpose = 'related-login'").run();
+ assert.equal((await related('verify', expired)).status, 401, 'expired challenge');
+ const third = await related('options', { origin, binding });
+ const thirdVerified = await related('verify', { origin, binding, state: third.body.state, response: device.assert(third.body.options, { origin }) });
+ assert.equal(thirdVerified.status, 200);
+ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('blog-other', 'other-secret'))).status, 401, 'another client cannot redeem');
+ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('live-related', 'live-secret'))).status, 403, 'a client of another origin cannot even ask');
+ assert.equal((await related('redeem', { origin: 'https://openvibe.live', result: thirdVerified.body.result })).status, 403, 'another origin cannot redeem');
+
+ // ── account state: revoked/merged, and a pending password for someone else ──
+ await reset(aliceId); await reset(bobId);
+ const pending = cookieOf(await call('/login', { username: 'related_alice', password: pw }), '__Host-ov_mfa_pending');
+ assert.ok(pending, 'Alice has a pending password challenge');
+ o = await related('options', { origin, binding });
+ const bobVerified = await related('verify', { origin, binding, state: o.body.state, response: bobDevice.assert(o.body.options, { origin }) });
+ assert.equal(bobVerified.status, 200);
+ const bobRedeemed = await related('redeem', { origin, result: bobVerified.body.result });
+ assert.equal(bobRedeemed.status, 200);
+ assert.equal(JSON.parse(Buffer.from(bobRedeemed.body.access_token.split('.')[1], 'base64url').toString()).sub, bobId, 'a pending password for Alice cannot switch the passkey account');
+ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(aliceId, aliceId);
+ assert.equal((await assertionFor(device)).status, 401, 'merged user refused');
+ await db.prepare('UPDATE users SET merged_into = NULL WHERE id = ?').run(aliceId);
+ await reset(aliceId);
+ const after = await assertionFor(device);
+ assert.equal(after.status, 200, 'a merged flag removed lets the account use its key again');
+
+ // The real verifier checked the signed origin: a valid Network-origin assertion is refused for this flow.
+ o = await related('options', { origin, binding });
+ const networkOrigin = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin: 'https://openvibe.network' }) });
+ assert.equal(networkOrigin.status, 401, 'Network-origin assertion is not a product-origin assertion');
+
+ console.log('Related passkeys: published/read-only origins, real ES256 at https://openvibe.blog, RP/origin/UV/user-handle refusals, expiry, replay, concurrent redemption, client/origin binding, merged users and pending-password isolation passed');
+ } finally { server.close(); await globalThis.__ovNetworkTestDbClose?.(); }
+})().catch(err => {
+ console.error('Related passkey tests failed (assertion details redacted)');
+ console.error(String(err.stack).split('\n').filter(line => /^\s+at /.test(line)).slice(0, 4).join('\n'));
+ process.exitCode = 1;
+});