openvibers/openvibe.network Commits
One-time cross-site sign-in handoff tickets (#797)
Changes
nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- One-time cross-site sign-in handoff tickets (signin arrival part 3): a signed-in person's++-- source site mints a short-lived opaque ticket for one destination OAuth client, and the++-- destination redeems it server-to-server for a standard authorization code. Only the SHA-256++-- hash is stored; redemption is atomic single-use. Additive: older releases never read this table.++CREATE TABLE IF NOT EXISTS handoff_tickets (++ ticket_hash text COLLATE "C" PRIMARY KEY,++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ client_id text COLLATE "C" NOT NULL REFERENCES oauth_clients(client_id),++ redirect_uri text COLLATE "C" NOT NULL,++ scope text COLLATE "C" DEFAULT 'profile theme',++ source text COLLATE "C",++ issued_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ used bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE INDEX IF NOT EXISTS handoff_tickets_user_idx ON handoff_tickets (user_id);nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++/**++ * One-time cross-site sign-in handoff tickets (signin arrival part 3).++ *++ * POST /api/v1/handoff/tickets → 200 { ticket, expires_at, expires_in, client_id }++ * POST /api/v1/handoff/redeem → 200 { code, expires_in }++ *++ * The source site's server mints a ticket for its signed-in person (the person's own Bearer++ * token, never a cookie a hostile page could send), puts it on a link to another OpenVibe site,++ * and the destination's server redeems it for a standard OAuth authorization code, which it++ * exchanges at /oauth/token through the unchanged grant path (client_secret + registered++ * redirect_uri + single-use code). The browser makes no extra round trip: the destination sets++ * its own session cookie while serving the first navigation, so the page renders signed in on++ * its first paint.++ *++ * Security properties:++ * - Only the SHA-256 hash is stored; the ticket itself (`ovh_` + 32 random bytes) is high++ * entropy and never logged (POST bodies are not access-logged; never put it in a query string++ * server-side).++ * - 60 s expiry, atomic single-use (`UPDATE ... WHERE used = 0`), bound to the exact destination++ * client_id AND redirect_uri (both must match the client's registered redirect_uris).++ * - Redeem refuses banned/merged/deleted accounts and accounts whose tokens were revoked after++ * the ticket was issued (password change / sign-out-everywhere).++ * - Audience checks are unchanged: the code is redeemed for the destination client's own++ * audience-scoped session through the existing token endpoint.++ *++ * Integration contract for destinations (enforced there, not here):++ * - Never redeem when the destination already has a session (no silent account switch), and++ * never redeem when the browser carries the destination's own guest hint (a guest stays a++ * guest with no round trip at all).++ * - Known residual: a bearer ticket is redeemable by whichever browser presents it first, so a++ * fresh ticket link pasted into a stranger's address bar within its 60 s life signs that++ * browser into the ticket owner's account on that one site. The 60 s life, single use, exact++ * destination binding and never-switch rule make a real-time targeted trick the only vector;++ * a browser-transaction binding is future hardening (see signin-handoff-design.md).++ *++ * 401 no session (issue) / bad ticket or client credentials (redeem)++ * 403 guest sessions cannot mint tickets++ * 503 no such destination client or unregistered redirect_uri is 400, not 503++ */++++const crypto = require('crypto');++const express = require('express');++++const TTL_S = 60;++const CODE_TTL_S = 5 * 60;++const TICKET_PREFIX = 'ovh_';++++function hashTicket(ticket) {++ return crypto.createHash('sha256').update(String(ticket || ''), 'utf8').digest('hex');++}++++/** Mint a ticket for `userId` bound to one destination. → { ticket, ticketHash, issuedAt, expiresAt } */++function mintTicket({ userId, now = Date.now() } = {}) {++ if (!Number.isInteger(userId)) throw new Error('handoff ticket: a numeric user id is required');++ const ticket = `${TICKET_PREFIX}${crypto.randomBytes(32).toString('base64url')}`;++ const issuedAt = new Date(now).toISOString();++ const expiresAt = new Date(now + TTL_S * 1000).toISOString();++ return { ticket, ticketHash: hashTicket(ticket), issuedAt, expiresAt };++}++++/** Issue a standard single-use authorization code row for the redeemed ticket. → { code, expiresAt } */++async function issueHandoffCode(db, { clientId, userId, redirectUri, scope }) {++ const code = crypto.randomBytes(32).toString('hex');++ const expiresAt = new Date(Date.now() + CODE_TTL_S * 1000).toISOString();++ await db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)++ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt);++ return { code, expiresAt };++}++++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 parseTime(v) {++ const s = String(v || '');++ if (!s) return NaN;++ return new Date(s + (s.includes('Z') ? '' : 'Z')).getTime();++}++++function registeredRedirect(allowedUris, redirectUri) {++ const want = String(redirectUri || '').toLowerCase();++ return Array.isArray(allowedUris) && allowedUris.some(u => String(u).toLowerCase() === want);++}++++/** The redeem router. `requireAuth` is the session guard (user Bearer token or first-party cookie). */++function router({ db, requireAuth }) {++ const r = express.Router();++ const getDb = async () => (typeof db?.prepare === 'function' ? db : await db);++++ // The ticket mint: the person's own source site calls with the person's Bearer token.++ r.post('/tickets', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try {++ const database = await getDb();++ const user = req.user;++ if (!user || user.is_anon) return res.status(403).json({ code: 'handoff.guest', error: 'sign in with an account to mint a handoff ticket' });++ const clientId = String(req.body?.client_id || '');++ const redirectUri = String(req.body?.redirect_uri || '');++ const scope = String(req.body?.scope || 'profile theme').slice(0, 200);++ const source = String(req.body?.source || '').slice(0, 200) || null;++ if (!clientId || !redirectUri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });++ const client = await database.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);++ if (!client) return res.status(400).json({ error: 'invalid_request', error_description: 'Unknown destination client' });++ if (!registeredRedirect(JSON.parse(client.redirect_uris || '[]'), redirectUri)) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });++ }++ const { ticket, ticketHash, issuedAt, expiresAt } = mintTicket({ userId: user.id });++ await database.prepare(`INSERT INTO handoff_tickets (ticket_hash, user_id, client_id, redirect_uri, scope, source, issued_at, expires_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(ticketHash, user.id, clientId, redirectUri, scope, source, issuedAt, expiresAt);++ res.json({ ticket, expires_at: expiresAt, expires_in: TTL_S, client_id: clientId });++ } catch (err) {++ res.status(500).json({ error: 'handoff issue failed' });++ }++ });++++ // The destination's server redeems with its OAuth client credentials. No browser session needed.++ r.post('/redeem', async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try {++ const database = await getDb();++ const { ticket, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri } = req.body || {};++ if (!ticket || !clientId || !clientSecret || !redirectUri) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'ticket, client_id, client_secret and redirect_uri are required' });++ }++ const client = await database.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId));++ if (!client || !sameSecret(client.client_secret, clientSecret)) {++ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });++ }++ const presented = String(ticket || '');++ if (!presented.startsWith(TICKET_PREFIX) || presented.length > 200) {++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });++ }++ const row = await database.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(presented));++ if (!row || row.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });++ if (row.client_id !== String(clientId)) return res.status(400).json({ error: 'invalid_grant', error_description: 'Ticket is for another client' });++ if (String(row.redirect_uri).toLowerCase() !== String(redirectUri).toLowerCase()) {++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' });++ }++ if (Date.now() > parseTime(row.expires_at)) {++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket expired' });++ }++ const user = await database.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);++ if (!user || user.is_banned || user.merged_into || user.deleted_at) {++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Account is no longer valid' });++ }++ // A password change or sign-out-everywhere after issuance revokes outstanding tickets.++ if (user.token_valid_after && parseTime(row.issued_at) < parseTime(user.token_valid_after)) {++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket revoked' });++ }++ // Atomic single-use: two concurrent redeems cannot both succeed.++ const consumed = await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ? AND used = 0').run(row.ticket_hash);++ if (consumed.changes !== 1) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });++ const { code } = await issueHandoffCode(database, { clientId: client.client_id, userId: user.id, redirectUri: String(redirectUri), scope: row.scope });++ res.json({ code, expires_in: CODE_TTL_S });++ } catch (err) {++ res.status(500).json({ error: 'handoff redeem failed' });++ }++ });++++ return r;++}++++module.exports = { router, mintTicket, issueHandoffCode, hashTicket, TTL_S, CODE_TTL_S, TICKET_PREFIX };@@@ -690,6 +690,12 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.blocapp.use('/api/v1/realtime', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/realtime-ticket').router({db, requireAuth: requireDock('activity'), privateKey, issuer: config.jwt.issuer, streamUrl: process.env.OV_EVENTS_PUBLIC_URL,}));++// One-time cross-site sign-in handoff tickets (server/auth/handoff.js): a source site mints a++// 60 s ticket for one destination client, the destination redeems it for an authorization code.++// Plain user sessions only (never a dock-scoped grant): a ticket signs a browser in.++app.use('/api/v1/handoff', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/handoff').router({++ db, requireAuth,++}));// ── Routes ───────────────────────────────────────────────────// Public key endpoint (services fetch this to verify JWTs).@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++// One-time cross-site sign-in handoff tickets: issue → redeem → standard code exchange.++// ov test test/handoff.test.js (or: node test/handoff.test.js)++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { getDb } = require('../server/db/database');++const { hashTicket } = require('../server/auth/handoff');++++(async () => {++const db = getDb();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();++await db.prepare("INSERT INTO users (id, username, password_hash, is_banned) VALUES (8, 'banned1', 'x', 1)").run();++await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (9, 'guest1', 'x', 1)").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (10, 'revoked1', 'x')").run();++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++const requireAuth = require('../server/auth/session').makeRequireAuth(++ async () => ({ db, publicKey: keys.publicKey, config: app.locals.config }),++ require('../server/auth/routes').signToken);++app.use('/api/v1/handoff', require('../server/auth/handoff').router({ db, requireAuth }));++app.use('/oauth', require('../server/auth/oauth-routes'));++const server = http.createServer(app);++++const REDIRECT = 'https://openvibe.live/api/auth/callback';++const tokenFor = (id, name) => jwt.sign({ sub: id, id, username: name }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const post = (p, body, token) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) })++ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));++ const userToken = tokenFor(7, 'viewer');++++ // No session mints nothing.++ let r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 401, 'issue without auth is 401');++++ // Banned accounts and guests cannot mint.++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(8, 'banned1'));++ assert.strictEqual(r.status, 403, 'banned user cannot mint');++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(9, 'guest1'));++ assert.strictEqual(r.status, 403, 'guest cannot mint');++++ // Unknown client and unregistered redirect are refused.++ r = await post('/api/v1/handoff/tickets', { client_id: 'nope', redirect_uri: REDIRECT }, userToken);++ assert.strictEqual(r.status, 400, 'unknown destination client is 400');++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: 'https://evil.example/cb' }, userToken);++ assert.strictEqual(r.status, 400, 'unregistered redirect_uri is 400');++++ // A ticket mints: opaque, short-lived, stored hashed only.++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, userToken);++ assert.strictEqual(r.status, 200, 'issue is 200');++ assert.ok(r.body.ticket.startsWith('ovh_'), 'ticket carries its prefix');++ assert.strictEqual(r.body.expires_in, 60);++ const row = await db.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(r.body.ticket));++ assert.ok(row && !row.used, 'ticket row stored hashed');++ assert.strictEqual(row.client_id, 'live');++ const dump = await db.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(r.body.ticket));++ assert.ok(!JSON.stringify(dump).includes(r.body.ticket), 'plaintext ticket never stored');++++ // Redeem refuses a bad client secret, then yields a standard code on the exact binding.++ const ticket = r.body.ticket;++ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'wrong', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 401, 'wrong client secret is 401');++ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'tools', client_secret: 'tools-secret', redirect_uri: 'https://openvibe.tools/auth/callback' });++ assert.strictEqual(r.status, 400, 'another client cannot redeem a live ticket');++ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 200, 'redeem is 200');++ assert.ok(/^[0-9a-f]{64}$/.test(r.body.code), 'redeem yields a code');++ const code = r.body.code;++++ // The code exchanges through the unchanged token endpoint for the destination's session.++ r = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 200, 'code exchanges for tokens');++ assert.ok(r.body.access_token, 'access token issued');++ assert.strictEqual(r.body.user.username, 'viewer');++++ // Replay is dead, and expiry is enforced.++ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 400, 'used ticket is refused');++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, userToken);++ const stale = r.body.ticket;++ await db.prepare("UPDATE handoff_tickets SET expires_at = '2000-01-01T00:00:00.000Z' WHERE ticket_hash = ?").run(hashTicket(stale));++ r = await post('/api/v1/handoff/redeem', { ticket: stale, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 400, 'expired ticket is refused');++++ // A password change (token_valid_after past issuance... here set after) revokes outstanding tickets.++ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(10, 'revoked1'));++ const revoked = r.body.ticket;++ await db.prepare("UPDATE users SET token_valid_after = '2999-01-01 00:00:00' WHERE id = 10").run();++ r = await post('/api/v1/handoff/redeem', { ticket: revoked, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });++ assert.strictEqual(r.status, 400, 'post-issuance revocation is refused');++++ console.log('handoff.test.js: all assertions passed');++ server.close();++ process.exit(0);++})().catch(e => { console.error(e); server.close(); process.exit(1); });++})();@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- One-time cross-site sign-in handoff tickets (signin arrival part 3): a signed-in person's
+-- source site mints a short-lived opaque ticket for one destination OAuth client, and the
+-- destination redeems it server-to-server for a standard authorization code. Only the SHA-256
+-- hash is stored; redemption is atomic single-use. Additive: older releases never read this table.
+CREATE TABLE IF NOT EXISTS handoff_tickets (
+ ticket_hash text COLLATE "C" PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ client_id text COLLATE "C" NOT NULL REFERENCES oauth_clients(client_id),
+ redirect_uri text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" DEFAULT 'profile theme',
+ source text COLLATE "C",
+ issued_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ used bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE INDEX IF NOT EXISTS handoff_tickets_user_idx ON handoff_tickets (user_id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * One-time cross-site sign-in handoff tickets (signin arrival part 3).
+ *
+ * POST /api/v1/handoff/tickets → 200 { ticket, expires_at, expires_in, client_id }
+ * POST /api/v1/handoff/redeem → 200 { code, expires_in }
+ *
+ * The source site's server mints a ticket for its signed-in person (the person's own Bearer
+ * token, never a cookie a hostile page could send), puts it on a link to another OpenVibe site,
+ * and the destination's server redeems it for a standard OAuth authorization code, which it
+ * exchanges at /oauth/token through the unchanged grant path (client_secret + registered
+ * redirect_uri + single-use code). The browser makes no extra round trip: the destination sets
+ * its own session cookie while serving the first navigation, so the page renders signed in on
+ * its first paint.
+ *
+ * Security properties:
+ * - Only the SHA-256 hash is stored; the ticket itself (`ovh_` + 32 random bytes) is high
+ * entropy and never logged (POST bodies are not access-logged; never put it in a query string
+ * server-side).
+ * - 60 s expiry, atomic single-use (`UPDATE ... WHERE used = 0`), bound to the exact destination
+ * client_id AND redirect_uri (both must match the client's registered redirect_uris).
+ * - Redeem refuses banned/merged/deleted accounts and accounts whose tokens were revoked after
+ * the ticket was issued (password change / sign-out-everywhere).
+ * - Audience checks are unchanged: the code is redeemed for the destination client's own
+ * audience-scoped session through the existing token endpoint.
+ *
+ * Integration contract for destinations (enforced there, not here):
+ * - Never redeem when the destination already has a session (no silent account switch), and
+ * never redeem when the browser carries the destination's own guest hint (a guest stays a
+ * guest with no round trip at all).
+ * - Known residual: a bearer ticket is redeemable by whichever browser presents it first, so a
+ * fresh ticket link pasted into a stranger's address bar within its 60 s life signs that
+ * browser into the ticket owner's account on that one site. The 60 s life, single use, exact
+ * destination binding and never-switch rule make a real-time targeted trick the only vector;
+ * a browser-transaction binding is future hardening (see signin-handoff-design.md).
+ *
+ * 401 no session (issue) / bad ticket or client credentials (redeem)
+ * 403 guest sessions cannot mint tickets
+ * 503 no such destination client or unregistered redirect_uri is 400, not 503
+ */
+
+const crypto = require('crypto');
+const express = require('express');
+
+const TTL_S = 60;
+const CODE_TTL_S = 5 * 60;
+const TICKET_PREFIX = 'ovh_';
+
+function hashTicket(ticket) {
+ return crypto.createHash('sha256').update(String(ticket || ''), 'utf8').digest('hex');
+}
+
+/** Mint a ticket for `userId` bound to one destination. → { ticket, ticketHash, issuedAt, expiresAt } */
+function mintTicket({ userId, now = Date.now() } = {}) {
+ if (!Number.isInteger(userId)) throw new Error('handoff ticket: a numeric user id is required');
+ const ticket = `${TICKET_PREFIX}${crypto.randomBytes(32).toString('base64url')}`;
+ const issuedAt = new Date(now).toISOString();
+ const expiresAt = new Date(now + TTL_S * 1000).toISOString();
+ return { ticket, ticketHash: hashTicket(ticket), issuedAt, expiresAt };
+}
+
+/** Issue a standard single-use authorization code row for the redeemed ticket. → { code, expiresAt } */
+async function issueHandoffCode(db, { clientId, userId, redirectUri, scope }) {
+ const code = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + CODE_TTL_S * 1000).toISOString();
+ await db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt);
+ return { code, expiresAt };
+}
+
+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 parseTime(v) {
+ const s = String(v || '');
+ if (!s) return NaN;
+ return new Date(s + (s.includes('Z') ? '' : 'Z')).getTime();
+}
+
+function registeredRedirect(allowedUris, redirectUri) {
+ const want = String(redirectUri || '').toLowerCase();
+ return Array.isArray(allowedUris) && allowedUris.some(u => String(u).toLowerCase() === want);
+}
+
+/** The redeem router. `requireAuth` is the session guard (user Bearer token or first-party cookie). */
+function router({ db, requireAuth }) {
+ const r = express.Router();
+ const getDb = async () => (typeof db?.prepare === 'function' ? db : await db);
+
+ // The ticket mint: the person's own source site calls with the person's Bearer token.
+ r.post('/tickets', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try {
+ const database = await getDb();
+ const user = req.user;
+ if (!user || user.is_anon) return res.status(403).json({ code: 'handoff.guest', error: 'sign in with an account to mint a handoff ticket' });
+ const clientId = String(req.body?.client_id || '');
+ const redirectUri = String(req.body?.redirect_uri || '');
+ const scope = String(req.body?.scope || 'profile theme').slice(0, 200);
+ const source = String(req.body?.source || '').slice(0, 200) || null;
+ if (!clientId || !redirectUri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
+ const client = await database.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);
+ if (!client) return res.status(400).json({ error: 'invalid_request', error_description: 'Unknown destination client' });
+ if (!registeredRedirect(JSON.parse(client.redirect_uris || '[]'), redirectUri)) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
+ }
+ const { ticket, ticketHash, issuedAt, expiresAt } = mintTicket({ userId: user.id });
+ await database.prepare(`INSERT INTO handoff_tickets (ticket_hash, user_id, client_id, redirect_uri, scope, source, issued_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(ticketHash, user.id, clientId, redirectUri, scope, source, issuedAt, expiresAt);
+ res.json({ ticket, expires_at: expiresAt, expires_in: TTL_S, client_id: clientId });
+ } catch (err) {
+ res.status(500).json({ error: 'handoff issue failed' });
+ }
+ });
+
+ // The destination's server redeems with its OAuth client credentials. No browser session needed.
+ r.post('/redeem', async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try {
+ const database = await getDb();
+ const { ticket, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri } = req.body || {};
+ if (!ticket || !clientId || !clientSecret || !redirectUri) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'ticket, client_id, client_secret and redirect_uri are required' });
+ }
+ const client = await database.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId));
+ if (!client || !sameSecret(client.client_secret, clientSecret)) {
+ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
+ }
+ const presented = String(ticket || '');
+ if (!presented.startsWith(TICKET_PREFIX) || presented.length > 200) {
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
+ }
+ const row = await database.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(presented));
+ if (!row || row.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
+ if (row.client_id !== String(clientId)) return res.status(400).json({ error: 'invalid_grant', error_description: 'Ticket is for another client' });
+ if (String(row.redirect_uri).toLowerCase() !== String(redirectUri).toLowerCase()) {
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
+ }
+ if (Date.now() > parseTime(row.expires_at)) {
+ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket expired' });
+ }
+ const user = await database.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
+ if (!user || user.is_banned || user.merged_into || user.deleted_at) {
+ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Account is no longer valid' });
+ }
+ // A password change or sign-out-everywhere after issuance revokes outstanding tickets.
+ if (user.token_valid_after && parseTime(row.issued_at) < parseTime(user.token_valid_after)) {
+ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket revoked' });
+ }
+ // Atomic single-use: two concurrent redeems cannot both succeed.
+ const consumed = await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ? AND used = 0').run(row.ticket_hash);
+ if (consumed.changes !== 1) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
+ const { code } = await issueHandoffCode(database, { clientId: client.client_id, userId: user.id, redirectUri: String(redirectUri), scope: row.scope });
+ res.json({ code, expires_in: CODE_TTL_S });
+ } catch (err) {
+ res.status(500).json({ error: 'handoff redeem failed' });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { router, mintTicket, issueHandoffCode, hashTicket, TTL_S, CODE_TTL_S, TICKET_PREFIX };
@@ -690,6 +690,12 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -690,6 +690,12 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
app.use('/api/v1/realtime', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/realtime-ticket').router({
app.use('/api/v1/realtime', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/realtime-ticket').router({
db, requireAuth: requireDock('activity'), privateKey, issuer: config.jwt.issuer, streamUrl: process.env.OV_EVENTS_PUBLIC_URL,
db, requireAuth: requireDock('activity'), privateKey, issuer: config.jwt.issuer, streamUrl: process.env.OV_EVENTS_PUBLIC_URL,
}));
}));
+// One-time cross-site sign-in handoff tickets (server/auth/handoff.js): a source site mints a
+// 60 s ticket for one destination client, the destination redeems it for an authorization code.
+// Plain user sessions only (never a dock-scoped grant): a ticket signs a browser in.
+app.use('/api/v1/handoff', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/handoff').router({
+ db, requireAuth,
+}));
// ── Routes ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
// Public key endpoint (services fetch this to verify JWTs).
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+// One-time cross-site sign-in handoff tickets: issue → redeem → standard code exchange.
+// ov test test/handoff.test.js (or: node test/handoff.test.js)
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { getDb } = require('../server/db/database');
+const { hashTicket } = require('../server/auth/handoff');
+
+(async () => {
+const db = getDb();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, is_banned) VALUES (8, 'banned1', 'x', 1)").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (9, 'guest1', 'x', 1)").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (10, 'revoked1', 'x')").run();
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+const requireAuth = require('../server/auth/session').makeRequireAuth(
+ async () => ({ db, publicKey: keys.publicKey, config: app.locals.config }),
+ require('../server/auth/routes').signToken);
+app.use('/api/v1/handoff', require('../server/auth/handoff').router({ db, requireAuth }));
+app.use('/oauth', require('../server/auth/oauth-routes'));
+const server = http.createServer(app);
+
+const REDIRECT = 'https://openvibe.live/api/auth/callback';
+const tokenFor = (id, name) => jwt.sign({ sub: id, id, username: name }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const post = (p, body, token) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) })
+ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+ const userToken = tokenFor(7, 'viewer');
+
+ // No session mints nothing.
+ let r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 401, 'issue without auth is 401');
+
+ // Banned accounts and guests cannot mint.
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(8, 'banned1'));
+ assert.strictEqual(r.status, 403, 'banned user cannot mint');
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(9, 'guest1'));
+ assert.strictEqual(r.status, 403, 'guest cannot mint');
+
+ // Unknown client and unregistered redirect are refused.
+ r = await post('/api/v1/handoff/tickets', { client_id: 'nope', redirect_uri: REDIRECT }, userToken);
+ assert.strictEqual(r.status, 400, 'unknown destination client is 400');
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: 'https://evil.example/cb' }, userToken);
+ assert.strictEqual(r.status, 400, 'unregistered redirect_uri is 400');
+
+ // A ticket mints: opaque, short-lived, stored hashed only.
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, userToken);
+ assert.strictEqual(r.status, 200, 'issue is 200');
+ assert.ok(r.body.ticket.startsWith('ovh_'), 'ticket carries its prefix');
+ assert.strictEqual(r.body.expires_in, 60);
+ const row = await db.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(r.body.ticket));
+ assert.ok(row && !row.used, 'ticket row stored hashed');
+ assert.strictEqual(row.client_id, 'live');
+ const dump = await db.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(r.body.ticket));
+ assert.ok(!JSON.stringify(dump).includes(r.body.ticket), 'plaintext ticket never stored');
+
+ // Redeem refuses a bad client secret, then yields a standard code on the exact binding.
+ const ticket = r.body.ticket;
+ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'wrong', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 401, 'wrong client secret is 401');
+ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'tools', client_secret: 'tools-secret', redirect_uri: 'https://openvibe.tools/auth/callback' });
+ assert.strictEqual(r.status, 400, 'another client cannot redeem a live ticket');
+ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 200, 'redeem is 200');
+ assert.ok(/^[0-9a-f]{64}$/.test(r.body.code), 'redeem yields a code');
+ const code = r.body.code;
+
+ // The code exchanges through the unchanged token endpoint for the destination's session.
+ r = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 200, 'code exchanges for tokens');
+ assert.ok(r.body.access_token, 'access token issued');
+ assert.strictEqual(r.body.user.username, 'viewer');
+
+ // Replay is dead, and expiry is enforced.
+ r = await post('/api/v1/handoff/redeem', { ticket, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 400, 'used ticket is refused');
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, userToken);
+ const stale = r.body.ticket;
+ await db.prepare("UPDATE handoff_tickets SET expires_at = '2000-01-01T00:00:00.000Z' WHERE ticket_hash = ?").run(hashTicket(stale));
+ r = await post('/api/v1/handoff/redeem', { ticket: stale, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 400, 'expired ticket is refused');
+
+ // A password change (token_valid_after past issuance... here set after) revokes outstanding tickets.
+ r = await post('/api/v1/handoff/tickets', { client_id: 'live', redirect_uri: REDIRECT }, tokenFor(10, 'revoked1'));
+ const revoked = r.body.ticket;
+ await db.prepare("UPDATE users SET token_valid_after = '2999-01-01 00:00:00' WHERE id = 10").run();
+ r = await post('/api/v1/handoff/redeem', { ticket: revoked, client_id: 'live', client_secret: 'live-secret', redirect_uri: REDIRECT });
+ assert.strictEqual(r.status, 400, 'post-issuance revocation is refused');
+
+ console.log('handoff.test.js: all assertions passed');
+ server.close();
+ process.exit(0);
+})().catch(e => { console.error(e); server.close(); process.exit(1); });
+})();
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.