@ultimat3/auth 27.3.0 → 27.5.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 +1 -0
- package/README.md +4 -0
- package/package.json +4 -4
- package/src/auth.ts +30 -6
- package/src/errors.ts +12 -0
- package/src/index.ts +2 -0
- package/src/login-identity.ts +42 -0
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.
|
|
3
|
+
"version": "27.5.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.
|
|
36
|
-
"@ultimat3/db": "27.
|
|
37
|
-
"@ultimat3/schema": "27.
|
|
35
|
+
"@ultimat3/core": "27.5.0",
|
|
36
|
+
"@ultimat3/db": "27.5.0",
|
|
37
|
+
"@ultimat3/schema": "27.5.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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|