openvibers/openvibe.network Commits

One-time cross-site sign-in handoff tickets (#797)

OP OpenVibers committed 2 days ago · ea86dc2c15 · 6 files, +307 −2

* One-time cross-site sign-in handoff tickets (issue + redeem)

* Cover migration 0069 in the migration-list tests

Changes

ddiff --git a/migrations/0071_handoff_tickets.sql b/migrations/0071_handoff_tickets.sql
nnew file mode 100644
iindex 00000000..cfeac5ef
---- /dev/null
++++ b/migrations/0071_handoff_tickets.sql
@@@ -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);
ddiff --git a/server/auth/handoff.js b/server/auth/handoff.js
nnew file mode 100644
iindex 00000000..6e8d3514
---- /dev/null
++++ b/server/auth/handoff.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 5bab83fc..1fa7159c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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({
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).
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 2345dfa8..d76e5762 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/handoff.test.js b/test/handoff.test.js
nnew file mode 100644
iindex 00000000..68db2c5b
---- /dev/null
++++ b/test/handoff.test.js
@@@ -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); });
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex bdca6d52..681c64ac 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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.