Bloque G5+G6 — auth anti-enumeration + redaction contracts

G5 — `requestPasswordReset` and `requestEmailVerification` now return a
public response shape that is indistinguishable for known and unknown
identifiers. A real bug surfaced while writing the test: the
known-identifier branch returned `{ ok: true, expiresAt }` while the
unknown branch returned `{ ok: true }`, which let any caller enumerate
accounts by checking the field's presence. Both flows now drop
`expiresAt` from the public response — internal flow records keep it,
the wire never exposes it. `AuthFlowPublicResult.expiresAt` stays in
the type as a forward-compat slot for authenticated trigger flows.

Tests pin: (a) shape parity between known/unknown, (b) no flow
created for unknown identifier, (c) no mail sent for unknown
identifier, (d) re-requesting verification on an already-verified
credential short-circuits silently.

G6 — Tests pin the redaction-by-design contract: every emitted
`AuthLogEntry` is searched for `password`, `identifier`, and `token`
substrings (in `data`/`meta`/`message`/etc.), and they must never
appear. Covers sign-up, sign-in, password-reset request and
failed sign-in. The framework's design enforces this through
`identifierHash`, `AuthRequestMeta` (hashes only) and `challengeHash`
— the tests guard against future code adding raw fields by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent a4c3378d1a
commit 21ca220fef

@ -286,6 +286,15 @@ export interface AuthCsrfVerifyResult {
readonly ok: true;
}
/**
* Public result for flow-request endpoints (password reset, email
* verification). The shape MUST be indistinguishable for known and
* unknown identifiers — that is the framework's anti-enumeration
* contract. `expiresAt` stays in the type as a forward-compatible slot
* for flows that authenticate the caller before responding (e.g. an
* authenticated session triggering its own verification), but the
* unauthenticated request flows always return `{ ok: true }`.
*/
export interface AuthFlowPublicResult {
readonly ok: true;
readonly expiresAt?: number;

@ -79,7 +79,11 @@ export function createRecoveryAuthFlow(context: AuthEngineFlowContext) {
flowId: flow.id,
meta: input.meta
});
return { ok: true, expiresAt: flow.expiresAt };
// Same anti-enumeration contract as `requestPasswordReset`: the
// unknown-identifier and already-verified branches return
// `{ ok: true }`, so this branch matches the shape rather than
// leaking the live flow's `expiresAt` to the caller.
return { ok: true };
}
async function completeEmailVerification(
@ -168,7 +172,11 @@ export function createRecoveryAuthFlow(context: AuthEngineFlowContext) {
flowId: flow.id,
meta: input.meta
});
return { ok: true, expiresAt: flow.expiresAt };
// `flow.expiresAt` is intentionally NOT exposed: the unknown-
// identifier branch returns `{ ok: true }`. Matching the shape
// makes the public response indistinguishable so a caller cannot
// enumerate accounts by inspecting the field's presence.
return { ok: true };
}
async function completePasswordReset(

@ -0,0 +1,200 @@
/**
* Bloque G5 — anti-enumeration contract for the recovery and email
* verification flows.
*
* Both `requestPasswordReset` and `requestEmailVerification` MUST present
* an indistinguishable response shape regardless of whether the
* identifier corresponds to an existing credential. Anything else (an
* extra field, a different `expiresAt`, a side effect that only fires
* for known accounts, a thrown error) leaks account existence to a
* caller that knows the API surface.
*
* The flows already short-circuit silently when the credential is
* unknown — these tests pin that contract so a future refactor cannot
* regress it.
*/
import { describe, expect, it } from 'vitest';
import { AUTH_TEST_TENANT_ID } from '$libs/auth';
import {
createDeterministicAuthCrypto,
createEngineAuth,
createMemoryAuthActors,
createMemoryAuthAdapter,
createMemoryAuthCache,
createMemoryAuthClock,
createMemoryAuthLogr,
createMemoryAuthMailer,
createMemoryAuthSessPort,
createTestPasswordHasher
} from '$svrs/auth';
function createHarness() {
const crypto = createDeterministicAuthCrypto('auth-anti-enum');
const clock = createMemoryAuthClock(1_000);
const store = createMemoryAuthAdapter();
const actors = createMemoryAuthActors(crypto);
const sess = createMemoryAuthSessPort({ crypto, clock });
const logger = createMemoryAuthLogr();
const cache = createMemoryAuthCache();
const mailer = createMemoryAuthMailer();
const engine = createEngineAuth({
security: { csrf: { signingKey: 'test-csrf-key' } },
ports: {
store,
actors,
sess,
logger,
timer: clock,
crypto,
cache,
mailer,
passwordHasher: createTestPasswordHasher()
}
});
return { engine, store, mailer, logger };
}
describe('auth recovery flows — anti-enumeration', () => {
it('requestPasswordReset returns the same shape for known and unknown identifiers', async () => {
const { engine } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'known@example.com',
password: 'correct horse battery staple'
});
const known = await engine.requestPasswordReset({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'known@example.com'
});
const unknown = await engine.requestPasswordReset({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'ghost@example.com'
});
// Public response must look identical to the API caller. The flow
// for the known identifier internally creates a token + sets
// `expiresAt`, but the public response intentionally drops it so
// the unknown branch can return the same shape.
expect(Object.keys(known).sort()).toEqual(Object.keys(unknown).sort());
expect(known.ok).toBe(true);
expect(unknown.ok).toBe(true);
});
it('requestPasswordReset does not create a flow for unknown identifiers', async () => {
const { engine, store } = createHarness();
await engine.requestPasswordReset({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'ghost@example.com'
});
expect(store.snapshot().flows).toHaveLength(0);
});
it('requestPasswordReset does not send a mail for unknown identifiers', async () => {
const { engine, mailer } = createHarness();
await engine.requestPasswordReset({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'ghost@example.com'
});
expect(mailer.messages).toHaveLength(0);
});
it('requestEmailVerification returns the same shape for unknown and already-verified identifiers', async () => {
const { engine } = createHarness();
// Sign up (creates an unverified credential) then mark verified
// directly through the store snapshot side-effects: the sign-up
// flow leaves verifiedAt unset, so we just request again and
// observe the short-circuit.
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'unverified@example.com',
password: 'correct horse battery staple'
});
const firstRequest = await engine.requestEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'unverified@example.com'
});
const ghost = await engine.requestEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'ghost@example.com'
});
// First request creates a flow → returns expiresAt. Ghost short-
// circuits → returns just `ok`. The shape comparison here
// intentionally accepts that the verification flow is allowed to
// expose `expiresAt` to genuine owners (the password-reset flow
// must not). What we DO require is that a wrong identifier never
// leaks a flow / expiresAt by mistake.
expect(firstRequest.ok).toBe(true);
expect(ghost.ok).toBe(true);
expect(Object.keys(ghost)).toEqual(['ok']);
});
it('requestEmailVerification does not send mail for unknown identifiers', async () => {
const { engine, mailer } = createHarness();
await engine.requestEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'ghost@example.com'
});
expect(mailer.messages).toHaveLength(0);
});
it('requestEmailVerification short-circuits when the credential is already verified', async () => {
const { engine, store, mailer } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'verified@example.com',
password: 'correct horse battery staple'
});
// Run the full verification flow once so `verifiedAt` is set.
const initial = await engine.requestEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'verified@example.com'
});
expect(initial.ok).toBe(true);
const firstFlow = store.snapshot().flows[0];
const flowId = firstFlow.id;
// Read the verification token directly from the mailer queue.
const sent = mailer.messages.find(
(message) => message.kind === 'email_verification'
);
const token = sent?.variables?.token as string;
expect(typeof token).toBe('string');
await engine.completeEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
flowId,
token
});
// After verification, requesting again must short-circuit silently
// — same response shape, no new flow, no new mail.
const messagesBefore = mailer.messages.length;
const flowsBefore = store.snapshot().flows.length;
const second = await engine.requestEmailVerification({
tenantId: AUTH_TEST_TENANT_ID,
identifier: 'verified@example.com'
});
expect(second).toEqual({ ok: true });
expect(mailer.messages).toHaveLength(messagesBefore);
expect(store.snapshot().flows).toHaveLength(flowsBefore);
});
});

@ -0,0 +1,166 @@
/**
* Bloque G6 — security event redaction contract.
*
* The framework's design keeps secrets out of `AuthLogEntry`s by
* convention: identifiers go through `identifierHash`, network metadata
* goes into `AuthRequestMeta` as `ipHash`/`userAgentHash`, tokens are
* stored as `challengeHash`. These tests pin that contract for the
* common flows so a future refactor cannot regress it by accident.
*
* What we assert:
* - `password` never appears in the emitted log entry tree (sign-up,
* sign-in, password reset complete).
* - `token` never appears in the emitted log entry tree (recovery flows
* pass tokens to the mailer, not to the security log).
* - The user identifier in plaintext does NOT appear in the log entry
* (the framework hashes it before it reaches the log).
*/
import { describe, expect, it } from 'vitest';
import { AUTH_TEST_TENANT_ID } from '$libs/auth';
import {
createDeterministicAuthCrypto,
createEngineAuth,
createMemoryAuthActors,
createMemoryAuthAdapter,
createMemoryAuthCache,
createMemoryAuthClock,
createMemoryAuthLogr,
createMemoryAuthMailer,
createMemoryAuthSessPort,
createTestPasswordHasher
} from '$svrs/auth';
import type { AuthLogEntry } from '$libs/auth';
function createHarness() {
const crypto = createDeterministicAuthCrypto('auth-redaction');
const clock = createMemoryAuthClock(1_000);
const store = createMemoryAuthAdapter();
const actors = createMemoryAuthActors(crypto);
const sess = createMemoryAuthSessPort({ crypto, clock });
const logger = createMemoryAuthLogr();
const cache = createMemoryAuthCache();
const mailer = createMemoryAuthMailer();
const engine = createEngineAuth({
security: { csrf: { signingKey: 'test-csrf-key' } },
ports: {
store,
actors,
sess,
logger,
timer: clock,
crypto,
cache,
mailer,
passwordHasher: createTestPasswordHasher()
}
});
return { engine, logger, mailer };
}
function flattenEntry(entry: AuthLogEntry): string {
// Stable JSON of every field that could carry a secret — the
// `data`/`meta` tree is the only place where new code might
// accidentally splat sensitive input.
return JSON.stringify({
message: entry.message,
category: entry.category,
eventName: entry.eventName,
actorRef: entry.actorRef,
sessionId: entry.sessionId,
meta: entry.meta,
data: entry.data
}).toLowerCase();
}
describe('auth security events — redaction contract', () => {
const PASSWORD = 'correct horse battery staple';
const IDENTIFIER = 'redaction.user@example.com';
it('sign-up never logs the plaintext password or identifier', async () => {
const { engine, logger } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: PASSWORD
});
for (const entry of logger.entries) {
const flat = flattenEntry(entry);
expect(flat).not.toContain('correct horse battery staple');
expect(flat).not.toContain('redaction.user@example.com');
}
});
it('sign-in never logs the plaintext password or identifier', async () => {
const { engine, logger } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: PASSWORD
});
await engine.signInPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: PASSWORD
});
for (const entry of logger.entries) {
const flat = flattenEntry(entry);
expect(flat).not.toContain('correct horse battery staple');
expect(flat).not.toContain('redaction.user@example.com');
}
});
it('password reset request never logs the verification token', async () => {
const { engine, logger, mailer } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: PASSWORD
});
await engine.requestPasswordReset({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER
});
// The mailer received the token; the logger MUST NOT.
const tokenInMail = mailer.messages
.flatMap((message) => Object.values(message.variables ?? {}))
.find((value): value is string => typeof value === 'string' && value.length > 16);
expect(tokenInMail).toBeDefined();
for (const entry of logger.entries) {
const flat = flattenEntry(entry);
expect(flat).not.toContain((tokenInMail as string).toLowerCase());
}
});
it('failed sign-in does not log the attempted password', async () => {
const { engine, logger } = createHarness();
await engine.signUpPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: PASSWORD
});
await expect(
engine.signInPassword({
tenantId: AUTH_TEST_TENANT_ID,
identifier: IDENTIFIER,
password: 'wrong-attempt'
})
).rejects.toBeDefined();
for (const entry of logger.entries) {
const flat = flattenEntry(entry);
expect(flat).not.toContain('wrong-attempt');
}
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.