@zudojs/auth 1.2.0 → 1.3.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/README.md CHANGED
@@ -126,8 +126,22 @@ logout can revoke them. **The service therefore rejects a token without a
126
126
  Accepting them by default let a session-less refresh chain outlive
127
127
  `logoutAll()`.
128
128
 
129
+ ### Login identifiers
130
+
131
+ `login()` normalizes the identifier before `findUser()` sees it: NFKC, trim,
132
+ and lower-case when it is an email address (a username keeps its case). So
133
+ `" Alice@Example.COM"` finds the account stored as `alice@example.com`, and
134
+ case variants share one lockout budget. Store identifiers through the same
135
+ exported `normalizeLoginIdentifier()` at registration. Pass
136
+ `normalizeIdentifier: false` to receive the raw string, or your own function.
137
+
129
138
  ### Brute-force lockout
130
139
 
140
+ A locked identifier gets `AccountLockedError` (`423`, `ERR_ACCOUNT_LOCKED`)
141
+ with `retryAfterSeconds` set to the time left on the lockout and a
142
+ `Retry-After` header in `error.headers`, which `@zudojs/http` copies onto the
143
+ response. `AuthRateLimitError` (`429`) carries `Retry-After` the same way.
144
+
131
145
  A failure is reserved *before* the password is checked and cleared on
132
146
  success, so a parallel burst gets exactly `maxFailedAttempts` guesses before
133
147
  the lockout, not `maxAttemptsPerWindow`. Custom `LoginAttemptStore`s must make
@@ -143,6 +157,36 @@ unlocked failure streak after `failureTtlSeconds` of inactivity (default 900)
143
157
  and caps the tracked identifiers at `maxEntries` (default 100 000, oldest
144
158
  unlocked evicted first), so spraying identifiers cannot grow it without bound.
145
159
 
160
+ ### Sessions for OAuth and other sign-ins
161
+
162
+ A user authenticated some other way — the OAuth callback of
163
+ [`@zudojs/auth-oauth`](../auth-oauth), a passkey, a magic link — gets a
164
+ session and tokens from `createSessionForUser()`, exactly as `login()` would
165
+ issue them, without a password check:
166
+
167
+ ```typescript
168
+ const auth = createAuthService({
169
+ /* … */
170
+ externalSessionMethods: ["oauth"], // off by default
171
+ });
172
+
173
+ // In the OAuth callback, after state/PKCE were verified and the provider
174
+ // identity was mapped to one of your users:
175
+ const { tokens, sessionId } = await auth.createSessionForUser(user.id, {
176
+ method: "oauth",
177
+ metadata: { provider: "github" }, // stored on the session
178
+ ip: req.socket.remoteAddress,
179
+ });
180
+ ```
181
+
182
+ It checks no credential — your code asserts the user is authenticated — so
183
+ it throws `AuthConfigurationError` unless the `method` is listed in
184
+ `externalSessionMethods`. It still loads the user with `findUserById()` and
185
+ refuses an unknown user (`InvalidCredentialsError`) or a deactivated one
186
+ (`AccountDeactivatedError`). **Never pass it a user id taken from the
187
+ request.** The session records `metadata.authMethod`, and `logout()` /
188
+ `logoutAll()` revoke it like any other.
189
+
146
190
  ### Refresh-token rotation
147
191
 
148
192
  With a `revocationStore` configured, `refresh()` claims the presented token's
@@ -163,8 +207,11 @@ const decision = await auth.checkAccess({
163
207
  });
164
208
  ```
165
209
 
166
- When a `permissions` engine is configured the decision comes from it. When it
167
- is not, `checkAccess()` **throws** `AuthConfigurationError` rather than
210
+ When a `permissions` engine is configured the decision comes from it, with
211
+ `resourceOwnerId` passed as the resource `{ ownerId }`. An ownership policy
212
+ that should grant on its own must say `effect: "grant"` — since
213
+ `@zudojs/permissions` 1.4 an allowing policy only constrains what the roles
214
+ grant. When it is not, `checkAccess()` **throws** `AuthConfigurationError` rather than
168
215
  guessing. Setting `allowInsecureFallbackGuard: true` opts into a built-in
169
216
  fallback that grants a resource owner *every* permission and grants the
170
217
  `fallbackAdminRole` (default `"admin"`) everything; its allowed results carry
@@ -210,7 +257,9 @@ with OWASP's N=2^14, r=8, p=5 row. Hashes written by earlier versions of this
210
257
  package (`scrypt$N$r$p$…`, including the p=1 default and the param-less legacy
211
258
  format) still verify, and `needsRehash()` returns `true` for every hash that is
212
259
  not a current-parameter `@zudojs/crypto` scrypt hash, so they upgrade on the
213
- next login. `hashPassword("")` throws `AuthError` (`INVALID_INPUT`).
260
+ next login. A hash made by `@zudojs/crypto`'s own `hashPassword()` with its
261
+ defaults (same N, r, p; 16-byte salt, 32-byte key) is current too, and
262
+ `needsRehash()` returns `false` for it. `hashPassword("")` throws `AuthError` (`INVALID_INPUT`).
214
263
 
215
264
  - `createAuthService()` validates its configuration up front: bad or
216
265
  identical secrets, a non-positive or `NaN` `sessionTtlSeconds` /
@@ -244,23 +293,25 @@ throw.
244
293
 
245
294
  Every error carries an accurate HTTP status and is safe to expose:
246
295
 
247
- | Error | Status | Category |
248
- | --- | --- | --- |
249
- | `AuthError` | 401 | authentication |
250
- | `InvalidCredentialsError` | 401 | authentication |
251
- | `TokenExpiredError` | 401 | authentication |
252
- | `TokenInvalidError` | 401 | authentication |
253
- | `SessionExpiredError` | 401 | authentication |
254
- | `TokenRevokedError` | 403 | authorization |
255
- | `AccountDeactivatedError` | 403 | authorization |
256
- | `AccessDeniedError` | 403 | authorization |
257
- | `AccountLockedError` | 423 | rate_limit |
258
- | `AuthRateLimitError` | 429 | rate_limit |
259
- | `AuthConfigurationError` | 500 | configuration (not exposed) |
260
-
261
- `AccountLockedError` and `AuthRateLimitError` carry
262
- `metadata.retryAfterSeconds` for a `Retry-After` header; `AccessDeniedError`
263
- carries `metadata.requiredPermission`.
296
+ | Error | Status | Code | Category |
297
+ | --- | --- | --- | --- |
298
+ | `AuthError` | 401 | `ERR_AUTHENTICATION` | authentication |
299
+ | `InvalidCredentialsError` | 401 | `ERR_INVALID_CREDENTIALS` | authentication |
300
+ | `TokenExpiredError` | 401 | `ERR_TOKEN_EXPIRED` | authentication |
301
+ | `TokenInvalidError` | 401 | `ERR_TOKEN_INVALID` | authentication |
302
+ | `TokenRevokedError` | 401 | `ERR_TOKEN_REVOKED` | authentication |
303
+ | `SessionExpiredError` | 401 | `ERR_SESSION_EXPIRED` | authentication |
304
+ | `AccountDeactivatedError` | 403 | `ERR_ACCOUNT_DEACTIVATED` | authorization |
305
+ | `AccessDeniedError` | 403 | `ERR_ACCESS_DENIED` | authorization |
306
+ | `AccountLockedError` | 423 | `ERR_ACCOUNT_LOCKED` | rate_limit |
307
+ | `AuthRateLimitError` | 429 | `ERR_RATE_LIMITED` | rate_limit |
308
+ | `AuthConfigurationError` | 500 | `ERR_CONFIGURATION_INVALID` | configuration (not exposed) |
309
+
310
+ `AccountLockedError` and `AuthRateLimitError` carry `retryAfterSeconds` (also
311
+ in `metadata`) and a `Retry-After` header in `headers`; `AccessDeniedError`
312
+ carries `metadata.requiredPermission`. Before 1.3, `TokenRevokedError` was
313
+ `403 ERR_FORBIDDEN`, and the lockout and deactivation errors shared
314
+ `ERR_FORBIDDEN` too, so a client could not tell them apart.
264
315
 
265
316
  `login()` throws the same `InvalidCredentialsError` for an unknown user and a
266
317
  wrong password, and performs equivalent scrypt work on both paths, so the
@@ -273,7 +324,8 @@ after the password has been proven correct.
273
324
  [`@zudojs/auth-oauth`](../auth-oauth), which implements the
274
325
  authorization-code flow with PKCE, mandatory `state`, and provider presets
275
326
  for Google, GitHub, Microsoft, Apple and Discord. Nothing OAuth-related is
276
- exported from this package any more.
327
+ exported from this package any more; once the callback has identified the
328
+ user, `createSessionForUser()` issues the session.
277
329
  - **No password-reset flow.** `generateRandomToken()` gives you a random
278
330
  token; storage, expiry, single-use enforcement and constant-time comparison
279
331
  are yours to build.
@@ -45,22 +45,16 @@ export declare class TokenInvalidError extends AuthError {
45
45
  constructor(message?: string, options?: AuthErrorOptions);
46
46
  }
47
47
  /**
48
- * Token has been revoked.
48
+ * Token has been revoked (logout, or a refresh token replayed after
49
+ * rotation).
50
+ *
51
+ * `401` with `ERR_TOKEN_REVOKED`: the credential is no longer valid, so the
52
+ * client must authenticate again. It was `403 ERR_FORBIDDEN`, which told a
53
+ * client that re-authenticating would not help.
49
54
  */
50
55
  export declare class TokenRevokedError extends AuthError {
51
56
  constructor(message?: string, options?: AuthErrorOptions);
52
57
  }
53
- /**
54
- * User account is locked (too many failed attempts).
55
- *
56
- * `423 Locked`; `metadata.retryAfterSeconds` is intended for a `Retry-After`
57
- * response header.
58
- */
59
- export declare class AccountLockedError extends AuthError {
60
- constructor(message?: string, options?: AuthErrorOptions & {
61
- readonly retryAfterSeconds?: number;
62
- });
63
- }
64
58
  /**
65
59
  * User account is deactivated.
66
60
  */
@@ -81,12 +75,4 @@ export declare class AccessDeniedError extends AuthError {
81
75
  export declare class SessionExpiredError extends AuthError {
82
76
  constructor(message?: string, options?: AuthErrorOptions);
83
77
  }
84
- /**
85
- * Rate limit exceeded for an auth endpoint.
86
- */
87
- export declare class AuthRateLimitError extends AuthError {
88
- constructor(message?: string, options?: AuthErrorOptions & {
89
- readonly retryAfterSeconds?: number;
90
- });
91
- }
92
78
  //# sourceMappingURL=authError.base.d.ts.map
@@ -73,46 +73,29 @@ export class TokenInvalidError extends AuthError {
73
73
  }
74
74
  }
75
75
  /**
76
- * Token has been revoked.
76
+ * Token has been revoked (logout, or a refresh token replayed after
77
+ * rotation).
78
+ *
79
+ * `401` with `ERR_TOKEN_REVOKED`: the credential is no longer valid, so the
80
+ * client must authenticate again. It was `403 ERR_FORBIDDEN`, which told a
81
+ * client that re-authenticating would not help.
77
82
  */
78
83
  export class TokenRevokedError extends AuthError {
79
84
  constructor(message = "Token has been revoked", options) {
80
85
  super(message, {
81
- code: ErrorCode.FORBIDDEN,
82
- category: ErrorCategory.AUTHORIZATION,
83
- statusCode: 403,
86
+ code: ErrorCode.TOKEN_REVOKED,
87
+ statusCode: 401,
84
88
  ...options,
85
89
  });
86
90
  }
87
91
  }
88
- /**
89
- * User account is locked (too many failed attempts).
90
- *
91
- * `423 Locked`; `metadata.retryAfterSeconds` is intended for a `Retry-After`
92
- * response header.
93
- */
94
- export class AccountLockedError extends AuthError {
95
- constructor(message = "Account is locked due to too many failed attempts", options) {
96
- const { retryAfterSeconds, metadata, ...rest } = options ?? {};
97
- super(message, {
98
- code: ErrorCode.FORBIDDEN,
99
- category: ErrorCategory.RATE_LIMIT,
100
- statusCode: 423,
101
- ...rest,
102
- metadata: {
103
- retryAfterSeconds: retryAfterSeconds ?? 900,
104
- ...metadata,
105
- },
106
- });
107
- }
108
- }
109
92
  /**
110
93
  * User account is deactivated.
111
94
  */
112
95
  export class AccountDeactivatedError extends AuthError {
113
96
  constructor(message = "User account is deactivated", options) {
114
97
  super(message, {
115
- code: ErrorCode.FORBIDDEN,
98
+ code: ErrorCode.ACCOUNT_DEACTIVATED,
116
99
  category: ErrorCategory.AUTHORIZATION,
117
100
  statusCode: 403,
118
101
  ...options,
@@ -149,22 +132,4 @@ export class SessionExpiredError extends AuthError {
149
132
  });
150
133
  }
151
134
  }
152
- /**
153
- * Rate limit exceeded for an auth endpoint.
154
- */
155
- export class AuthRateLimitError extends AuthError {
156
- constructor(message = "Too many authentication attempts", options) {
157
- const { retryAfterSeconds, metadata, ...rest } = options ?? {};
158
- super(message, {
159
- code: ErrorCode.RATE_LIMITED,
160
- category: ErrorCategory.RATE_LIMIT,
161
- statusCode: 429,
162
- ...rest,
163
- metadata: {
164
- retryAfterSeconds: retryAfterSeconds ?? 60,
165
- ...metadata,
166
- },
167
- });
168
- }
169
- }
170
135
  //# sourceMappingURL=authError.base.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Throttling errors: lockout and rate limit.
3
+ *
4
+ * Both carry `retryAfterSeconds` and a `Retry-After` header in `headers`.
5
+ * `@zudojs/http` copies an error's `headers` onto the response it answers
6
+ * the error with, so a client told `423 Locked` or `429 Too Many Requests`
7
+ * also learns when to try again; the lockout used to go out without one.
8
+ *
9
+ * @module authErrors/authError.throttle
10
+ */
11
+ import { AuthError, type AuthErrorOptions } from "@zudojs/errors";
12
+ /** Options shared by the throttling errors. */
13
+ export type ThrottleErrorOptions = AuthErrorOptions & {
14
+ /** Seconds until the client may retry. Rounded up; at least 1. */
15
+ readonly retryAfterSeconds?: number;
16
+ };
17
+ /**
18
+ * User account is locked (too many failed attempts).
19
+ *
20
+ * `423 Locked`, code `ERR_ACCOUNT_LOCKED`. `retryAfterSeconds` is the time
21
+ * left on the lockout, and `headers` carries it as `Retry-After`.
22
+ */
23
+ export declare class AccountLockedError extends AuthError {
24
+ /** Seconds until the lockout lapses. */
25
+ readonly retryAfterSeconds: number;
26
+ /** Response headers for this error: `Retry-After`. */
27
+ readonly headers: Readonly<Record<string, string>>;
28
+ constructor(message?: string, options?: ThrottleErrorOptions);
29
+ }
30
+ /**
31
+ * Rate limit exceeded for an auth endpoint.
32
+ *
33
+ * `429 Too Many Requests`; `headers` carries `Retry-After`.
34
+ */
35
+ export declare class AuthRateLimitError extends AuthError {
36
+ /** Seconds until the window resets. */
37
+ readonly retryAfterSeconds: number;
38
+ /** Response headers for this error: `Retry-After`. */
39
+ readonly headers: Readonly<Record<string, string>>;
40
+ constructor(message?: string, options?: ThrottleErrorOptions);
41
+ }
42
+ //# sourceMappingURL=authError.throttle.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Throttling errors: lockout and rate limit.
3
+ *
4
+ * Both carry `retryAfterSeconds` and a `Retry-After` header in `headers`.
5
+ * `@zudojs/http` copies an error's `headers` onto the response it answers
6
+ * the error with, so a client told `423 Locked` or `429 Too Many Requests`
7
+ * also learns when to try again; the lockout used to go out without one.
8
+ *
9
+ * @module authErrors/authError.throttle
10
+ */
11
+ import { AuthError, ErrorCategory, ErrorCode, } from "@zudojs/errors";
12
+ function toRetryAfter(value, fallback) {
13
+ if (value === undefined || !Number.isFinite(value))
14
+ return fallback;
15
+ return Math.max(1, Math.ceil(value));
16
+ }
17
+ function retryAfterHeaders(seconds) {
18
+ return Object.freeze({ "retry-after": String(seconds) });
19
+ }
20
+ /**
21
+ * User account is locked (too many failed attempts).
22
+ *
23
+ * `423 Locked`, code `ERR_ACCOUNT_LOCKED`. `retryAfterSeconds` is the time
24
+ * left on the lockout, and `headers` carries it as `Retry-After`.
25
+ */
26
+ export class AccountLockedError extends AuthError {
27
+ /** Seconds until the lockout lapses. */
28
+ retryAfterSeconds;
29
+ /** Response headers for this error: `Retry-After`. */
30
+ headers;
31
+ constructor(message = "Account is locked due to too many failed attempts", options) {
32
+ const { retryAfterSeconds, metadata, ...rest } = options ?? {};
33
+ const seconds = toRetryAfter(retryAfterSeconds, 900);
34
+ super(message, {
35
+ code: ErrorCode.ACCOUNT_LOCKED,
36
+ category: ErrorCategory.RATE_LIMIT,
37
+ statusCode: 423,
38
+ ...rest,
39
+ metadata: { retryAfterSeconds: seconds, ...metadata },
40
+ });
41
+ this.retryAfterSeconds = seconds;
42
+ this.headers = retryAfterHeaders(seconds);
43
+ }
44
+ }
45
+ /**
46
+ * Rate limit exceeded for an auth endpoint.
47
+ *
48
+ * `429 Too Many Requests`; `headers` carries `Retry-After`.
49
+ */
50
+ export class AuthRateLimitError extends AuthError {
51
+ /** Seconds until the window resets. */
52
+ retryAfterSeconds;
53
+ /** Response headers for this error: `Retry-After`. */
54
+ headers;
55
+ constructor(message = "Too many authentication attempts", options) {
56
+ const { retryAfterSeconds, metadata, ...rest } = options ?? {};
57
+ const seconds = toRetryAfter(retryAfterSeconds, 60);
58
+ super(message, {
59
+ code: ErrorCode.RATE_LIMITED,
60
+ category: ErrorCategory.RATE_LIMIT,
61
+ statusCode: 429,
62
+ ...rest,
63
+ metadata: { retryAfterSeconds: seconds, ...metadata },
64
+ });
65
+ this.retryAfterSeconds = seconds;
66
+ this.headers = retryAfterHeaders(seconds);
67
+ }
68
+ }
69
+ //# sourceMappingURL=authError.throttle.js.map
@@ -3,5 +3,6 @@
3
3
  *
4
4
  * @module authErrors
5
5
  */
6
- export { AuthError, type AuthErrorOptions, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
6
+ export { AuthError, type AuthErrorOptions, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, } from "./authError.base.js";
7
+ export { AccountLockedError, AuthRateLimitError, type ThrottleErrorOptions, } from "./authError.throttle.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,6 @@
3
3
  *
4
4
  * @module authErrors
5
5
  */
6
- export { AuthError, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
6
+ export { AuthError, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, } from "./authError.base.js";
7
+ export { AccountLockedError, AuthRateLimitError, } from "./authError.throttle.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -5,8 +5,10 @@
5
5
  */
6
6
  /**
7
7
  * Check if a password hash needs rehashing: every hash that is not a
8
- * `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
9
- * and key length). All legacy `scrypt$…` hashes return `true`.
8
+ * `@zudojs/crypto` scrypt hash with the current parameters. Current means
9
+ * N, r and p equal to this package's policy, with the salt and key lengths
10
+ * either this package writes (32/64 bytes) or `@zudojs/crypto` writes by
11
+ * default (16/32 bytes). All legacy `scrypt$…` hashes return `true`.
10
12
  *
11
13
  * @param hashedPassword - The stored hash
12
14
  * @returns Whether the hash should be regenerated
@@ -3,12 +3,28 @@
3
3
  *
4
4
  * @module authPassword/authPassword.rehash
5
5
  */
6
- import { CryptoAlgorithm, decodePasswordHash } from "@zudojs/crypto";
6
+ import { CryptoAlgorithm, decodePasswordHash, getDefaultPasswordHashOptions, } from "@zudojs/crypto";
7
7
  import { KEY_LENGTH, SALT_LENGTH, SCRYPT_N, SCRYPT_P, SCRYPT_R, } from "./authPassword.policy.js";
8
+ /**
9
+ * Salt and key lengths a current hash may have: the ones this package's
10
+ * `hashPassword()` writes, and the ones `@zudojs/crypto`'s `hashPassword()`
11
+ * writes with its own defaults. Both use the same scrypt N, r and p, and
12
+ * both are current — flagging a crypto-default hash made every login
13
+ * rewrite it for nothing.
14
+ */
15
+ function currentLengths() {
16
+ const crypto = getDefaultPasswordHashOptions();
17
+ return [
18
+ [SALT_LENGTH, KEY_LENGTH],
19
+ [crypto.saltBytes, crypto.keyBytes],
20
+ ];
21
+ }
8
22
  /**
9
23
  * Check if a password hash needs rehashing: every hash that is not a
10
- * `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
11
- * and key length). All legacy `scrypt$…` hashes return `true`.
24
+ * `@zudojs/crypto` scrypt hash with the current parameters. Current means
25
+ * N, r and p equal to this package's policy, with the salt and key lengths
26
+ * either this package writes (32/64 bytes) or `@zudojs/crypto` writes by
27
+ * default (16/32 bytes). All legacy `scrypt$…` hashes return `true`.
12
28
  *
13
29
  * @param hashedPassword - The stored hash
14
30
  * @returns Whether the hash should be regenerated
@@ -20,11 +36,12 @@ export function needsRehash(hashedPassword) {
20
36
  const decoded = decodePasswordHash(hashedPassword);
21
37
  if (decoded.algorithm !== CryptoAlgorithm.SCRYPT)
22
38
  return true;
23
- return (decoded.salt.byteLength !== SALT_LENGTH ||
24
- decoded.hash.byteLength !== KEY_LENGTH ||
25
- decoded.cost !== SCRYPT_N ||
39
+ if (decoded.cost !== SCRYPT_N ||
26
40
  decoded.blockSize !== SCRYPT_R ||
27
- decoded.parallelization !== SCRYPT_P);
41
+ decoded.parallelization !== SCRYPT_P) {
42
+ return true;
43
+ }
44
+ return !currentLengths().some(([salt, key]) => decoded.salt.byteLength === salt && decoded.hash.byteLength === key);
28
45
  }
29
46
  catch {
30
47
  return true;
@@ -7,6 +7,7 @@ import type { AuthUser, UserCredentials, UserId } from "../authTypes/authUser.ty
7
7
  import type { TokenPair, TokenPayload, TokenConfig, TokenRevocationStore } from "../authTypes/authToken.type.js";
8
8
  import type { SessionStore, SessionId } from "../authTypes/authSession.type.js";
9
9
  import type { LoginThrottleConfig } from "../authTypes/authAttempt.type.js";
10
+ import { type ExternalSessionOptions } from "./authProvider.external.js";
10
11
  import type { GuardContext, GuardResult } from "../authTypes/authRbac.type.js";
11
12
  import type { PermissionEngine } from "@zudojs/permissions";
12
13
  export { throttleKey } from "./authProvider.throttle.js";
@@ -82,6 +83,23 @@ export interface AuthServiceConfig {
82
83
  * and verify them through this service.
83
84
  */
84
85
  readonly allowSessionlessTokens?: boolean;
86
+ /**
87
+ * Methods `createSessionForUser()` may start a session for, e.g.
88
+ * `["oauth"]`. Default: none, so `createSessionForUser()` throws.
89
+ *
90
+ * That method checks no credential — your code asserts the user is
91
+ * already authenticated — so it is off until you name the flows that
92
+ * verify authentication themselves (an OAuth callback that validated
93
+ * `state` and exchanged the code).
94
+ */
95
+ readonly externalSessionMethods?: readonly string[];
96
+ /**
97
+ * How `login()` normalizes the submitted identifier before `findUser()`
98
+ * sees it. Default: {@link normalizeLoginIdentifier} (NFKC, trim, and
99
+ * lower-case for an email). Pass `false` to hand `findUser()` the raw
100
+ * string, or your own function to match how you store identifiers.
101
+ */
102
+ readonly normalizeIdentifier?: false | ((identifier: string) => string);
85
103
  }
86
104
  /**
87
105
  * Auth service interface.
@@ -101,6 +119,18 @@ export interface AuthService {
101
119
  refresh(refreshToken: string): Promise<TokenPair>;
102
120
  logout(sessionId: SessionId, refreshToken?: string): Promise<void>;
103
121
  logoutAll(userId: UserId): Promise<void>;
122
+ /**
123
+ * Start a session for a user your code has already authenticated some
124
+ * other way — OAuth (`@zudojs/auth-oauth`), a passkey, a magic link — and
125
+ * return tokens exactly as `login()` does, without a password check.
126
+ *
127
+ * Only for methods listed in `externalSessionMethods`; otherwise it throws
128
+ * `AuthConfigurationError`. The user is loaded with `findUserById()`, and
129
+ * an unknown user (`InvalidCredentialsError`) or a deactivated one
130
+ * (`AccountDeactivatedError`) is refused. Never pass it a user id taken
131
+ * from the request.
132
+ */
133
+ createSessionForUser(userId: UserId, options: ExternalSessionOptions): Promise<LoginResult>;
104
134
  checkAccess(context: GuardContext): Promise<GuardResult>;
105
135
  hashPassword(password: string): Promise<string>;
106
136
  verifyPasswordHash(password: string, hash: string): Promise<boolean>;
@@ -4,6 +4,8 @@
4
4
  * @module authProvider/authProvider
5
5
  */
6
6
  import { createLoginThrottleGate } from "./authProvider.throttle.js";
7
+ import { createExternalSessionStarter, } from "./authProvider.external.js";
8
+ import { normalizeLoginIdentifier } from "../authUtils/authUtils.identifier.js";
7
9
  import { hashPassword, verifyPassword, } from "../authPassword/authPassword.core.js";
8
10
  import { createTokenPair, verifyAccessToken, verifyRefreshToken, } from "../authToken/authToken.core.js";
9
11
  import { assertTokenSecrets } from "../authToken/authToken.signing.js";
@@ -22,7 +24,7 @@ export { throttleKey } from "./authProvider.throttle.js";
22
24
  * Create an auth service.
23
25
  */
24
26
  export function createAuthService(config) {
25
- const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, allowSessionlessTokens, } = config;
27
+ const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, allowSessionlessTokens, externalSessionMethods, normalizeIdentifier, } = config;
26
28
  // Fail at construction, not at the first login: a bad secret or a NaN
27
29
  // lifetime (`Number(process.env.X)` with X unset) otherwise surfaced as
28
30
  // a runtime error on the request path — or, for the session TTL, not at
@@ -31,6 +33,17 @@ export function createAuthService(config) {
31
33
  assertPositiveSeconds(sessionTtlSeconds, "sessionTtlSeconds");
32
34
  assertPositiveSeconds(absoluteSessionTtlSeconds, "absoluteSessionTtlSeconds");
33
35
  const throttle = createLoginThrottleGate(loginThrottle);
36
+ const normalize = normalizeIdentifier === false
37
+ ? (identifier) => identifier
38
+ : (normalizeIdentifier ?? normalizeLoginIdentifier);
39
+ const createSessionForUser = createExternalSessionStarter({
40
+ methods: externalSessionMethods,
41
+ findUserById,
42
+ sessionStore,
43
+ tokenConfig,
44
+ sessionTtlSeconds,
45
+ absoluteSessionTtlSeconds,
46
+ });
34
47
  /**
35
48
  * Reject the token unless the session it was issued against is still
36
49
  * alive; refresh the session's idle timer when it is.
@@ -72,9 +85,12 @@ export function createAuthService(config) {
72
85
  * deactivated account are indistinguishable to the caller: the same
73
86
  * `InvalidCredentialsError` is thrown, and the unknown-user path performs
74
87
  * the same scrypt work as the known-user path.
88
+ *
89
+ * The identifier is normalized first (see `normalizeIdentifier`), so
90
+ * `findUser()` and the lockout counters see one spelling of it.
75
91
  */
76
92
  async login(credentials, context) {
77
- const identifier = credentials.identifier;
93
+ const identifier = normalize(credentials.identifier);
78
94
  const slot = await throttle.begin(identifier);
79
95
  const user = await findUser(identifier);
80
96
  let authenticated = false;
@@ -197,6 +213,7 @@ export function createAuthService(config) {
197
213
  async logoutAll(userId) {
198
214
  await sessionStore.destroyAllForUser(userId);
199
215
  },
216
+ createSessionForUser,
200
217
  /**
201
218
  * Check if a user has a specific permission.
202
219
  * Delegates to the @zudojs/permissions engine when configured.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Sessions for users authenticated outside `login()` — OAuth, passkeys,
3
+ * magic links.
4
+ *
5
+ * @module authProvider/authProvider.external
6
+ */
7
+ import type { AuthUser, UserId } from "../authTypes/authUser.type.js";
8
+ import type { TokenConfig, TokenPair } from "../authTypes/authToken.type.js";
9
+ import type { SessionId, SessionStore } from "../authTypes/authSession.type.js";
10
+ /** Options for `AuthService.createSessionForUser()`. */
11
+ export interface ExternalSessionOptions {
12
+ /**
13
+ * How your code authenticated the user — `"oauth"`, `"passkey"`,
14
+ * `"magic-link"`. It must be listed in the service's
15
+ * `externalSessionMethods`, and is recorded on the session as
16
+ * `metadata.authMethod`.
17
+ */
18
+ readonly method: string;
19
+ /** Client user-agent string, recorded on the session. */
20
+ readonly userAgent?: string;
21
+ /** Client IP address, recorded on the session. */
22
+ readonly ip?: string;
23
+ /** Extra session metadata, e.g. `{ provider: "github" }`. */
24
+ readonly metadata?: Readonly<Record<string, unknown>>;
25
+ }
26
+ /** What `createSessionForUser()` returns: the same shape as `login()`. */
27
+ export interface ExternalSessionResult {
28
+ readonly user: AuthUser;
29
+ readonly tokens: TokenPair;
30
+ readonly sessionId: SessionId;
31
+ }
32
+ /** The parts of the service configuration the starter needs. */
33
+ export interface ExternalSessionDependencies {
34
+ readonly methods: readonly string[] | undefined;
35
+ readonly findUserById: (userId: UserId) => Promise<AuthUser | null>;
36
+ readonly sessionStore: SessionStore;
37
+ readonly tokenConfig: TokenConfig;
38
+ readonly sessionTtlSeconds: number;
39
+ readonly absoluteSessionTtlSeconds: number | undefined;
40
+ }
41
+ /**
42
+ * Build `createSessionForUser()`.
43
+ *
44
+ * It performs no credential check: the caller asserts the user is already
45
+ * authenticated. It is therefore off unless the service lists the method in
46
+ * `externalSessionMethods` — a route that forwards a client-supplied user id
47
+ * cannot reach it by default — and it still refuses unknown and deactivated
48
+ * users.
49
+ */
50
+ export declare function createExternalSessionStarter(deps: ExternalSessionDependencies): (userId: UserId, options: ExternalSessionOptions) => Promise<ExternalSessionResult>;
51
+ //# sourceMappingURL=authProvider.external.d.ts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Sessions for users authenticated outside `login()` — OAuth, passkeys,
3
+ * magic links.
4
+ *
5
+ * @module authProvider/authProvider.external
6
+ */
7
+ import { createTokenPair } from "../authToken/authToken.core.js";
8
+ import { AccountDeactivatedError, AuthConfigurationError, InvalidCredentialsError, } from "../authErrors/authError.base.js";
9
+ /**
10
+ * Build `createSessionForUser()`.
11
+ *
12
+ * It performs no credential check: the caller asserts the user is already
13
+ * authenticated. It is therefore off unless the service lists the method in
14
+ * `externalSessionMethods` — a route that forwards a client-supplied user id
15
+ * cannot reach it by default — and it still refuses unknown and deactivated
16
+ * users.
17
+ */
18
+ export function createExternalSessionStarter(deps) {
19
+ const allowed = new Set(deps.methods ?? []);
20
+ return async (userId, options) => {
21
+ const method = options?.method;
22
+ if (typeof method !== "string" || !allowed.has(method)) {
23
+ throw new AuthConfigurationError(`createSessionForUser(): method "${String(method)}" is not enabled. ` +
24
+ "List it in `externalSessionMethods` once your code verifies that " +
25
+ "authentication itself (e.g. the OAuth callback).");
26
+ }
27
+ if (typeof userId !== "string" || userId.length === 0) {
28
+ throw new InvalidCredentialsError();
29
+ }
30
+ const user = await deps.findUserById(userId);
31
+ if (!user)
32
+ throw new InvalidCredentialsError();
33
+ if (!user.active)
34
+ throw new AccountDeactivatedError();
35
+ const session = await deps.sessionStore.create({
36
+ userId: user.id,
37
+ userAgent: options.userAgent,
38
+ ip: options.ip,
39
+ ttlSeconds: deps.sessionTtlSeconds,
40
+ absoluteTtlSeconds: deps.absoluteSessionTtlSeconds,
41
+ metadata: { ...options.metadata, authMethod: method },
42
+ });
43
+ const tokens = createTokenPair(user.id, deps.tokenConfig, {
44
+ roles: user.roles,
45
+ sessionId: session.id,
46
+ });
47
+ return { user, tokens, sessionId: session.id };
48
+ };
49
+ }
50
+ //# sourceMappingURL=authProvider.external.js.map
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @module authProvider/authProvider.throttle
6
6
  */
7
- import { AccountLockedError, AuthRateLimitError, } from "../authErrors/authError.base.js";
7
+ import { AccountLockedError, AuthRateLimitError, } from "../authErrors/authError.throttle.js";
8
8
  const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
9
9
  const DEFAULT_LOCKOUT_SECONDS = 900;
10
10
  const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export { createAuthService, type AuthService, type AuthServiceConfig, type LoginResult, type UserLookup, type UserByIdLookup, type PasswordVerifier, } from "./authProvider.core.js";
7
7
  export { createMemoryLoginAttemptStore } from "./authAttempt.memory.js";
8
+ export { type ExternalSessionOptions, type ExternalSessionResult, } from "./authProvider.external.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export { createAuthService, } from "./authProvider.core.js";
7
7
  export { createMemoryLoginAttemptStore } from "./authAttempt.memory.js";
8
+ export {} from "./authProvider.external.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Login identifier normalization.
3
+ *
4
+ * @module authUtils/authUtils.identifier
5
+ */
6
+ /**
7
+ * Normalize a login identifier the way `login()` does before looking the
8
+ * user up: Unicode-compatibility fold (NFKC) and trim, then lower-case the
9
+ * whole identifier when it is an email address. A username keeps its case.
10
+ *
11
+ * Store identifiers through this same function at registration, so
12
+ * `" Alice@Example.com"` at sign-in finds the account registered as
13
+ * `alice@example.com`. The lockout counters are keyed case-insensitively
14
+ * independently of this, so case variants never get their own attempt
15
+ * budget either way.
16
+ *
17
+ * @param identifier - The identifier as submitted.
18
+ * @returns The normalized identifier.
19
+ */
20
+ export declare function normalizeLoginIdentifier(identifier: string): string;
21
+ //# sourceMappingURL=authUtils.identifier.d.ts.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Login identifier normalization.
3
+ *
4
+ * @module authUtils/authUtils.identifier
5
+ */
6
+ import { isEmail } from "@zudojs/types";
7
+ /**
8
+ * Normalize a login identifier the way `login()` does before looking the
9
+ * user up: Unicode-compatibility fold (NFKC) and trim, then lower-case the
10
+ * whole identifier when it is an email address. A username keeps its case.
11
+ *
12
+ * Store identifiers through this same function at registration, so
13
+ * `" Alice@Example.com"` at sign-in finds the account registered as
14
+ * `alice@example.com`. The lockout counters are keyed case-insensitively
15
+ * independently of this, so case variants never get their own attempt
16
+ * budget either way.
17
+ *
18
+ * @param identifier - The identifier as submitted.
19
+ * @returns The normalized identifier.
20
+ */
21
+ export function normalizeLoginIdentifier(identifier) {
22
+ const trimmed = String(identifier).normalize("NFKC").trim();
23
+ return isEmail(trimmed) ? trimmed.toLowerCase() : trimmed;
24
+ }
25
+ //# sourceMappingURL=authUtils.identifier.js.map
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Auth utility helpers: token parsing, cookie parsing, CSRF.
2
+ * Auth utility helpers: token parsing, cookie parsing, CSRF, login
3
+ * identifier normalization.
3
4
  *
4
5
  * @module authUtils
5
6
  */
6
7
  export { parseBearerToken, parseCookies, isTokenExpired, extractUserId, generateCsrfToken, } from "./authUtils.helper.js";
8
+ export { normalizeLoginIdentifier } from "./authUtils.identifier.js";
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Auth utility helpers: token parsing, cookie parsing, CSRF.
2
+ * Auth utility helpers: token parsing, cookie parsing, CSRF, login
3
+ * identifier normalization.
3
4
  *
4
5
  * @module authUtils
5
6
  */
6
7
  export { parseBearerToken, parseCookies, isTokenExpired, extractUserId, generateCsrfToken, } from "./authUtils.helper.js";
8
+ export { normalizeLoginIdentifier } from "./authUtils.identifier.js";
7
9
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/auth",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Authentication and authorization services for the Zudojs framework — JWT, sessions, RBAC, and password management.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -25,17 +25,18 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/constants": "1.1.0",
29
- "@zudojs/crypto": "1.2.0",
30
- "@zudojs/errors": "1.1.0",
31
- "@zudojs/permissions": "1.2.0"
28
+ "@zudojs/constants": "1.1.2",
29
+ "@zudojs/crypto": "1.3.1",
30
+ "@zudojs/errors": "1.3.0",
31
+ "@zudojs/permissions": "1.4.0",
32
+ "@zudojs/types": "1.2.0"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=24.0.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "typescript": "7.0.2",
38
- "vitest": "^4.1.11"
39
+ "vitest": "^5.0.1"
39
40
  },
40
41
  "publishConfig": {
41
42
  "access": "public"
@@ -48,7 +49,7 @@
48
49
  "sessions",
49
50
  "rbac"
50
51
  ],
51
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
52
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-auth",
52
53
  "bugs": {
53
54
  "url": "https://github.com/oyinlola-tech/zudo/issues"
54
55
  },