Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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.

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.
1 change: 1 addition & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
| CAPTCHA widget is visible but an immediate verification event leaves proof empty | [Attach widget listeners during commit](../inbox/2026-10-27-widget-events-need-commit-phase-listeners.md); `AuthCaptcha.test.tsx` Profiler regression |
| CI development quick login fails required proof and exhausts login attempts with NODE_ENV unset | [Declare mode in the dev entry point without overriding production](../inbox/2026-10-27-dev-launch-must-declare-mode.md); `development-startup.test.ts` and actual dev smoke |
| 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` |
Expand Down
1 change: 1 addition & 0 deletions .agents/issues/magic-link-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
1 change: 1 addition & 0 deletions .agents/issues/security-audit-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
| Historical CSS audit findings disappear while the lock is unchanged | [Reconcile official backport ranges with actual consumers](../inbox/2026-10-27-reconcile-advisory-backports-with-locked-consumers.md); locked-path and Tailwind/parser regressions |
| Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates |
Expand Down
73 changes: 73 additions & 0 deletions docs/quality/AUTH_LINK_LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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, 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
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.

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
rules and linked-account recovery remain the OAuth portion of #184.
4 changes: 2 additions & 2 deletions packages/server/src/agent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
}
Expand Down
68 changes: 68 additions & 0 deletions packages/server/src/auth/email-link-handlers.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
};
Loading
Loading