@ultimat3/auth 27.2.3 → 27.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -33,6 +33,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
33
33
  `password.test.ts` proves an unreadable hash burns the KDF. A TTL takes `min: 1`.
34
34
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
35
35
  parameter to it re-opens account enumeration.
36
+ - **`login()` takes an email or a handle** (`login-identity.ts`, `defineAuth({ handles })`).
36
37
  - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF.**
37
38
  `verifyAgainst` (`password.ts`) answers `null` for `Bun.password.verify`'s throw (unsupported
38
39
  algorithm, malformed PHC) and `''`, joining the no-user branch; nothing is logged. An
package/README.md CHANGED
@@ -18,6 +18,10 @@ export const auth = defineAuth({
18
18
 
19
19
  const { actor, token, cookie } = await login(auth, { email, password, ip });
20
20
 
21
+ // Or by handle, for an app whose users are `@ada`: `defineAuth({ handles })` names the lookup from
22
+ // a normalised handle to the auth user's id, and `register(auth, { id, ... })` links the two rows.
23
+ const byHandle = await login(auth, { handle: 'ada', password, ip });
24
+
21
25
  // "Log in with GitHub" is a link to /auth/oauth/github. These two routes are what serves it.
22
26
  const { start, callback } = oauthLogin(auth);
23
27
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "27.2.3",
3
+ "version": "27.4.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,8 +32,8 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "27.2.3",
36
- "@ultimat3/db": "27.2.3",
37
- "@ultimat3/schema": "27.2.3"
35
+ "@ultimat3/core": "27.4.0",
36
+ "@ultimat3/db": "27.4.0",
37
+ "@ultimat3/schema": "27.4.0"
38
38
  }
39
39
  }
package/src/auth.ts CHANGED
@@ -10,6 +10,7 @@ import { normaliseEmail } from './email';
10
10
  import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import { installedAuthLimiter } from './limiter-install';
12
12
  import { openLoginAttempt } from './login-attempt';
13
+ import { type HandleDirectory, loginIdentity } from './login-identity';
13
14
  import { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
14
15
  import { mfaChallengeRequired } from './mfa-challenge';
15
16
  import type { OAuthProviderId } from './oauth';
@@ -25,7 +26,6 @@ import { assertPasswordPolicy, assertRateLimitPolicy, assertSessionPolicy } from
25
26
  import {
26
27
  type AuthLimiter,
27
28
  type AuthRateLimitPolicy,
28
- accountKey,
29
29
  assertAuthLimiterPolicy,
30
30
  DEFAULT_AUTH_RATE_LIMIT,
31
31
  loginFailed,
@@ -162,6 +162,13 @@ export interface AuthConfigInput {
162
162
  readonly providers?: readonly OAuthProviderId[] | undefined;
163
163
  /** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
164
164
  readonly link?: OAuthLinkPolicy | undefined;
165
+ /**
166
+ * How `login(auth, { handle })` finds its user: the app's lookup from a normalised handle to the
167
+ * auth user's id. Absent means logins are by email only, and a handle login is refused
168
+ * (`X_CONFIG_INVALID`). For an app that keeps handles in a table of its own, linked to `x_users`
169
+ * by id — `register(auth, { id })` creates the auth user under the app's id.
170
+ */
171
+ readonly handles?: HandleDirectory | undefined;
165
172
  }
166
173
 
167
174
  export interface Auth {
@@ -178,6 +185,7 @@ export interface Auth {
178
185
  readonly totpReplay: TotpReplayGuard;
179
186
  readonly providers: readonly OAuthProviderId[];
180
187
  readonly link: OAuthLinkPolicy;
188
+ readonly handles?: HandleDirectory | undefined;
181
189
  }
182
190
 
183
191
  export function defineAuth(config: AuthConfigInput): Auth {
@@ -245,10 +253,16 @@ export function defineAuth(config: AuthConfigInput): Auth {
245
253
  // to be.
246
254
  providers: config.providers ?? [],
247
255
  link: config.link ?? 'verified-email',
256
+ ...(config.handles === undefined ? {} : { handles: config.handles }),
248
257
  });
249
258
  }
250
259
 
251
260
  export interface RegisterInput {
261
+ /**
262
+ * The user's id, when the app already chose one — its own `users` row's, so the two link by id.
263
+ * Omit it and one is minted.
264
+ */
265
+ readonly id?: string | undefined;
252
266
  readonly email: string;
253
267
  readonly password: string;
254
268
  readonly orgId?: string | null | undefined;
@@ -259,7 +273,7 @@ export interface RegisterInput {
259
273
  export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
260
274
  checkPasswordStrength(input.password, { policy: auth.password });
261
275
  return await auth.adapter.createUser({
262
- id: uuidV7(auth.clock),
276
+ id: input.id ?? uuidV7(auth.clock),
263
277
  email: normaliseEmail(input.email),
264
278
  passwordHash: await hashPassword(input.password, auth.password.params),
265
279
  orgId: input.orgId ?? null,
@@ -268,13 +282,22 @@ export async function register(auth: Auth, input: RegisterInput): Promise<AuthUs
268
282
  });
269
283
  }
270
284
 
271
- export interface LoginInput {
272
- readonly email: string;
285
+ interface LoginCredentials {
273
286
  readonly password: string;
274
287
  readonly ip?: string | null | undefined;
275
288
  readonly userAgent?: string | null | undefined;
276
289
  }
277
290
 
291
+ /**
292
+ * By email, or by handle when `defineAuth({ handles })` names the lookup — one or the other, and
293
+ * every failure of either is the same `loginFailed()`.
294
+ */
295
+ export type LoginInput = LoginCredentials &
296
+ (
297
+ | { readonly email: string; readonly handle?: undefined }
298
+ | { readonly handle: string; readonly email?: undefined }
299
+ );
300
+
278
301
  export interface LoginResult {
279
302
  readonly actor: PolicyActor;
280
303
  readonly session: AuthSession;
@@ -294,12 +317,13 @@ export interface LoginResult {
294
317
  */
295
318
  export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
296
319
  const ip = input.ip ?? null;
297
- const attempt = await openLoginAttempt(auth, accountKey(input.email), ip);
320
+ const identity = loginIdentity(auth, input);
321
+ const attempt = await openLoginAttempt(auth, identity.key, ip);
298
322
 
299
323
  let user: AuthUser | null;
300
324
  let verification: Awaited<ReturnType<typeof verifyPassword>>;
301
325
  try {
302
- user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
326
+ user = await identity.find();
303
327
  // The tenant bucket can only be joined once the address resolves to an org, which is still
304
328
  // before the KDF runs — the expensive half of this function — and it caps a spray that
305
329
  // per-IP and per-account buckets both let through.
package/src/errors.ts CHANGED
@@ -254,6 +254,18 @@ export const mfaRequiredUnenforceable = (): AuthError =>
254
254
  fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
255
255
  });
256
256
 
257
+ /**
258
+ * `login(auth, { handle })` on an app whose `defineAuth` named no `handles` lookup. Refused before
259
+ * any bucket is reserved or any hash run: this is a wiring fault, never a failed guess.
260
+ */
261
+ export const handlesUndeclared = (): AuthError =>
262
+ new AuthError({
263
+ code: 'X_CONFIG_INVALID',
264
+ cause:
265
+ 'login() was handed a handle, and defineAuth declared no handles lookup to resolve one to a user',
266
+ fix: 'defineAuth({ adapter, handles: async (handle) => (await db.users.where({ handle }).one())?.id ?? null })',
267
+ });
268
+
257
269
  /**
258
270
  * A policy NUMBER that is not one, refused where `defineAuth` still names the key.
259
271
  *
package/src/index.ts CHANGED
@@ -123,6 +123,8 @@ export type { AuthLimiterFactory } from './limiter-install';
123
123
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
124
124
  // caller building limiters out of band would be a second answer to where failures are counted.
125
125
  export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
126
+ export type { HandleDirectory } from './login-identity';
127
+ export { normaliseHandle } from './login-identity';
126
128
  export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
127
129
  export type {
128
130
  EnrolTotpInput,
@@ -0,0 +1,42 @@
1
+ // Single responsibility: which account a login names — an email, or a handle an app resolves
2
+ // through `defineAuth({ handles })` — as the bucket key its failures count against and the lookup
3
+ // that finds the user. Both spellings normalise before either is used, so a bucket keyed one way
4
+ // and a row found another cannot hand a sprayer a fresh budget per spelling.
5
+
6
+ import type { AuthUser } from './adapter';
7
+ import type { Auth, LoginInput } from './auth';
8
+ import { normaliseEmail } from './email';
9
+ import { handlesUndeclared } from './errors';
10
+ import { accountKey } from './rate-limit';
11
+
12
+ /** An app's lookup from a normalised handle to the auth user's id, or `null` for nobody. */
13
+ export type HandleDirectory = (handle: string) => Promise<string | null>;
14
+
15
+ /**
16
+ * Trim, then lowercase: a handle differing only in case or surrounding space is one account. The
17
+ * app's lookup receives this form, so its table must store handles the same way.
18
+ */
19
+ export const normaliseHandle = (handle: string): string => handle.trim().toLowerCase();
20
+
21
+ export interface LoginIdentity {
22
+ /** The ACCOUNT bucket — prefixed apart from an email's, so the two namespaces never meet. */
23
+ readonly key: string;
24
+ readonly find: () => Promise<AuthUser | null>;
25
+ }
26
+
27
+ export function loginIdentity(auth: Auth, input: LoginInput): LoginIdentity {
28
+ if (input.handle === undefined) {
29
+ const email = normaliseEmail(input.email);
30
+ return { key: accountKey(email), find: () => auth.adapter.findUserByEmail(email) };
31
+ }
32
+ const handles = auth.handles;
33
+ if (handles === undefined) throw handlesUndeclared();
34
+ const handle = normaliseHandle(input.handle);
35
+ return {
36
+ key: `account-handle:${handle}`,
37
+ async find() {
38
+ const id = await handles(handle);
39
+ return id === null ? null : await auth.adapter.findUserById(id);
40
+ },
41
+ };
42
+ }