From b79ecd593ef92a12e9d4b3070da68e42f9254477 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 13:27:14 -0700 Subject: [PATCH 1/2] Bind email token purpose and revoke prior sessions on password reset --- ...urpose-and-revision-are-auth-boundaries.md | 52 +++ .agents/issues/INDEX.md | 1 + .agents/issues/magic-link-index.md | 1 + .agents/issues/security-audit-index.md | 1 + docs/quality/AUTH_LINK_LIFECYCLE.md | 63 ++++ .../server/src/auth/email-link-handlers.ts | 68 ++++ packages/server/src/auth/sqlite-auth.ts | 107 +++++- packages/server/src/auth/websocket-auth.ts | 24 ++ packages/server/src/index.ts | 102 ++---- packages/server/src/middleware/auth.ts | 18 + packages/server/src/resolvers/sqlite-auth.ts | 12 +- packages/server/src/utils/auth.ts | 5 +- .../server/tests/auth-link-boundary.test.ts | 313 ++++++++++++++++++ packages/server/tests/magic-link.test.ts | 26 +- .../server/tests/production-startup.test.ts | 4 +- 15 files changed, 677 insertions(+), 120 deletions(-) create mode 100644 .agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md create mode 100644 docs/quality/AUTH_LINK_LIFECYCLE.md create mode 100644 packages/server/src/auth/email-link-handlers.ts create mode 100644 packages/server/src/auth/websocket-auth.ts create mode 100644 packages/server/tests/auth-link-boundary.test.ts diff --git a/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md b/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md new file mode 100644 index 00000000..f8e52ee1 --- /dev/null +++ b/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md @@ -0,0 +1,52 @@ +# Bind email link purpose and session revision at the database write + +On 2026-10-27, #184 token work started from the reviewed CAPTCHA composition +`8c9712b7bda0c7515568c08bae710be4bea675a1`, containing dev `32149ee`. +The two production email redemption handlers were extracted without changing +their behavior, then exercised through owned HTTP and real in-memory SQLite. +`npm run test -w @graphdone/server -- tests/auth-link-boundary.test.ts +--maxWorkers=1 --minWorkers=1` failed both negative controls: a reset link +produced a login JWT, and a login link completed password reset with HTTP200. +No token values or deployed credentials are included in this record. + +Cause: both routes consumed the same untyped token, and changing a password +changed neither existing JWT authority nor other outstanding links. A standalone +select/consume followed by password update cannot supply the desired reset +atomicity. Carry purpose, account identity and revision in the issuance record, +and evaluate them with execution-time expiry in the conditional SQLite write. +Increment the account revision with the password and revoke links through a +trigger in the same statement. Compare JWT revision with the current active +account on HTTP and WebSocket boundaries; never refresh a stale authorization +snapshot into the new revision. + +The implementation and migration contract are in +[the authentication lifecycle document](../../docs/quality/AUTH_LINK_LIFECYCLE.md). +Legacy untyped links are invalidated; old signed JWTs map to revision zero and +remain revocable. The existing inactive-account test exposed +`SQLITE_ERROR: no such column: deactivationDate` when it first exercised the +real status method instead of directly changing a row. The migration now adds +that column too, and status mutation can return an inactive profile without +making it authenticatable. + +Intermediate corrections retained in local logs: the new User type initially +missed the bootstrap admin's revision; the old inactive-account fixture issued +a link after disabling the user, which is now rejected at issuance; async +WebCrypto proof solving exceeded a five-second case budget. The test now solves +the same real 100,000-bound SHA-256 challenge with Node's synchronous hash loop, +keeping challenge policy and assertion deadlines unchanged. The signing-key +environment import remains before auth modules after route extraction. + +At the first composed token snapshot, typecheck/lint passed and the full server +suite passed **150/150**, including10 HTTP/SQLite/WebSocket lifecycle cases, +11 existing magic-link cases and9 startup cases. Subsequent strengthened +wrong-purpose/correct-use assertions and inactive request handling require +fresh focused validation; final exact-head review and CI results belong in the +PR discussion. Local logs under `/tmp/graphdone-resolution-20261027/token-*` +retain the initial failures and corrections; the repository record travels with +the fix rather than treating those local files as published knowledge. + +Limits: no live provider/email/Cloud/D1 acceptance or deployment is established. +Existing requests authorized before reset are not rolled back. Idle sockets +are denied at their next operation/result, not claimed to be instantly closed +across API processes. Do not infer verified-provider email or account-linking +safety from token-purpose tests; that remains separate work in #184. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 518852aa..078b0eeb 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| Login and reset links cross-use, or a reset/reactivation revives old sessions | [Bind purpose and session revision at the write](../inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md); real SQLite/HTTP/WebSocket `auth-link-boundary.test.ts` | | Arbitrary client CAPTCHA strings succeed, or direct login/signup omits proof | [Require server-issued action-bound proof and atomic consumption](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); server `captcha` / `captcha-auth` tests and `qc:captcha` | | A dependency fix exists in pending main promotion but dev still fails the audit | [Backport the reviewed patch and refresh target-branch evidence](../inbox/2026-10-27-dev-undici-backport.md); PR #194 | | Main-promotion audit flags Undici through SQLite's optional native-build dependency | [Narrow consumer-verified dependency update](../inbox/2026-10-24-undici-native-build-dependency.md); [security audit index](security-audit-index.md) | diff --git a/.agents/issues/magic-link-index.md b/.agents/issues/magic-link-index.md index 1ad1d38c..15630afa 100644 --- a/.agents/issues/magic-link-index.md +++ b/.agents/issues/magic-link-index.md @@ -2,5 +2,6 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| Login and reset links cross-use, or a reset/reactivation revives old sessions | [Bind purpose and session revision at the write](../inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md); real SQLite/HTTP/WebSocket `auth-link-boundary.test.ts` | | One email token returns identity more than once under concurrent verification | [Guarded token-consumption lesson](../inbox/2026-10-23-magic-link-consumption.md); `packages/server/tests/magic-link.test.ts` | | Token consumption fails but the caller still receives identity, or an inactive account signs in by email | [Correction and failure behavior](../../docs/quality/MAGIC_LINK_REPLAY_2026-10-23.md); real SQLite error/inactive-account regressions | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index fb35fa96..f7230a79 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -2,6 +2,7 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| Login and reset links cross-use, or a reset/reactivation revives old sessions | [Bind purpose and session revision at the write](../inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md); real SQLite/HTTP/WebSocket `auth-link-boundary.test.ts` | | CAPTCHA validates only a client string's format or skips missing GraphQL proof | [Server policy, signed action/expiry and bounded replay](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); `packages/server/tests/captcha*.test.ts`, `qc:captcha` | | Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions | | Green production audit hides development-tool findings or package counts are treated as exploit counts | [Audit scope lesson](../inbox/2026-10-23-dependency-audit-scope.md); [dated security scope](../../docs/quality/SECURITY_SCOPE_2026-10-23.md) | diff --git a/docs/quality/AUTH_LINK_LIFECYCLE.md b/docs/quality/AUTH_LINK_LIFECYCLE.md new file mode 100644 index 00000000..fa07e41b --- /dev/null +++ b/docs/quality/AUTH_LINK_LIFECYCLE.md @@ -0,0 +1,63 @@ +# Email link purpose and session lifetime + +This contract is implemented by the SQLite authentication path. The token work +for #184 depends on the server CAPTCHA policy in #195; provider email proof and +OAuth account linking remain a separate review. + +## Purpose and migration + +Every newly issued email link stores `login` or `password-reset`, the account +ID when one exists, and its current `authVersion`. Login redemption only claims +login links. Password reset only accepts a reset link bound to an active account +at the same revision. A wrong-purpose request leaves the link available for its +correct purpose; expiry and one-use checks occur in the SQLite write predicate. + +Startup adds the new columns idempotently and marks old untyped links used. +People with old emails must request a new link; migration does not guess a +purpose. Existing users start at revision zero. A correctly signed legacy JWT +without a revision is interpreted as zero, so it continues until its existing +expiry or the account's first revision change. Invalid/negative/non-integer +revision claims are rejected. New JWTs always carry an explicit revision. + +## Reset and account status + +Reset performs one conditional SQLite user update: password hash and revision +change together, only if the reset token is still unexpired, unused, correctly +purposed and bound to the current account revision. A trigger in that same +statement revokes outstanding links. If revocation fails, the password/revision +update rolls back. Concurrent reset attempts can produce only one success. +Administrative password updates use the same revision/trigger mechanism. + +Disabling or reactivating an account changes its revision whenever status +changes. Repeating an unchanged status does not mint a new revision. Reactivation +therefore cannot revive an earlier session or link. The migration also supplies +the existing status API's missing `deactivationDate` column, and status updates +return the inactive account without making it available to authentication. + +HTTP GraphQL context and the share-link creation route verify the current +active account and revision. WebSocket connection authentication and every +outgoing result recheck the revision. A rejected socket closes with code4403; +an idle transport can remain open until its next operation/result, without +restoring authorization. Requests already authorized before a concurrent reset +are not retroactively undone. Refresh rejects an authorization snapshot whose +revision no longer matches. Password/OAuth issuers sign the revision of the +identity they authenticated; login-link redemption rejects a revision change +between consumption and identity lookup. + +## Evidence and boundaries + +`packages/server/tests/auth-link-boundary.test.ts` mounts the production email +handlers with real in-memory SQLite, Apollo HTTP and graphql-ws transports. It +covers cross-purpose rejection and subsequent correct use, reset/disable/ +reactivate revocation, an already connected socket, fresh login, concurrent +reset, trigger rollback, old-schema migration, delayed expiry and stale refresh/ +identity snapshots. `magic-link.test.ts` retains login concurrency, replay, +expiry and database-error controls. Production startup tests retain environment +loading and signing-secret checks. Owned-fixture CAPTCHA proofs are genuinely +solved and verified; the test uses a bounded Node SHA-256 loop to avoid async +WebCrypto scheduling costs. It does not lower production challenge difficulty. + +These tests do not exercise live email delivery, a provider callback, Cloud D1, +shared databases or a deployment. The old unused Neo4j authentication modules +are not asserted to implement this contract. Provider subject/verified-email +rules and linked-account recovery remain the OAuth portion of #184. diff --git a/packages/server/src/auth/email-link-handlers.ts b/packages/server/src/auth/email-link-handlers.ts new file mode 100644 index 00000000..85c3d00e --- /dev/null +++ b/packages/server/src/auth/email-link-handlers.ts @@ -0,0 +1,68 @@ +import type { RequestHandler } from 'express'; +import { sqliteAuthStore } from './sqlite-auth.js'; +import { generateToken } from '../utils/auth.js'; +import { verifyCaptcha } from '../utils/captcha.js'; + +export const verifyMagicLinkHandler: RequestHandler = async (req, res) => { + try { + const { token } = req.query; + + if (!token || typeof token !== 'string') { + return res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=invalid_magic_link`); + } + + const result = await sqliteAuthStore.verifyMagicLink(token); + + if (!result.valid || !result.userId || result.authVersion === undefined || !result.role) { + return res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=expired_magic_link`); + } + + const jwtToken = generateToken(result.userId, result.email!, result.role, result.authVersion); + const clientUrl = process.env.CLIENT_URL || 'http://localhost:3127'; + const redirectUrl = `${clientUrl}/login?token=${jwtToken}`; + + console.log(`🔐 Magic link verified for: ${result.email}`); // eslint-disable-line no-console + res.redirect(redirectUrl); + } catch (error) { + console.error('❌ Magic link verification failed:', error); // eslint-disable-line no-console + res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=magic_link_failed`); + } +}; + +export const resetPasswordHandler: RequestHandler = async (req, res) => { + try { + const { token, newPassword, captchaPayload } = req.body; + + if (!token || typeof token !== 'string') { + return res.status(400).json({ error: 'Reset token is required' }); + } + + if (!newPassword || typeof newPassword !== 'string') { + return res.status(400).json({ error: 'New password is required' }); + } + + if (newPassword.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + // Verify CAPTCHA + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'reset-password'); + if (!isCaptchaValid) { + return res.status(400).json({ error: 'CAPTCHA verification failed' }); + } + + const success = await sqliteAuthStore.resetPasswordWithLink(token, newPassword); + + if (!success) { + return res.status(400).json({ error: 'Invalid or expired reset token' }); + } + + return res.json({ + success: true, + message: 'Password updated successfully' + }); + } catch (error) { + console.error('❌ Password update failed:', error); // eslint-disable-line no-console + return res.status(500).json({ error: 'Failed to update password' }); + } +}; diff --git a/packages/server/src/auth/sqlite-auth.ts b/packages/server/src/auth/sqlite-auth.ts index 9ef21473..9fa5f416 100644 --- a/packages/server/src/auth/sqlite-auth.ts +++ b/packages/server/src/auth/sqlite-auth.ts @@ -8,6 +8,8 @@ import fs from 'fs'; // SQLite-based authentication system // Separate from Neo4j for better availability and security +export type AuthLinkPurpose = 'login' | 'password-reset'; + interface User { id: string; email: string; @@ -15,6 +17,7 @@ interface User { name: string; role: 'ADMIN' | 'USER' | 'VIEWER' | 'GUEST'; passwordHash: string; + authVersion: number; createdAt: string; updatedAt: string; isActive: boolean; @@ -28,6 +31,7 @@ interface User { class SQLiteAuthStore { private db: sqlite3.Database | null = null; private initialized = false; + private initializing: Promise | null = null; private async getDb(): Promise { if (this.db) return this.db; @@ -55,6 +59,13 @@ class SQLiteAuthStore { async initialize(): Promise { if (this.initialized) return; + if (!this.initializing) { + this.initializing = this.initializeDatabase().finally(() => { this.initializing = null; }); + } + return this.initializing; + } + + private async initializeDatabase(): Promise { const db = await this.getDb(); @@ -296,6 +307,7 @@ class SQLiteAuthStore { } try { + await this.migrateAuthLinks(); await this.createDefaultUsers(); await this.createDefaultFolders(); this.initialized = true; @@ -308,6 +320,29 @@ class SQLiteAuthStore { }); } + private async migrateAuthLinks(): Promise { + const db = await this.getDb(); + const run = (sql: string) => new Promise((resolve, reject) => db.run(sql, error => error ? reject(error) : resolve())); + for (const [table, columns] of Object.entries({ + users: { authVersion: 'INTEGER NOT NULL DEFAULT 0', deactivationDate: 'TEXT' }, + magic_links: { purpose: "TEXT CHECK(purpose IN ('login', 'password-reset'))", authVersion: 'INTEGER' }, + })) { + const existing = await new Promise<{ name: string }[]>((resolve, reject) => { + db.all(`PRAGMA table_info(${table})`, (error, rows: { name: string }[]) => error ? reject(error) : resolve(rows)); + }); + for (const [name, definition] of Object.entries(columns)) { + if (!existing.some(column => column.name === name)) await run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + } + await run("UPDATE magic_links SET usedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE purpose IS NULL AND usedAt IS NULL"); + await run(`CREATE TRIGGER IF NOT EXISTS revoke_links_on_auth_version + AFTER UPDATE OF authVersion ON users WHEN NEW.authVersion != OLD.authVersion + BEGIN + UPDATE magic_links SET usedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE usedAt IS NULL AND (userId = NEW.id OR (userId IS NULL AND email = NEW.email)); + END`); + } + private async createDefaultFolders(): Promise { const db = await this.getDb(); const now = new Date().toISOString(); @@ -404,6 +439,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -421,7 +457,7 @@ class SQLiteAuthStore { }); } - async findUserById(id: string): Promise { + async findUserById(id: string, includeInactive = false): Promise { await this.initialize(); const db = await this.getDb(); @@ -432,10 +468,10 @@ class SQLiteAuthStore { LEFT JOIN user_teams ut ON u.id = ut.userId LEFT JOIN teams t ON ut.teamId = t.id WHERE u.id = ? - AND u.isActive = 1 + AND (? = 1 OR u.isActive = 1) `; - db.get(sql, [id], (err, row: any) => { + db.get(sql, [id, includeInactive ? 1 : 0], (err, row: any) => { if (err) { reject(err); } else if (row) { @@ -446,6 +482,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -488,6 +525,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -536,6 +574,7 @@ class SQLiteAuthStore { name: userData.name, role: 'ADMIN', passwordHash, + authVersion: 0, createdAt: now, updatedAt: now, isActive: true, @@ -563,6 +602,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -625,6 +665,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -779,13 +820,16 @@ class SQLiteAuthStore { const deactivationDate = isActive ? null : now; return new Promise((resolve, reject) => { - db.run('UPDATE users SET isActive = ?, deactivationDate = ?, updatedAt = ? WHERE id = ?', - [isActive ? 1 : 0, deactivationDate, now, userId], (err) => { + db.run('UPDATE users SET authVersion = authVersion + CASE WHEN isActive != ? THEN 1 ELSE 0 END, isActive = ?, deactivationDate = ?, updatedAt = ? WHERE id = ?', + [isActive ? 1 : 0, isActive ? 1 : 0, deactivationDate, now, userId], (err) => { if (err) { reject(err); } else { // Return updated user - this.findUserById(userId).then(user => resolve(user!)).catch(reject); + this.findUserById(userId, true).then(user => { + if (!user) throw new Error('User not found'); + resolve(user); + }).catch(reject); } }); }); @@ -799,7 +843,7 @@ class SQLiteAuthStore { const now = new Date().toISOString(); return new Promise((resolve, reject) => { - db.run('UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?', + db.run('UPDATE users SET passwordHash = ?, authVersion = authVersion + 1, updatedAt = ? WHERE id = ?', [passwordHash, now, userId], (err) => { if (err) { reject(err); @@ -1146,6 +1190,7 @@ class SQLiteAuthStore { name: row.name, role: row.role, passwordHash: row.passwordHash, + authVersion: row.authVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, isActive: Boolean(row.isActive), @@ -1309,10 +1354,13 @@ class SQLiteAuthStore { }); } - async createMagicLink(email: string): Promise<{ id: string; token: string; expiresAt: string }> { + async createMagicLink(email: string, purpose: AuthLinkPurpose): Promise<{ id: string; token: string; expiresAt: string }> { await this.initialize(); const db = await this.getDb(); + if (purpose !== 'login' && purpose !== 'password-reset') throw new Error('Invalid email link purpose'); + const account = await this.findUserByEmailOrUsername(email); + if ((account && !account.isActive) || (purpose === 'password-reset' && !account)) throw new Error('Email link unavailable'); const id = uuidv4(); const token = uuidv4() + uuidv4(); const now = new Date(); @@ -1321,8 +1369,8 @@ class SQLiteAuthStore { return new Promise((resolve, reject) => { db.run( - 'INSERT INTO magic_links (id, email, token, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?)', - [id, email.toLowerCase(), token, expiresAt.toISOString(), createdAt], + 'INSERT INTO magic_links (id, email, token, expiresAt, createdAt, purpose, userId, authVersion) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, email.toLowerCase(), token, expiresAt.toISOString(), createdAt, purpose, account?.id ?? null, account?.authVersion ?? 0], (err) => { if (err) { reject(err); @@ -1334,16 +1382,16 @@ class SQLiteAuthStore { }); } - async verifyMagicLink(token: string): Promise<{ valid: boolean; email?: string; userId?: string }> { + async verifyMagicLink(token: string): Promise<{ valid: boolean; email?: string; userId?: string; authVersion?: number; role?: string }> { await this.initialize(); const db = await this.getDb(); const now = new Date().toISOString(); - const link = await new Promise<{ id: string; email: string } | undefined>((resolve, reject) => { + const link = await new Promise<{ id: string; email: string; userId: string | null; authVersion: number } | undefined>((resolve, reject) => { db.get( - 'SELECT id, email FROM magic_links WHERE token = ? AND usedAt IS NULL AND expiresAt > ?', + "SELECT id, email, userId, authVersion FROM magic_links WHERE token = ? AND purpose = 'login' AND authVersion IS NOT NULL AND usedAt IS NULL AND expiresAt > ?", [token, now], - (error, row: { id: string; email: string } | undefined) => { + (error, row: { id: string; email: string; userId: string | null; authVersion: number } | undefined) => { if (error) reject(error); else resolve(row); } @@ -1353,7 +1401,13 @@ class SQLiteAuthStore { const changed = await new Promise((resolve, reject) => { db.run( - "UPDATE magic_links SET usedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND token = ? AND usedAt IS NULL AND expiresAt > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + `UPDATE magic_links SET usedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ? AND token = ? AND purpose = 'login' AND usedAt IS NULL + AND expiresAt > strftime('%Y-%m-%dT%H:%M:%fZ', 'now') AND ( + EXISTS (SELECT 1 FROM users WHERE users.email = magic_links.email AND users.isActive = 1 + AND users.authVersion = magic_links.authVersion AND (magic_links.userId IS NULL OR users.id = magic_links.userId)) + OR (userId IS NULL AND authVersion = 0 AND NOT EXISTS (SELECT 1 FROM users WHERE users.email = magic_links.email)) + )`, [link.id, token], function (error) { if (error) reject(error); @@ -1373,8 +1427,27 @@ class SQLiteAuthStore { role: 'USER' }); } - if (!user.isActive) return { valid: false }; - return { valid: true, email: link.email, userId: user.id }; + if (!user.isActive || user.authVersion !== link.authVersion || (link.userId && link.userId !== user.id)) return { valid: false }; + return { valid: true, email: link.email, userId: user.id, authVersion: link.authVersion, role: user.role }; + } + + async resetPasswordWithLink(token: string, newPassword: string): Promise { + await this.initialize(); + const db = await this.getDb(); + const passwordHash = await bcrypt.hash(newPassword, 10); + const changed = await new Promise((resolve, reject) => { + db.run(`UPDATE users SET passwordHash = ?, authVersion = authVersion + 1, + updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE isActive = 1 AND EXISTS ( + SELECT 1 FROM magic_links WHERE token = ? AND purpose = 'password-reset' + AND userId = users.id AND email = users.email AND authVersion = users.authVersion + AND usedAt IS NULL AND expiresAt > strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + )`, [passwordHash, token], function (error) { + if (error) reject(error); + else resolve(this.changes); + }); + }); + return changed === 1; } async createShareableLink(data: { diff --git a/packages/server/src/auth/websocket-auth.ts b/packages/server/src/auth/websocket-auth.ts new file mode 100644 index 00000000..21f04bb0 --- /dev/null +++ b/packages/server/src/auth/websocket-auth.ts @@ -0,0 +1,24 @@ +import type { ServerOptions } from 'graphql-ws'; +import type { WebSocket } from 'ws'; +import { GraphQLError } from 'graphql'; +import { resolveActiveAuthUser } from '../middleware/auth.js'; + +type ConnectionParams = Record; +type Extra = { socket: WebSocket }; +type Guard = Pick, 'onConnect' | 'onNext'>; + +export const webSocketAuthentication: Guard = { + onConnect: async context => { + const header = context.connectionParams?.authorization ?? context.connectionParams?.Authorization; + if (header === undefined) return true; + return typeof header === 'string' && Boolean(await resolveActiveAuthUser(header)); + }, + onNext: async context => { + const header = context.connectionParams?.authorization ?? context.connectionParams?.Authorization; + if (header !== undefined && (typeof header !== 'string' || !await resolveActiveAuthUser(header))) { + context.extra.socket.close(4403, 'Session expired'); + return { errors: [new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } })] }; + } + return undefined; + }, +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 43544a02..a35e1ece 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,4 +1,5 @@ import './config/environment.js'; +import { verifyMagicLinkHandler, resetPasswordHandler } from './auth/email-link-handlers.js'; import { operationsApi } from './operations-api.js'; import { agentApi } from './agent-api.js'; import { ApolloServer } from '@apollo/server'; @@ -22,7 +23,8 @@ import { authOnlyTypeDefs } from './schema/auth-only-schema.js'; import { sqliteAuthResolvers } from './resolvers/sqlite-auth.js'; import { graphProtectionResolvers } from './resolvers/graph-protection.js'; import { workItemRecoveryResolvers } from './resolvers/work-item-recovery.js'; -import { extractUserFromToken } from './middleware/auth.js'; +import { resolveActiveAuthUser } from './middleware/auth.js'; +import { webSocketAuthentication } from './auth/websocket-auth.js'; import { mergeTypeDefs } from '@graphql-tools/merge'; import { driver, NEO4J_URI } from './db.js'; import { sqliteAuthStore } from './auth/sqlite-auth.js'; @@ -369,12 +371,11 @@ async function startServer() { }); const graphContext = async (authHeader?: string) => { - const claims = extractUserFromToken(authHeader); - const account = claims ? await sqliteAuthStore.findUserById(claims.userId) : null; - if (account?.isActive && isNeo4jAvailable) await ensureGraphIdentity(driver, account); - const user = account?.isActive - ? { userId: account.id, email: account.email, role: account.role } - : (claims?.role === 'GUEST' && /^guest-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(claims.userId) ? claims : null); + const user = await resolveActiveAuthUser(authHeader); + if (user && isNeo4jAvailable) { + const account = await sqliteAuthStore.findUserById(user.userId); + if (account) await ensureGraphIdentity(driver, account); + } return { driver: isNeo4jAvailable ? driver : null, user, @@ -387,6 +388,7 @@ async function startServer() { const serverCleanup = useServer({ schema, + ...webSocketAuthentication, context: async connection => { const supplied = connection.connectionParams?.authorization ?? connection.connectionParams?.Authorization; return graphContext(typeof supplied === 'string' ? supplied : undefined); @@ -663,7 +665,7 @@ async function startServer() { console.log('🔐 Google OAuth callback received'); // eslint-disable-line no-console const user = req.user as any; console.log('👤 User from OAuth:', user?.email || 'No user'); // eslint-disable-line no-console - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); console.log('🎫 Generated token:', token?.substring(0, 20) + '...'); // eslint-disable-line no-console const clientUrl = process.env.CLIENT_URL || 'http://localhost:3127'; const redirectUrl = `${clientUrl}/login?token=${token}`; @@ -681,7 +683,7 @@ async function startServer() { console.log('🔐 LinkedIn OAuth callback received'); // eslint-disable-line no-console const user = req.user as any; console.log('👤 User from OAuth:', user?.email || 'No user'); // eslint-disable-line no-console - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); console.log('🎫 Generated token:', token?.substring(0, 20) + '...'); // eslint-disable-line no-console const clientUrl = process.env.CLIENT_URL || 'http://localhost:3127'; const redirectUrl = `${clientUrl}/login?token=${token}`; @@ -699,7 +701,7 @@ async function startServer() { console.log('🔐 GitHub OAuth callback received'); // eslint-disable-line no-console const user = req.user as any; console.log('👤 User from OAuth:', user?.email || 'No user'); // eslint-disable-line no-console - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); console.log('🎫 Generated token:', token?.substring(0, 20) + '...'); // eslint-disable-line no-console const clientUrl = process.env.CLIENT_URL || 'http://localhost:3127'; const redirectUrl = `${clientUrl}/login?token=${token}`; @@ -731,10 +733,11 @@ async function startServer() { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } - const user = await sqliteAuthStore.findUserByEmailOrUsername(email); + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const user = account?.isActive ? account : null; if (user) { - const magicLink = await sqliteAuthStore.createMagicLink(email); + const magicLink = await sqliteAuthStore.createMagicLink(email, 'login'); await emailService.sendMagicLink(email, magicLink.token); console.log(`✉️ Magic link sent to: ${email}`); // eslint-disable-line no-console } else { @@ -755,31 +758,7 @@ async function startServer() { } }); - app.get('/auth/magic-link/verify', async (req, res) => { - try { - const { token } = req.query; - - if (!token || typeof token !== 'string') { - return res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=invalid_magic_link`); - } - - const result = await sqliteAuthStore.verifyMagicLink(token); - - if (!result.valid || !result.userId) { - return res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=expired_magic_link`); - } - - const jwtToken = generateToken(result.userId, result.email!, 'USER'); - const clientUrl = process.env.CLIENT_URL || 'http://localhost:3127'; - const redirectUrl = `${clientUrl}/login?token=${jwtToken}`; - - console.log(`🔐 Magic link verified for: ${result.email}`); // eslint-disable-line no-console - res.redirect(redirectUrl); - } catch (error) { - console.error('❌ Magic link verification failed:', error); // eslint-disable-line no-console - res.redirect(`${process.env.CLIENT_URL || 'http://localhost:3127'}/login?error=magic_link_failed`); - } - }); + app.get('/auth/magic-link/verify', verifyMagicLinkHandler); const forgotPasswordCorsOptions = { origin: process.env.CORS_ORIGIN || 'http://localhost:3127', @@ -805,11 +784,12 @@ async function startServer() { } // Check if user exists - const user = await sqliteAuthStore.findUserByEmailOrUsername(email); + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const user = account?.isActive ? account : null; if (user) { // User exists - create reset link and send email - const resetLink = await sqliteAuthStore.createMagicLink(email); + const resetLink = await sqliteAuthStore.createMagicLink(email, 'password-reset'); await emailService.sendPasswordReset(email, resetLink.token); console.log(`🔐 Password reset link sent to: ${email}`); // eslint-disable-line no-console } else { @@ -860,52 +840,12 @@ async function startServer() { app.options('/auth/reset-password', cors(resetPasswordCorsOptions)); - app.post('/auth/reset-password', cors(resetPasswordCorsOptions), express.json(), async (req, res) => { - try { - const { token, newPassword, captchaPayload } = req.body; - - if (!token || typeof token !== 'string') { - return res.status(400).json({ error: 'Reset token is required' }); - } - - if (!newPassword || typeof newPassword !== 'string') { - return res.status(400).json({ error: 'New password is required' }); - } - - if (newPassword.length < 8) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); - } - - // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload, 'reset-password'); - if (!isCaptchaValid) { - return res.status(400).json({ error: 'CAPTCHA verification failed' }); - } - - const result = await sqliteAuthStore.verifyMagicLink(token); - - if (!result.valid || !result.userId) { - return res.status(400).json({ error: 'Invalid or expired reset token' }); - } - - await sqliteAuthStore.updateUserPassword(result.userId, newPassword); - - console.log(`🔐 Password updated successfully for: ${result.email}`); // eslint-disable-line no-console - - return res.json({ - success: true, - message: 'Password updated successfully' - }); - } catch (error) { - console.error('❌ Password update failed:', error); // eslint-disable-line no-console - return res.status(500).json({ error: 'Failed to update password' }); - } - }); + app.post('/auth/reset-password', cors(resetPasswordCorsOptions), express.json(), resetPasswordHandler); app.post('/share/create', cors(), express.json(), async (req, res) => { try { const authHeader = req.headers.authorization; - const user = extractUserFromToken(authHeader); + const user = await resolveActiveAuthUser(authHeader); if (!user) { return res.status(401).json({ error: 'Authentication required' }); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 07d97267..a23eae84 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,9 +1,11 @@ import { verifyToken } from '../utils/auth.js'; +import { sqliteAuthStore } from '../auth/sqlite-auth.js'; export interface AuthUser { userId: string; email: string; role: string; + authVersion: number; } export function extractUserFromToken(authHeader?: string): AuthUser | null { @@ -18,7 +20,11 @@ export function extractUserFromToken(authHeader?: string): AuthUser | null { try { const decoded = verifyToken(token); + const authVersion = decoded?.authVersion === undefined ? 0 : decoded.authVersion; + if (!decoded || typeof decoded.userId !== 'string' || typeof decoded.email !== 'string' + || typeof decoded.role !== 'string' || !Number.isSafeInteger(authVersion) || authVersion < 0) return null; return { + authVersion, userId: decoded.userId, email: decoded.email, role: decoded.role @@ -28,6 +34,18 @@ export function extractUserFromToken(authHeader?: string): AuthUser | null { } } +export async function resolveActiveAuthUser(authHeader?: string): Promise { + const claims = extractUserFromToken(authHeader); + if (!claims) return null; + const account = await sqliteAuthStore.findUserById(claims.userId); + if (account?.isActive && account.authVersion === claims.authVersion) { + return { userId: account.id, email: account.email, role: account.role, authVersion: account.authVersion }; + } + if (!account && claims.role === 'GUEST' && claims.authVersion === 0 + && /^guest-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(claims.userId)) return claims; + return null; +} + export function requireAuth(user: AuthUser | null | undefined): AuthUser { if (!user) { throw new Error('Authentication required'); diff --git a/packages/server/src/resolvers/sqlite-auth.ts b/packages/server/src/resolvers/sqlite-auth.ts index 32ac5757..4f35a023 100644 --- a/packages/server/src/resolvers/sqlite-auth.ts +++ b/packages/server/src/resolvers/sqlite-auth.ts @@ -365,7 +365,7 @@ export const sqliteAuthResolvers = { }); } - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); outcome = 'success'; console.log(`✅ Login successful: ${user.username} (${user.role})`); @@ -411,7 +411,7 @@ export const sqliteAuthResolvers = { team: null }; - const token = generateToken(guestUser.id, guestUser.email, guestUser.role); + const token = generateToken(guestUser.id, guestUser.email, guestUser.role, 0); return { token, @@ -460,7 +460,7 @@ export const sqliteAuthResolvers = { role: 'USER' }); - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); return { token, @@ -514,11 +514,11 @@ export const sqliteAuthResolvers = { } const user = await sqliteAuthStore.findUserById(context.user.userId); - if (!user) { - throw new GraphQLError('User not found'); + if (!user?.isActive || user.authVersion !== context.user.authVersion) { + throw new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } }); } - const token = generateToken(user.id, user.email, user.role); + const token = generateToken(user.id, user.email, user.role, user.authVersion); return { token, diff --git a/packages/server/src/utils/auth.ts b/packages/server/src/utils/auth.ts index 11a1b870..fd2cf56b 100644 --- a/packages/server/src/utils/auth.ts +++ b/packages/server/src/utils/auth.ts @@ -3,9 +3,10 @@ import jwt from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET || 'your-dev-secret-key'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '24h'; -export function generateToken(userId: string, email: string, role: string): string { +export function generateToken(userId: string, email: string, role: string, authVersion: number): string { + if (!Number.isSafeInteger(authVersion) || authVersion < 0) throw new Error('Invalid session revision'); return jwt.sign( - { userId, email, role }, + { userId, email, role, authVersion }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions ); diff --git a/packages/server/tests/auth-link-boundary.test.ts b/packages/server/tests/auth-link-boundary.test.ts new file mode 100644 index 00000000..7fa75a98 --- /dev/null +++ b/packages/server/tests/auth-link-boundary.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import sqlite3 from 'sqlite3'; +import express from 'express'; +import { createServer, type Server } from 'node:http'; +import { createHash } from 'node:crypto'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express4'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { createClient, type Client } from 'graphql-ws'; +import { useServer } from 'graphql-ws/lib/use/ws'; +import { WebSocket, WebSocketServer } from 'ws'; +import jwt from 'jsonwebtoken'; +import { sqliteAuthStore } from '../src/auth/sqlite-auth'; +import { verifyMagicLinkHandler, resetPasswordHandler } from '../src/auth/email-link-handlers'; +import { createCaptchaChallenge } from '../src/utils/captcha'; +import { authOnlyTypeDefs } from '../src/schema/auth-only-schema'; +import { sqliteAuthResolvers } from '../src/resolvers/sqlite-auth'; +import { resolveActiveAuthUser } from '../src/middleware/auth'; +import { webSocketAuthentication } from '../src/auth/websocket-auth'; +import { generateToken, verifyToken } from '../src/utils/auth'; + +let database: sqlite3.Database; +let server: Server; +let endpoint: string; +let apollo: ApolloServer; +let disposeSockets: () => Promise; +let clients: Client[]; +const email = 'purpose-boundary@example.invalid'; +const signingSecret = process.env.JWT_SECRET || 'your-dev-secret-key'; +const store = sqliteAuthStore as unknown as { db: sqlite3.Database | null; initialized: boolean }; +const execute = (sql: string, values: unknown[] = []) => new Promise((resolve, reject) => { + database.run(sql, values, error => error ? reject(error) : resolve()); +}); +const row = (sql: string, values: unknown[] = []) => new Promise((resolve, reject) => { + database.get(sql, values, (error, result: T) => error ? reject(error) : resolve(result)); +}); + +async function graphRequest(query: string, token?: string, variables?: Record) { + const response = await fetch(`${endpoint}/graphql`, { + method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ query, variables }), + }); + return response.json(); +} + +function socket(token: string) { + let closeCode: number | undefined; + const client = createClient({ url: endpoint.replace('http:', 'ws:') + '/graphql', webSocketImpl: WebSocket, + lazy: false, retryAttempts: 0, connectionParams: { authorization: `Bearer ${token}` }, + onNonLazyError: () => {}, + on: { closed: event => { closeCode = (event as { code: number }).code; } }, + }); + clients.push(client); + return { client, closeCode: () => closeCode }; +} + +function socketMe(client: Client) { + return new Promise<{ data?: { me?: { id: string } } }>((resolve, reject) => { + client.subscribe({ query: '{ me { id } }' }, { + next: value => resolve(value as { data?: { me?: { id: string } } }), + error: () => reject(new Error('WebSocket authentication rejected')), + complete: () => {}, + }); + }); +} + +async function reset(token: string, password = 'ChangedPurposeFixture!2026') { + return fetch(`${endpoint}/auth/reset-password`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token, newPassword: password, captchaPayload: await resetProof() }), + }); +} + +async function resetProof() { + const challenge = await createCaptchaChallenge('reset-password'); + for (let number = 0; number <= challenge.maxnumber; number++) { + if (createHash('sha256').update(challenge.salt + number).digest('hex') === challenge.challenge) { + return Buffer.from(JSON.stringify({ ...challenge, number })).toString('base64'); + } + } + throw new Error('Owned challenge could not be solved'); +} + +beforeEach(async () => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('ADMIN_EMAIL', email); + vi.stubEnv('ADMIN_PASSWORD', 'OwnedPurposeFixture!2026'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + database = await new Promise((resolve, reject) => { + const connection = new sqlite3.Database(':memory:', error => error ? reject(error) : resolve(connection)); + }); + store.db = database; store.initialized = false; + await sqliteAuthStore.initialize(); + clients = []; + const schema = makeExecutableSchema({ typeDefs: authOnlyTypeDefs, resolvers: { + Query: { me: sqliteAuthResolvers.Query.me }, + Mutation: { login: sqliteAuthResolvers.Mutation.login, refreshToken: sqliteAuthResolvers.Mutation.refreshToken }, + } }); + apollo = new ApolloServer({ schema }); + await apollo.start(); + const app = express(); + app.get('/auth/magic-link/verify', verifyMagicLinkHandler); + app.post('/auth/reset-password', express.json(), resetPasswordHandler); + app.use('/graphql', express.json(), expressMiddleware(apollo, { + context: async ({ req }) => ({ user: await resolveActiveAuthUser(req.headers.authorization), req }), + })); + server = createServer(app); + const ws = new WebSocketServer({ server, path: '/graphql' }); + const cleanup = useServer({ schema, ...webSocketAuthentication, context: async connection => { + const header = connection.connectionParams?.authorization; + return { user: await resolveActiveAuthUser(typeof header === 'string' ? header : undefined) }; + } }, ws); + disposeSockets = () => cleanup.dispose(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Owned HTTP fixture has no port'); + endpoint = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + for (const client of clients) await client.dispose(); + await disposeSockets(); + await apollo.stop(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); + store.db = null; store.initialized = false; + vi.restoreAllMocks(); vi.unstubAllEnvs(); +}); + +describe('purpose-bound email links at the real HTTP handlers', () => { + it('rejects a password-reset link at login without issuing a session', async () => { + const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + const response = await fetch(`${endpoint}/auth/magic-link/verify?token=${encodeURIComponent(link.token)}`, { redirect: 'manual' }); + expect(response.status).toBe(302); + const location = new URL(response.headers.get('location')!); + expect(location.searchParams.has('token')).toBe(false); + expect(location.searchParams.has('error')).toBe(true); + expect(await row('SELECT usedAt FROM magic_links WHERE id=?', [link.id])).toEqual({ usedAt: null }); + expect((await reset(link.token)).status).toBe(200); + }); + + it('rejects a login link at password reset without changing credentials', async () => { + const before = await sqliteAuthStore.findUserByEmailOrUsername(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); + const response = await fetch(`${endpoint}/auth/reset-password`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: link.token, newPassword: 'ShouldNotBeInstalled!2026', captchaPayload: await resetProof() }), + }); + expect(response.status).toBe(400); + const after = await sqliteAuthStore.findUserByEmailOrUsername(email); + expect(after!.passwordHash).toBe(before!.passwordHash); + const login = await fetch(`${endpoint}/auth/magic-link/verify?token=${encodeURIComponent(link.token)}`, { redirect: 'manual' }); + const session = new URL(login.headers.get('location')!).searchParams.get('token'); + expect(Boolean(session)).toBe(true); + expect((await graphRequest('{me{id}}', session!)).data.me.id).toBe(before!.id); + }); + + it('revokes old HTTP sessions, an open WebSocket, and all outstanding links on reset', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const login = await graphRequest('mutation($input:LoginInput!){login(input:$input){token}}', undefined, + { input: { emailOrUsername: 'admin', password: 'OwnedPurposeFixture!2026' } }); + expect(login.errors).toBeUndefined(); + const token = login.data.login.token as string; + const open = socket(token); + expect((await socketMe(open.client)).data?.me?.id).toBe(account!.id); + expect((await graphRequest('{me{id}}', token)).data.me.id).toBe(account!.id); + const pendingLogin = await sqliteAuthStore.createMagicLink(email, 'login'); + const pendingReset = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + const chosen = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + expect((await reset(chosen.token)).status).toBe(200); + expect((await graphRequest('{me{id}}', token)).data.me).toBeNull(); + await expect(socketMe(open.client)).rejects.toThrow('WebSocket authentication rejected'); + await vi.waitFor(() => expect(open.closeCode()).toBe(4403)); + const rejected = socket(token); + await expect(socketMe(rejected.client)).rejects.toThrow('WebSocket authentication rejected'); + await vi.waitFor(() => expect(rejected.closeCode()).toBe(4403)); + expect(await sqliteAuthStore.verifyMagicLink(pendingLogin.token)).toEqual({ valid: false }); + expect((await reset(pendingReset.token)).status).toBe(400); + expect((await reset(chosen.token)).status).toBe(400); + const fresh = await graphRequest('mutation($input:LoginInput!){login(input:$input){token}}', undefined, + { input: { emailOrUsername: 'admin', password: 'ChangedPurposeFixture!2026' } }); + expect(fresh.errors).toBeUndefined(); + expect(verifyToken(fresh.data.login.token).authVersion).toBe(1); + expect((await graphRequest('{me{id}}', fresh.data.login.token)).data.me.id).toBe(account!.id); + expect((await socketMe(socket(fresh.data.login.token).client)).data?.me?.id).toBe(account!.id); + }); + + it('does not revive sessions or outstanding links when a disabled account is reactivated', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const token = generateToken(account!.id, email, account!.role, account!.authVersion); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); + const resetLink = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + const disabled = await sqliteAuthStore.updateUserStatus(account!.id, false); + expect(disabled.isActive).toBe(false); + expect((await graphRequest('{me{id}}', token)).data.me).toBeNull(); + expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); + expect((await reset(resetLink.token)).status).toBe(400); + await sqliteAuthStore.updateUserStatus(account!.id, true); + expect((await graphRequest('{me{id}}', token)).data.me).toBeNull(); + await expect(socketMe(socket(token).client)).rejects.toThrow('WebSocket authentication rejected'); + expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); + expect((await reset(resetLink.token)).status).toBe(400); + }); + + it('allows only one concurrent reset and commits password, session revision and link revocation together', async () => { + const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + const results = await Promise.all(Array.from({ length: 8 }, () => sqliteAuthStore.resetPasswordWithLink(link.token, 'ConcurrentReset!2026'))); + expect(results.filter(Boolean)).toHaveLength(1); + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + expect(account!.authVersion).toBe(1); + expect(await sqliteAuthStore.validatePassword(account!, 'ConcurrentReset!2026')).toBe(true); + expect((await row<{ usedAt: string }>('SELECT usedAt FROM magic_links WHERE id=?', [link.id])).usedAt).toBeTruthy(); + }); + + it('rolls back the password and revision if link revocation fails in SQLite', async () => { + const before = await sqliteAuthStore.findUserByEmailOrUsername(email); + const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + await execute("CREATE TRIGGER owned_revoke_failure BEFORE UPDATE OF usedAt ON magic_links BEGIN SELECT RAISE(ABORT, 'owned revocation failure'); END"); + await expect(sqliteAuthStore.resetPasswordWithLink(link.token, 'MustNotCommit!2026')).rejects.toThrow('owned revocation failure'); + const after = await sqliteAuthStore.findUserByEmailOrUsername(email); + expect(after!.passwordHash).toBe(before!.passwordHash); + expect(after!.authVersion).toBe(before!.authVersion); + expect(await row('SELECT usedAt FROM magic_links WHERE id=?', [link.id])).toEqual({ usedAt: null }); + }); + + it('migrates old tables by rejecting untyped links and keeps legacy JWTs revocable', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + for (const statement of [ + 'DROP TRIGGER revoke_links_on_auth_version', + 'ALTER TABLE magic_links DROP COLUMN authVersion', + 'ALTER TABLE magic_links DROP COLUMN purpose', + 'ALTER TABLE users DROP COLUMN authVersion', + 'ALTER TABLE users DROP COLUMN deactivationDate', + ]) await execute(statement); + await execute('INSERT INTO magic_links(id,email,token,expiresAt,createdAt) VALUES(?,?,?,?,?)', + ['owned-legacy-link', email, 'owned-legacy-token', new Date(Date.now() + 60000).toISOString(), new Date().toISOString()]); + store.initialized = false; + await sqliteAuthStore.initialize(); + expect((await row<{ usedAt: string }>('SELECT usedAt FROM magic_links WHERE id=?', ['owned-legacy-link'])).usedAt).toBeTruthy(); + expect(await sqliteAuthStore.verifyMagicLink('owned-legacy-token')).toEqual({ valid: false }); + expect((await reset('owned-legacy-token')).status).toBe(400); + const legacy = jwt.sign({ userId: account!.id, email, role: account!.role }, signingSecret, { expiresIn: '10m' }); + expect((await graphRequest('{me{id}}', legacy)).data.me.id).toBe(account!.id); + await sqliteAuthStore.updateUserPassword(account!.id, 'MigrationReset!2026'); + expect((await graphRequest('{me{id}}', legacy)).data.me).toBeNull(); + await expect(socketMe(socket(legacy).client)).rejects.toThrow('WebSocket authentication rejected'); + }); + + it('rejects refresh with an authorization snapshot captured before reset', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const token = generateToken(account!.id, email, account!.role, account!.authVersion); + const user = await resolveActiveAuthUser(`Bearer ${token}`); + await sqliteAuthStore.updateUserPassword(account!.id, 'RefreshFence!2026'); + await expect(sqliteAuthResolvers.Mutation.refreshToken(null, {}, { user })).rejects.toThrow('Session expired'); + const response = await graphRequest('mutation { refreshToken { token } }', token); + expect(response.errors?.[0].extensions.code).toBe('UNAUTHENTICATED'); + }); + + it('does not upgrade a consumed login link to the new revision if reset wins before identity lookup', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); + const lookup = sqliteAuthStore.findUserByEmailOrUsername.bind(sqliteAuthStore); + vi.spyOn(sqliteAuthStore, 'findUserByEmailOrUsername').mockImplementationOnce(async value => { + await sqliteAuthStore.updateUserPassword(account!.id, 'IdentityFence!2026'); + return lookup(value); + }); + expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); + }); + + it('checks reset expiry when SQLite executes a delayed password write', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + const originalRun = database.run.bind(database); + let release!: () => void; + const queued = new Promise(resolve => { + vi.spyOn(database, 'run').mockImplementationOnce((sql: string, ...args: unknown[]) => { + release = () => { originalRun(sql, ...args); }; + resolve(); + return database; + }); + }); + const pending = sqliteAuthStore.resetPasswordWithLink(link.token, 'ExpiredWrite!2026'); + await queued; + await execute('UPDATE magic_links SET expiresAt=? WHERE id=?', ['2000-01-01T00:00:00.000Z', link.id]); + release(); + expect(await pending).toBe(false); + const after = await sqliteAuthStore.findUserByEmailOrUsername(email); + expect(after!.passwordHash).toBe(account!.passwordHash); + expect(after!.authVersion).toBe(account!.authVersion); + }); + + it('rejects signed but malformed revision claims', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + for (const authVersion of [-1, 0.5, '0', null, Number.MAX_SAFE_INTEGER + 1]) { + const token = jwt.sign({ userId: account!.id, email, role: account!.role, authVersion }, signingSecret); + expect((await graphRequest('{me{id}}', token)).data.me).toBeNull(); + } + }); + + it('does not upgrade an in-flight password login when reset changes the authenticated snapshot', async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const validate = sqliteAuthStore.validatePassword.bind(sqliteAuthStore); + vi.spyOn(sqliteAuthStore, 'validatePassword').mockImplementationOnce(async (user, password) => { + await sqliteAuthStore.updateUserPassword(account!.id, 'PasswordSnapshotFence!2026'); + return validate(user, password); + }); + const result = await graphRequest('mutation($input:LoginInput!){login(input:$input){token}}', undefined, + { input: { emailOrUsername: 'admin', password: 'OwnedPurposeFixture!2026' } }); + expect(result.errors).toBeUndefined(); + expect(verifyToken(result.data.login.token).authVersion).toBe(0); + expect((await graphRequest('{me{id}}', result.data.login.token)).data.me).toBeNull(); + }); +}); diff --git a/packages/server/tests/magic-link.test.ts b/packages/server/tests/magic-link.test.ts index 405dbb2c..6f22d408 100644 --- a/packages/server/tests/magic-link.test.ts +++ b/packages/server/tests/magic-link.test.ts @@ -35,9 +35,9 @@ afterEach(async () => { describe('single-use magic links in real SQLite', () => { it('permits exactly one concurrent redemption for an existing account', async () => { const account = await sqliteAuthStore.findUserByEmailOrUsername(email); - const link = await sqliteAuthStore.createMagicLink(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); const results = await Promise.all(Array.from({ length: 12 }, () => sqliteAuthStore.verifyMagicLink(link.token))); - expect(results.filter(result => result.valid)).toEqual([{ valid: true, email, userId: account!.id }]); + expect(results.filter(result => result.valid)).toEqual([{ valid: true, email, userId: account!.id, authVersion: 0, role: 'ADMIN' }]); expect(results.filter(result => !result.valid)).toHaveLength(11); expect((await row<{ usedAt: string }>('SELECT usedAt FROM magic_links WHERE id=?', [link.id])).usedAt).toBeTruthy(); expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); @@ -45,24 +45,24 @@ describe('single-use magic links in real SQLite', () => { it('creates one new account when a fresh link is redeemed concurrently', async () => { const newEmail = 'new-magic-link-fixture@graphdone.invalid'; - const link = await sqliteAuthStore.createMagicLink(newEmail); + const link = await sqliteAuthStore.createMagicLink(newEmail, 'login'); const results = await Promise.all(Array.from({ length: 12 }, () => sqliteAuthStore.verifyMagicLink(link.token))); const account = await sqliteAuthStore.findUserByEmailOrUsername(newEmail); - expect(results.filter(result => result.valid)).toEqual([{ valid: true, email: newEmail, userId: account!.id }]); + expect(results.filter(result => result.valid)).toEqual([{ valid: true, email: newEmail, userId: account!.id, authVersion: 0, role: 'USER' }]); expect(results.filter(result => !result.valid)).toHaveLength(11); expect(await row('SELECT COUNT(*) AS count FROM users WHERE email=?', [newEmail])).toEqual({ count: 1 }); expect(account?.isActive).toBe(true); }); it('allows independent valid links to identify the same existing account', async () => { - const links = await Promise.all([sqliteAuthStore.createMagicLink(email), sqliteAuthStore.createMagicLink(email)]); + const links = await Promise.all([sqliteAuthStore.createMagicLink(email, 'login'), sqliteAuthStore.createMagicLink(email, 'login')]); const results = await Promise.all(links.map(link => sqliteAuthStore.verifyMagicLink(link.token))); expect(results.every(result => result.valid)).toBe(true); expect(results[0].userId).toBe(results[1].userId); }); it.each(['expired', 'used', 'missing'])('rejects an %s link without changing its record', async state => { - const link = await sqliteAuthStore.createMagicLink(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); if (state === 'expired') await execute('UPDATE magic_links SET expiresAt=? WHERE id=?', ['2000-01-01T00:00:00.000Z', link.id]); if (state === 'used') await execute('UPDATE magic_links SET usedAt=? WHERE id=?', ['2000-01-01T00:00:00.000Z', link.id]); const before = await row('SELECT * FROM magic_links WHERE id=?', [link.id]); @@ -71,7 +71,7 @@ describe('single-use magic links in real SQLite', () => { }); it('rejects a link that expires while its consumption write is delayed', async () => { - const link = await sqliteAuthStore.createMagicLink(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); const originalRun = db.run.bind(db); let release!: () => void; const queued = new Promise(resolve => { @@ -92,10 +92,12 @@ describe('single-use magic links in real SQLite', () => { }); it('rejects an inactive account and keeps its consumed link unusable after reactivation', async () => { - await execute('UPDATE users SET isActive=0 WHERE email=?', [email]); - const link = await sqliteAuthStore.createMagicLink(email); + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); + await sqliteAuthStore.updateUserStatus(account!.id, false); + await expect(sqliteAuthStore.createMagicLink(email, 'login')).rejects.toThrow('Email link unavailable'); expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); - await execute('UPDATE users SET isActive=1 WHERE email=?', [email]); + await sqliteAuthStore.updateUserStatus(account!.id, true); expect(await sqliteAuthStore.verifyMagicLink(link.token)).toEqual({ valid: false }); }); @@ -105,7 +107,7 @@ describe('single-use magic links in real SQLite', () => { }); it('awaits consumption errors and allows a retry only after the failed update is repaired', async () => { - const link = await sqliteAuthStore.createMagicLink(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); await execute("CREATE TRIGGER reject_magic_consumption BEFORE UPDATE OF usedAt ON magic_links BEGIN SELECT RAISE(ABORT, 'fixture consumption failure'); END"); await expect(sqliteAuthStore.verifyMagicLink(link.token)).rejects.toThrow('fixture consumption failure'); expect(await row('SELECT usedAt FROM magic_links WHERE id=?', [link.id])).toEqual({ usedAt: null }); @@ -115,7 +117,7 @@ describe('single-use magic links in real SQLite', () => { }); it('fails closed if identity lookup fails after the token was consumed', async () => { - const link = await sqliteAuthStore.createMagicLink(email); + const link = await sqliteAuthStore.createMagicLink(email, 'login'); const schema = await row<{ sql: string }>("SELECT sql FROM sqlite_master WHERE name='user_teams'"); await execute('DROP TABLE user_teams'); await expect(sqliteAuthStore.verifyMagicLink(link.token)).rejects.toThrow('no such table'); diff --git a/packages/server/tests/production-startup.test.ts b/packages/server/tests/production-startup.test.ts index 51ca95dd..eb80c1c6 100644 --- a/packages/server/tests/production-startup.test.ts +++ b/packages/server/tests/production-startup.test.ts @@ -103,7 +103,7 @@ describe('production API startup secret boundary', () => { expect(fixture.session).toHaveBeenCalledOnce(); expect(listen).toHaveBeenCalledOnce(); const { generateToken } = await import('../src/utils/auth.js'); - expect(jwt.verify(generateToken('startup-user', 'startup@example.test', 'USER'), signingSecret)).toMatchObject({ userId: 'startup-user' }); + expect(jwt.verify(generateToken('startup-user', 'startup@example.test', 'USER', 0), signingSecret)).toMatchObject({ userId: 'startup-user' }); }); it('loads .env secrets before auth modules capture their signing key', async () => { @@ -113,7 +113,7 @@ describe('production API startup secret boundary', () => { await import('../src/index.js'); await listening; const { generateToken } = await import('../src/utils/auth.js'); - expect(jwt.verify(generateToken('dotenv-user', 'dotenv@example.test', 'USER'), signingSecret)).toMatchObject({ userId: 'dotenv-user' }); + expect(jwt.verify(generateToken('dotenv-user', 'dotenv@example.test', 'USER', 0), signingSecret)).toMatchObject({ userId: 'dotenv-user' }); }); it('preserves development startup without explicit secrets', async () => { From fd74f12fff14b6987e51d18dd96f54fdf85a1fa0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 13:37:28 -0700 Subject: [PATCH 2/2] Enforce session revision in human agent and operations requests --- ...urpose-and-revision-are-auth-boundaries.md | 13 ++++ docs/quality/AUTH_LINK_LIFECYCLE.md | 14 +++- packages/server/src/agent-api.ts | 4 +- packages/server/src/operations-api.ts | 14 ++-- .../server/tests/auth-link-boundary.test.ts | 66 +++++++++++++++++++ scripts/test-operations-local.mts | 2 +- 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md b/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md index f8e52ee1..80f3344b 100644 --- a/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md +++ b/.agents/inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md @@ -50,3 +50,16 @@ Existing requests authorized before reset are not rolled back. Idle sockets are denied at their next operation/result, not claimed to be instantly closed across API processes. Do not infer verified-provider email or account-linking safety from token-purpose tests; that remains separate work in #184. + +Independent review of `b79ecd593ef92a12e9d4b3070da68e42f9254477` found two +additional live consumers: human JWTs in `agent-api.ts` and `operations-api.ts` +still used signature extraction without checking the account revision. On +2026-10-27, four real-router negative controls all failed with HTTP201 instead +of401: reset and disable/reactivate sessions could mint new scoped credentials +in each API. The agent route used a bounded graph adapter, while both auth and +operations stores used real SQLite. Signature verification is not session +authorization; enumerate every live token consumer when adding revocation. +Both human paths now call the common active-account/revision resolver. Existing +scoped credentials keep their explicit separate contract. The corrected +16-case HTTP/SQLite/WebSocket suite passed; full frozen-head checks and review +follow in the PR. The operations smoke script also now signs explicit revisions. diff --git a/docs/quality/AUTH_LINK_LIFECYCLE.md b/docs/quality/AUTH_LINK_LIFECYCLE.md index fa07e41b..c4a2cd09 100644 --- a/docs/quality/AUTH_LINK_LIFECYCLE.md +++ b/docs/quality/AUTH_LINK_LIFECYCLE.md @@ -34,8 +34,11 @@ therefore cannot revive an earlier session or link. The migration also supplies the existing status API's missing `deactivationDate` column, and status updates return the inactive account without making it available to authentication. -HTTP GraphQL context and the share-link creation route verify the current -active account and revision. WebSocket connection authentication and every +HTTP GraphQL context, share-link creation and human sessions in the agent and +operations APIs verify the current active account and revision. Existing scoped +`gd_agent_` and `gd_ops_` credentials retain their separate expiry, revocation, +scope and active-owner checks; password reset does not revoke those credentials. +WebSocket connection authentication and every outgoing result recheck the revision. A rejected socket closes with code4403; an idle transport can remain open until its next operation/result, without restoring authorization. Requests already authorized before a concurrent reset @@ -57,6 +60,13 @@ loading and signing-secret checks. Owned-fixture CAPTCHA proofs are genuinely solved and verified; the test uses a bounded Node SHA-256 loop to avoid async WebCrypto scheduling costs. It does not lower production challenge difficulty. +The same suite mounts the real agent and operations routers and attempts +credential issuance with reset and disabled/reactivated JWTs. The operations +store is real in-memory SQLite; the agent graph driver is a bounded test adapter, +so these cases prove the HTTP authorization boundary, not Neo4j execution. Both +stale-session paths must return401 without issuing a credential or querying graph +content, while a fresh password login must succeed. + These tests do not exercise live email delivery, a provider callback, Cloud D1, shared databases or a deployment. The old unused Neo4j authentication modules are not asserted to implement this contract. Provider subject/verified-email diff --git a/packages/server/src/agent-api.ts b/packages/server/src/agent-api.ts index 8b2b91b2..56c82757 100644 --- a/packages/server/src/agent-api.ts +++ b/packages/server/src/agent-api.ts @@ -5,7 +5,7 @@ import cors from 'cors'; import rateLimit from 'express-rate-limit'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { driver } from './db.js'; -import { extractUserFromToken } from './middleware/auth.js'; +import { resolveActiveAuthUser } from './middleware/auth.js'; import { sqliteAuthStore } from './auth/sqlite-auth.js'; const hash = (value: string) => createHash('sha256').update(value).digest('hex'); @@ -31,7 +31,7 @@ agentApi.all('*', async (req, res) => { if (!token) return res.status(401).json({ error: 'Token expired, revoked, or invalid for this project' }); ownerId = token.ownerId; actor = token.name; actorId = token.id; scope = token.scope; } else { - const user = extractUserFromToken(req.headers.authorization); + const user = await resolveActiveAuthUser(req.headers.authorization); if (!user) return res.status(401).json({ error: 'Authentication required' }); ownerId = user.userId; actorId = user.userId; actor = user.email; } diff --git a/packages/server/src/operations-api.ts b/packages/server/src/operations-api.ts index e58ce092..cbc79ae2 100644 --- a/packages/server/src/operations-api.ts +++ b/packages/server/src/operations-api.ts @@ -6,7 +6,7 @@ import {mkdirSync,chmodSync} from 'node:fs'; import {dirname,join} from 'node:path'; import {createHash,randomUUID} from 'node:crypto'; import {OperationsService,OPS_SCHEMA,OpsDatabase} from '@graphdone/core/dist/operations.js'; -import {extractUserFromToken} from './middleware/auth.js'; +import {resolveActiveAuthUser} from './middleware/auth.js'; import {sqliteAuthStore} from './auth/sqlite-auth.js'; export class SQLiteOperationsDatabase implements OpsDatabase { private db:sqlite3.Database; @@ -20,21 +20,25 @@ export class SQLiteOperationsDatabase implements OpsDatabase { run(sql:string,args:unknown[]=[]):Promise<{changes:number}>{return new Promise((resolve,reject)=>this.db.run(sql,args,function(e){e?reject(e):resolve({changes:this.changes});}));} close():Promise{return new Promise((resolve,reject)=>this.db.close(e=>e?reject(e):resolve()));} } -let database:SQLiteOperationsDatabase|undefined; const hash=async(s:string)=>createHash('sha256').update(s).digest('hex'); -export const operationsApi=Router(); +export function createOperationsApi(createDatabase=()=>new SQLiteOperationsDatabase()) { +let database:SQLiteOperationsDatabase|undefined; +const operationsApi=Router(); operationsApi.use(cors(),express.json({limit:'32kb'}),rateLimit({windowMs:60000,limit:60})); operationsApi.all('*',async(req,res)=>{ res.setHeader('Cache-Control','no-store'); try { - database||=new SQLiteOperationsDatabase();await database.ready; + database||=createDatabase();await database.ready; const user=async(id:string)=>{const u=await sqliteAuthStore.findUserById(id);return u?.isActive?{id:u.id,role:u.role,name:u.name}:null;}; const bearer=req.headers.authorization?.match(/^Bearer (.+)$/)?.[1]||''; let identity= !bearer?{id:'public:'+await hash(req.ip||'unknown'),role:'GUEST'}:null; - if(bearer&&!bearer.startsWith('gd_ops_')){const claims=extractUserFromToken(req.headers.authorization);if(claims)identity=claims.role==='GUEST'?{id:'public:'+await hash(req.ip||'unknown'),role:'GUEST'}:await user(claims.userId);} + if(bearer&&!bearer.startsWith('gd_ops_')){const claims=await resolveActiveAuthUser(req.headers.authorization);if(claims)identity=claims.role==='GUEST'?{id:'public:'+await hash(req.ip||'unknown'),role:'GUEST'}:await user(claims.userId);} const service=new OperationsService({db:database,hash,random:randomUUID,user,usage:async()=>({hosted:false,message:'Self-hosted storage has no GraphDone service quota. Monitor your own disk and server resources.'}),userCount:()=>sqliteAuthStore.getUserCount()}); const q=Object.fromEntries(Object.entries(req.query).filter(([,v])=>typeof v==='string')) as Record; const result=await service.handle(req.method,req.path,q,req.body,identity,bearer); return res.status(result.status).json(result.body); }catch{return res.status(503).json({error:'Feedback service is temporarily unavailable. Your draft is retained.'});} }); +return operationsApi; +} +export const operationsApi=createOperationsApi(); diff --git a/packages/server/tests/auth-link-boundary.test.ts b/packages/server/tests/auth-link-boundary.test.ts index 7fa75a98..e0c47a38 100644 --- a/packages/server/tests/auth-link-boundary.test.ts +++ b/packages/server/tests/auth-link-boundary.test.ts @@ -18,8 +18,18 @@ import { sqliteAuthResolvers } from '../src/resolvers/sqlite-auth'; import { resolveActiveAuthUser } from '../src/middleware/auth'; import { webSocketAuthentication } from '../src/auth/websocket-auth'; import { generateToken, verifyToken } from '../src/utils/auth'; +import { agentApi } from '../src/agent-api'; +import { createOperationsApi, SQLiteOperationsDatabase } from '../src/operations-api'; + +const graphFixture = vi.hoisted(() => ({ run: vi.fn(), write: vi.fn(), close: vi.fn() })); +vi.mock('../src/db', () => ({ driver: { session: () => ({ + run: graphFixture.run, + executeWrite: graphFixture.write, + close: graphFixture.close, +}) } })); let database: sqlite3.Database; +let operationsDatabase: SQLiteOperationsDatabase; let server: Server; let endpoint: string; let apollo: ApolloServer; @@ -91,6 +101,17 @@ beforeEach(async () => { }); store.db = database; store.initialized = false; await sqliteAuthStore.initialize(); + operationsDatabase = new SQLiteOperationsDatabase(':memory:'); + await operationsDatabase.ready; + graphFixture.run.mockReset().mockImplementation(async (query: string) => { + if (query.includes('RETURN g.name AS name')) return { records: [{ get: () => 'Owned auth fixture' }] }; + if (query.includes('RETURN g.id')) return { records: [{ get: () => 'owned-auth-graph' }] }; + if (query.includes('RETURN count(t) AS total')) return { records: [{ get: () => ({ toNumber: () => 0 }) }] }; + if (query.startsWith('CREATE (t:AgentAccess)')) return { records: [] }; + throw new Error('Unexpected owned graph query'); + }); + graphFixture.write.mockReset().mockImplementation(async work => work({ run: graphFixture.run })); + graphFixture.close.mockReset().mockResolvedValue(undefined); clients = []; const schema = makeExecutableSchema({ typeDefs: authOnlyTypeDefs, resolvers: { Query: { me: sqliteAuthResolvers.Query.me }, @@ -101,6 +122,8 @@ beforeEach(async () => { const app = express(); app.get('/auth/magic-link/verify', verifyMagicLinkHandler); app.post('/auth/reset-password', express.json(), resetPasswordHandler); + app.use('/api/agent', agentApi); + app.use('/api/ops', createOperationsApi(() => operationsDatabase)); app.use('/graphql', express.json(), expressMiddleware(apollo, { context: async ({ req }) => ({ user: await resolveActiveAuthUser(req.headers.authorization), req }), })); @@ -122,12 +145,55 @@ afterEach(async () => { await disposeSockets(); await apollo.stop(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await operationsDatabase.close(); await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); store.db = null; store.initialized = false; vi.restoreAllMocks(); vi.unstubAllEnvs(); }); describe('purpose-bound email links at the real HTTP handlers', () => { + for (const route of ['agent', 'ops']) { + const issueCredential = (token: string) => fetch(`${endpoint}/api/${route}/tokens`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ graphId: 'owned-auth-graph', name: 'Owned revocation fixture', scope: 'read' }), + }); + + it(`rejects reset sessions before credential issuance in the real ${route} router`, async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const token = generateToken(account!.id, email, account!.role, account!.authVersion); + expect((await issueCredential(token)).status).toBe(201); + const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); + expect((await reset(link.token)).status).toBe(200); + const graphCalls = graphFixture.run.mock.calls.length; + const tokensBefore = await operationsDatabase.all('SELECT * FROM ops_tokens'); + expect((await issueCredential(token)).status).toBe(401); + expect(graphFixture.run).toHaveBeenCalledTimes(graphCalls); + expect(await operationsDatabase.all('SELECT * FROM ops_tokens')).toEqual(tokensBefore); + const fresh = await graphRequest('mutation($input:LoginInput!){login(input:$input){token}}', undefined, + { input: { emailOrUsername: 'admin', password: 'ChangedPurposeFixture!2026' } }); + expect(fresh.errors).toBeUndefined(); + expect((await issueCredential(fresh.data.login.token)).status).toBe(201); + }); + + it(`does not revive disabled sessions in the real ${route} router`, async () => { + const account = await sqliteAuthStore.findUserByEmailOrUsername(email); + const token = generateToken(account!.id, email, account!.role, account!.authVersion); + expect((await issueCredential(token)).status).toBe(201); + await sqliteAuthStore.updateUserStatus(account!.id, false); + expect([401, 403]).toContain((await issueCredential(token)).status); + await sqliteAuthStore.updateUserStatus(account!.id, true); + const graphCalls = graphFixture.run.mock.calls.length; + const tokensBefore = await operationsDatabase.all('SELECT * FROM ops_tokens'); + expect((await issueCredential(token)).status).toBe(401); + expect(graphFixture.run).toHaveBeenCalledTimes(graphCalls); + expect(await operationsDatabase.all('SELECT * FROM ops_tokens')).toEqual(tokensBefore); + const fresh = await graphRequest('mutation($input:LoginInput!){login(input:$input){token}}', undefined, + { input: { emailOrUsername: 'admin', password: 'OwnedPurposeFixture!2026' } }); + expect(fresh.errors).toBeUndefined(); + expect((await issueCredential(fresh.data.login.token)).status).toBe(201); + }); + } + it('rejects a password-reset link at login without issuing a session', async () => { const link = await sqliteAuthStore.createMagicLink(email, 'password-reset'); const response = await fetch(`${endpoint}/auth/magic-link/verify?token=${encodeURIComponent(link.token)}`, { redirect: 'manual' }); diff --git a/scripts/test-operations-local.mts b/scripts/test-operations-local.mts index 795986a5..ef443996 100644 --- a/scripts/test-operations-local.mts +++ b/scripts/test-operations-local.mts @@ -11,7 +11,7 @@ const dir=await mkdtemp(join(tmpdir(),'graphdone-ops-core-'));process.chdir(dir) await sqliteAuthStore.initialize(); const admin=await sqliteAuthStore.findUserByEmailOrUsername('admin'); const users=[];for(const name of ['person','other'])users.push(await sqliteAuthStore.createUser({email:name+'@operations.invalid',username:name,name,password:'local-fixture-only-strong',role:'USER'})); -const identity={admin:generateToken(admin!.id,admin!.email,'ADMIN'),user:generateToken(users[0].id,users[0].email,'USER'),other:generateToken(users[1].id,users[1].email,'USER'),forgedAdmin:generateToken(users[0].id,users[0].email,'ADMIN')}; +const identity={admin:generateToken(admin!.id,admin!.email,'ADMIN',admin!.authVersion),user:generateToken(users[0].id,users[0].email,'USER',users[0].authVersion),other:generateToken(users[1].id,users[1].email,'USER',users[1].authVersion),forgedAdmin:generateToken(users[0].id,users[0].email,'ADMIN',users[0].authVersion)}; const app=express();app.use('/api/ops',operationsApi);const server=app.listen(0,'127.0.0.1');await new Promise(ok=>server.once('listening',ok)); const address=server.address() as any; try{await operationsContract(async(path:string,method='GET',body?:unknown,token?:string)=>{const r=await fetch(`http://127.0.0.1:${address.port}/api/ops`+path,{method,headers:{'Content-Type':'application/json',...token?{Authorization:'Bearer '+token}:{}},...body?{body:JSON.stringify(body)}:{}});return {status:r.status,body:await r.json()};},identity);}finally{server.close();await rm(dir,{recursive:true,force:true});}