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
parent
a4c3378d1a
commit
21ca220fef
@ -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…
Reference in new issue