Skip to content

Commit 4c2fb57

Browse files
feat: add attemptWithReason() for login failure reasons (#37)
* feat: add attemptWithReason() for login failure reasons attempt() resolves the user and verifies the hash, then collapses both into a boolean. Callers who want to tell someone "no account found with that email" were paying a second lookup to recover what the attempt had already established one line earlier. That workaround is also wrong in a way that is hard to see: a Google or passkey signup has a row, so a findFirst by email reports the account as existing, and the user is told "invalid email or password" for an account where no password can ever succeed. Only the attempt itself can tell the difference. attemptWithReason() reports it as no_password. Two invariants hold the design: - attempt() keeps returning Promise<boolean>. Widening it would turn every `if (!await attempt())` downstream into a permanently false branch, since objects are always truthy, and log everyone in without a type error at the call site. - hash.verify() still runs unconditionally before any branch that can return early, against the cached dummy hash on a user miss. Asking for the reason must not buy back the timing oracle that exists to close. The reason is a fact, not a message. Deciding what reaches a screen stays with the application, so the disclosure policy ships as a guide rather than as configuration. * docs: correct createAuth signature to include AuthFactoryOptions The reference declared the factory as `() => AuthInstance<TUser>`, which has been wrong since per-request autoTouch landed in 36970fe. A bug report against this package cited the stale signature as evidence that `auth({ autoTouch: true })` was unsupported, and it is supported.
1 parent b5d6ae9 commit 4c2fb57

6 files changed

Lines changed: 401 additions & 37 deletions

File tree

__tests__/auth.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,109 @@ describe('AuthInstance', () => {
381381
expect(result).toBe(false);
382382
});
383383

384+
describe('attemptWithReason()', () => {
385+
it('reports no_user for an address with no account', async () => {
386+
const result = await laravelAuth.attemptWithReason({
387+
email: 'nobody@b.com',
388+
password: 'correct-password',
389+
});
390+
expect(result).toEqual({ ok: false, reason: 'no_user' });
391+
});
392+
393+
it('reports bad_password for a real account and a wrong password', async () => {
394+
const result = await laravelAuth.attemptWithReason({
395+
email: 'a@b.com',
396+
password: 'wrong',
397+
});
398+
expect(result).toEqual({ ok: false, reason: 'bad_password' });
399+
});
400+
401+
it('reports no_password for an account that has none (social/passkey signup)', async () => {
402+
const social = createAuth<TestUser>({
403+
secret: SECRET,
404+
cookie: bridge,
405+
resolveUser: async () => null,
406+
hash,
407+
resolveUserByCredentials: async () => ({ id: '2', email: 'g@b.com' }),
408+
})();
409+
410+
const result = await social.attemptWithReason({
411+
email: 'g@b.com',
412+
password: 'anything',
413+
});
414+
// The case a second `findFirst` by email cannot see: the row exists,
415+
// so "account exists" is true, but no password can ever succeed.
416+
expect(result).toEqual({ ok: false, reason: 'no_password' });
417+
});
418+
419+
it('reports rejected when attemptUser declines', async () => {
420+
const tokenAuth = createAuth<TestUser>({
421+
secret: SECRET,
422+
cookie: bridge,
423+
resolveUser: async () => null,
424+
attemptUser: async (creds) =>
425+
creds.token === 'valid' ? { id: '1', email: 'a@b.com' } : null,
426+
})();
427+
428+
expect(await tokenAuth.attemptWithReason({ token: 'bad' })).toEqual({
429+
ok: false,
430+
reason: 'rejected',
431+
});
432+
expect(await tokenAuth.attemptWithReason({ token: 'valid' })).toEqual({ ok: true });
433+
});
434+
435+
it('logs in on success and returns no user object', async () => {
436+
const result = await laravelAuth.attemptWithReason({
437+
email: 'a@b.com',
438+
password: 'correct-password',
439+
});
440+
441+
expect(result).toEqual({ ok: true });
442+
// The password hash must not ride back out on the result.
443+
expect(result).not.toHaveProperty('user');
444+
expect(await laravelAuth.check()).toBe(true);
445+
});
446+
447+
it('still verifies against the dummy hash on a user miss', async () => {
448+
let verifyCalls = 0;
449+
const countingHash = {
450+
make: (p: string) => hash.make(p),
451+
verify: (p: string, h: string) => {
452+
verifyCalls++;
453+
return hash.verify(p, h);
454+
},
455+
};
456+
457+
const auth = createAuth<TestUser>({
458+
secret: SECRET,
459+
cookie: bridge,
460+
resolveUser: async () => null,
461+
hash: countingHash,
462+
resolveUserByCredentials: async () => null,
463+
})();
464+
465+
// Returning the reason must not buy back the timing oracle the dummy
466+
// hash exists to close: the miss path still pays for a verify.
467+
expect(await auth.attemptWithReason({ email: 'x@b.com', password: 'p' }))
468+
.toEqual({ ok: false, reason: 'no_user' });
469+
expect(verifyCalls).toBe(1);
470+
});
471+
472+
it('agrees with attempt() on every path', async () => {
473+
const cases = [
474+
{ email: 'a@b.com', password: 'correct-password' },
475+
{ email: 'a@b.com', password: 'wrong' },
476+
{ email: 'nobody@b.com', password: 'correct-password' },
477+
];
478+
479+
for (const creds of cases) {
480+
const bool = await laravelAuth.attempt(creds);
481+
const rich = await laravelAuth.attemptWithReason(creds);
482+
expect(rich.ok).toBe(bool);
483+
}
484+
});
485+
});
486+
384487
it('strips password from lookup credentials', async () => {
385488
let receivedCreds: Record<string, any> = {};
386489
const authWithSpy = createAuth<TestUser>({

auth-instance.ts

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
AnyUser,
3+
AttemptResult,
34
AuthInstance,
45
ConfigurableCookieOptions,
56
CookieBridge,
@@ -171,6 +172,57 @@ export function createAuthInstance<TUser extends AnyUser>(
171172
}
172173
}
173174

175+
/**
176+
* The one implementation behind both `attempt()` and `attemptWithReason()`.
177+
*
178+
* Ordering here is load-bearing: hash.verify() runs unconditionally, before
179+
* any branch that could return early, so a caller who only logs the reason
180+
* still gets the constant-time property it never asked to give up.
181+
*/
182+
async function runAttempt(
183+
credentials: Record<string, any>,
184+
options?: LoginOptions,
185+
): Promise<AttemptResult> {
186+
// Escape hatch: attemptUser handles everything
187+
if (deps.attemptUser) {
188+
const user = await deps.attemptUser(credentials);
189+
if (!user) return { ok: false, reason: 'rejected' };
190+
await writeSession(user, options);
191+
return { ok: true };
192+
}
193+
194+
// Laravel-style: strip password, resolve user, verify hash
195+
if (deps.hash && deps.resolveUserByCredentials) {
196+
const { [deps.credentialKey]: password, ...lookup } = credentials;
197+
// Coerce non-string passwords (missing, arrays from query strings, etc.)
198+
// to '' so the flow returns false instead of throwing — and still runs
199+
// the verify below to keep timing uniform.
200+
const plaintext = typeof password === 'string' ? password : '';
201+
const dbUser = await deps.resolveUserByCredentials(lookup);
202+
203+
// Run verify even on miss against a dummy hash — prevents user enumeration via timing
204+
const rawStoredHash = dbUser
205+
? (dbUser as Record<string, any>)[deps.passwordField]
206+
: undefined;
207+
const storedHash = typeof rawStoredHash === 'string' && rawStoredHash
208+
? rawStoredHash
209+
: undefined;
210+
const hashToCheck = storedHash ?? (await getDummyHash(deps.hash));
211+
const ok = await deps.hash.verify(plaintext, hashToCheck);
212+
213+
if (!dbUser) return { ok: false, reason: 'no_user' };
214+
if (!storedHash) return { ok: false, reason: 'no_password' };
215+
if (!plaintext || !ok) return { ok: false, reason: 'bad_password' };
216+
217+
await writeSession(dbUser as TUser, options);
218+
return { ok: true };
219+
}
220+
221+
throw new Error(
222+
'Provide either attemptUser() or both hash + resolveUserByCredentials in config to use attempt()',
223+
);
224+
}
225+
174226
return {
175227
async login(user: TUser, options?: LoginOptions): Promise<void> {
176228
await writeSession(user, options);
@@ -186,42 +238,18 @@ export function createAuthInstance<TUser extends AnyUser>(
186238
},
187239

188240
async attempt(credentials: Record<string, any>, options?: LoginOptions): Promise<boolean> {
189-
// Escape hatch: attemptUser handles everything
190-
if (deps.attemptUser) {
191-
const user = await deps.attemptUser(credentials);
192-
if (!user) return false;
193-
await writeSession(user, options);
194-
return true;
195-
}
196-
197-
// Laravel-style: strip password, resolve user, verify hash
198-
if (deps.hash && deps.resolveUserByCredentials) {
199-
const { [deps.credentialKey]: password, ...lookup } = credentials;
200-
// Coerce non-string passwords (missing, arrays from query strings, etc.)
201-
// to '' so the flow returns false instead of throwing — and still runs
202-
// the verify below to keep timing uniform.
203-
const plaintext = typeof password === 'string' ? password : '';
204-
const dbUser = await deps.resolveUserByCredentials(lookup);
205-
206-
// Run verify even on miss against a dummy hash — prevents user enumeration via timing
207-
const rawStoredHash = dbUser
208-
? (dbUser as Record<string, any>)[deps.passwordField]
209-
: undefined;
210-
const storedHash = typeof rawStoredHash === 'string' && rawStoredHash
211-
? rawStoredHash
212-
: undefined;
213-
const hashToCheck = storedHash ?? (await getDummyHash(deps.hash));
214-
const ok = await deps.hash.verify(plaintext, hashToCheck);
215-
216-
if (!dbUser || !storedHash || !plaintext || !ok) return false;
217-
218-
await writeSession(dbUser as TUser, options);
219-
return true;
220-
}
241+
// Stays a boolean forever. Widening this return type would turn every
242+
// `if (!await attempt())` in every downstream app into a permanently
243+
// false branch — an object is always truthy — and log everyone in
244+
// without a type error at the call site. Use attemptWithReason().
245+
return (await runAttempt(credentials, options)).ok;
246+
},
221247

222-
throw new Error(
223-
'Provide either attemptUser() or both hash + resolveUserByCredentials in config to use attempt()',
224-
);
248+
async attemptWithReason(
249+
credentials: Record<string, any>,
250+
options?: LoginOptions,
251+
): Promise<AttemptResult> {
252+
return runAttempt(credentials, options);
225253
},
226254

227255
async logout(): Promise<void> {

docs/src/content/docs/api/create-auth.mdx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ title: createAuth
33
description: API reference for createAuth — the core session auth factory.
44
---
55

6-
import { Tabs, TabItem } from '@astrojs/starlight/components';
6+
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
77

88
## Signature
99

1010
```typescript
11-
function createAuth<TUser>(config: AuthConfig<TUser>): () => AuthInstance<TUser>
11+
function createAuth<TUser>(
12+
config: AuthConfig<TUser>,
13+
): (options?: AuthFactoryOptions) => AuthInstance<TUser>
1214
```
1315

1416
`createAuth` is the main entry point for session-based authentication. It accepts an `AuthConfig` object and returns a **factory function**. Each call to the factory produces an `AuthInstance` scoped to the current request's cookies.
@@ -184,6 +186,47 @@ if (!success) {
184186
}
185187
```
186188

189+
### attemptWithReason
190+
191+
```typescript
192+
attemptWithReason(
193+
credentials: Record<string, unknown>,
194+
options?: LoginOptions,
195+
): Promise<AttemptResult>
196+
```
197+
198+
Identical work to `attempt()`same lookup, same verification, same login on successbut returns *why* it failed instead of discarding it. No extra query: `attempt()` already resolved the user and already knew which branch failed.
199+
200+
```typescript
201+
const result = await auth().attemptWithReason({
202+
email: 'user@example.com',
203+
password: 'their-password',
204+
});
205+
206+
if (!result.ok) {
207+
return { error: messageFor(result.reason) };
208+
}
209+
```
210+
211+
| `reason` | Meaning |
212+
| --- | --- |
213+
| `no_user` | Nothing matched the lookup. |
214+
| `no_password` | The account exists but has no password seta social or passkey signup. |
215+
| `bad_password` | The password did not match. |
216+
| `rejected` | `attemptUser()` declined. Why is that callback's business, not the library's. |
217+
218+
<Aside type="caution" title="A reason is a fact, not a message">
219+
Three of the four confirm whether an address is registered. Putting any of them
220+
on a screen is a disclosure decision that belongs to your applicationsee
221+
[Login error messages](/ideal-auth/guides/login-error-messages/) for the
222+
probe-budget pattern. `bad_password` must never reach a user in any form: that
223+
a password was close is the one thing a login must not confirm.
224+
</Aside>
225+
226+
Success returns `{ ok: true }` and no user object. The resolved row still carries the password hash at that point, and handing it back is how it ends up somewhere it shouldn't — call [`user()`](#user) for the session-safe copy.
227+
228+
Timing is unaffected. Both methods run `hash.verify()` unconditionally before any branch, against a dummy hash when no user was found, so a caller that only logs the reason keeps the constant-time property it never asked to give up.
229+
187230
### logout
188231

189232
```typescript
@@ -409,7 +452,10 @@ All types are available as type-only exports:
409452

410453
```typescript
411454
import type {
455+
AttemptFailure,
456+
AttemptResult,
412457
AuthConfig,
458+
AuthFactoryOptions,
413459
AuthInstance,
414460
CookieBridge,
415461
CookieOptions,

0 commit comments

Comments
 (0)