@shtora/auth 0.1.0 → 0.1.1

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
@@ -1,15 +1,27 @@
1
1
  # @shtora/auth
2
2
 
3
+ [Repository](https://github.com/orgShtora-1/auth) · [Issues](https://github.com/orgShtora-1/auth/issues)
4
+
3
5
  Framework-neutral authentication handlers and session management for Next.js and Cloudflare Workers. The core uses standard `Request`/`Response` APIs and Web Crypto; optional entry points add Cloudflare D1, OIDC, and WebAuthn support.
4
6
 
5
7
  Your application owns its user records, credential verification, password hashing, email delivery, and persistent stores. The package does not hash, encrypt, or persist passwords and includes no ORM.
6
8
 
7
9
  ## Install
8
10
 
11
+ Install the published package from npm:
12
+
13
+ ```sh
14
+ bun add @shtora/auth@latest
15
+ ```
16
+
17
+ To install a beta release:
18
+
9
19
  ```sh
10
- bun add @shtora/auth
20
+ bun add @shtora/auth@beta
11
21
  ```
12
22
 
23
+ The `beta` tag must point to a published beta version. It is not currently available.
24
+
13
25
  Install optional protocol dependencies only when you use those entry points:
14
26
 
15
27
  ```sh
@@ -93,7 +105,7 @@ function getAuth(env: Env) {
93
105
  credentials,
94
106
  sessions: createD1SessionStore(env.DB),
95
107
  rateLimiter: createD1RateLimiter(env.DB, {
96
- email: { limit: 5, windowSeconds: 60 },
108
+ identifier: { limit: 5, windowSeconds: 60 },
97
109
  ip: { limit: 100, windowSeconds: 60 },
98
110
  }),
99
111
  resolveClientIp: (request) => request.headers.get("cf-connecting-ip"),
@@ -112,7 +124,7 @@ export default {
112
124
  };
113
125
  ```
114
126
 
115
- Apply the SQL files in `migrations/` to the D1 database before handling requests. D1 cleanup is bounded; schedule it often enough for your session and rate-limit volume. Workers Free and Paid use the same package code, subject to each plan's D1, storage, and CPU quotas. Local Worker tests do not measure production quotas.
127
+ The npm package omits SQL files; see the [Cloudflare D1 adapter](#cloudflare-d1-adapter) section for the pinned migration source and application instructions. D1 cleanup is bounded; schedule it often enough for your session and rate-limit volume. Workers Free and Paid use the same package code, subject to each plan's D1, storage, and CPU quotas.
116
128
 
117
129
  ## Routes
118
130
 
@@ -133,12 +145,44 @@ The default base path is `/api/auth`; set `basePath` to change it.
133
145
  | `POST` | `/api/auth/password-reset/confirm` | Optional; revokes sessions, then delegates the password update to the application. |
134
146
 
135
147
  State-changing POST and DELETE requests require a same-origin `Origin` header by default. Read-only session GET requests allow a missing `Origin`. Keep this check enabled for browser cookie authentication unless equivalent CSRF protection is in place.
148
+ ## Registration and login identifiers
149
+
150
+ Email-only behavior is the default. `identity.registration` accepts `"email"`, `"username"`, `"both"`, or `"either"`; `"either"` requires exactly one identifier. `identity.login` accepts `"email"`, `"username"`, or `"either"`, also requiring exactly one login identifier. Compatible pairs are email/email, username/username, either/either, and both with any login mode; other pairs fail during `createAuth`.
151
+
152
+ ```ts
153
+ createAuth({
154
+ users,
155
+ credentials,
156
+ sessions,
157
+ identity: { registration: "either", login: "either" },
158
+ passwordPolicy: {
159
+ minLength: 12,
160
+ maxLength: 128,
161
+ requireUppercase: true,
162
+ requireLowercase: true,
163
+ requireSpecial: true,
164
+ },
165
+ });
166
+ ```
167
+
168
+ Registration accepts `{ email, password }`, `{ username, password }`, both identifiers with `registration: "both"`, or exactly one identifier with `"either"`. Login accepts the configured identifier field and a password; `"either"` rejects requests containing both. Unknown JSON keys are ignored, but supplying a disabled email/username field is invalid. Password is always required.
169
+
170
+ Usernames are 3–32 ASCII letters, digits, `.`, `_`, or `-`; they are canonicalized to lowercase without trimming. The application’s `UserStore.findByIdentifier` must use the same canonical form, and `create` must enforce email/username uniqueness atomically with database constraints. Email input is lowercased; whitespace is rejected. Username syntax excludes `@`, so valid email and username values cannot collide across namespaces. Lookup and rate limiting carry an explicit `"email"`/`"username"` discriminator.
171
+
172
+ `AuthUser` contains an opaque `id` and optional `email`/`username`. The package returns only `id` and present email/username fields; it never spreads adapter fields into a public response. OIDC and WebAuthn continue to map identities through the application’s opaque user ID.
173
+
174
+ `UserStore` results must contain exactly the configured identity shape before a session is created: email mode requires only email, username mode only username, both requires both, and either requires exactly one. Missing or invalid canonical identifiers fail closed and are reported through `onError`; no partial identity response is issued.
175
+
176
+ The default password policy is min 1 and max 4096 Unicode code points, with uppercase/lowercase/special requirements disabled. Policy checks run during registration and password-reset confirmation, not login. Passwords are neither trimmed nor normalized. “Special” means a Unicode code point that is not a letter, number, or whitespace. The 4096-code-point hard cap is independent of the request-body byte limit. Invalid shape or policy returns the generic `400 { "error": "invalid_request" }`.
177
+
178
+ Email verification requires registration mode `"email"` or `"both"` and rejects incompatible configurations at `createAuth`. Password-reset request remains email-address-only; username-only accounts and `"either"` accounts created without an email cannot request an email reset. `emailPolicy` denies email-less users, including session creation through OIDC. No additional migration is needed for this identity-mode change: D1 rate-limit table keys store only digests, with identifier kind included in the hashed key.
179
+
136
180
 
137
181
  ## Email verification and password reset
138
182
 
139
183
  These flows are opt-in. Configure the matching options with an `AuthActionTokenStore` and a delivery callback. The store persists only a SHA-256 token hash, atomically replaces an earlier token for the same user and purpose, consumes the current unexpired token once, and implements bounded expiration cleanup. The callback receives the raw bearer token: enqueue it promptly and never log it. Submit tokens to the POST confirmation routes rather than consuming them in GET requests, which link scanners may follow.
140
184
 
141
- Email verification also requires `isVerified` and `markVerified`. When enabled, unverified users cannot log in or use existing sessions. Password reset delegates password hashing and persistence to `setPassword`; sessions are revoked before that callback runs. If password persistence fails, the user remains signed out but the old password may still work. The application remains responsible for password policy and hash configuration.
185
+ Email verification also requires `isVerified` and `markVerified`. When enabled, unverified users cannot log in or use existing sessions. The configured package `passwordPolicy` applies to registration and password-reset confirmation. Reset sessions are revoked before `setPassword` delegates hashing and persistence to the application; if persistence fails, the user remains signed out but the old password may still work. The application owns hashing, persistence, and any policy checks for password flows outside these package handlers.
142
186
 
143
187
  Known and unknown addresses receive the same request response, though delivery timing can differ. Use asynchronous delivery and configure rate limiting for public auth endpoints.
144
188
 
@@ -163,25 +207,29 @@ The application also owns persistent storage for action tokens, OIDC transaction
163
207
 
164
208
  ### OIDC
165
209
 
166
- Import `createOidc` from `@shtora/auth/oidc`. It uses state, nonce, and PKCE, checks the fixed callback URL, and consumes a matching unexpired transaction once. The application owns `OidcTransactionStore`, issuer/client configuration, identity-to-user mapping, and session creation. Use an HTTPS issuer and pre-registered callback URL; schedule the returned `cleanupExpired()` method.
210
+ Import `createOidc` and its types from `@shtora/auth/oidc`. Required options are `issuer` (HTTPS issuer URL), `clientId`, an absolute HTTPS `callbackUrl` registered with the provider, a fixed same-origin `successPath`, a persistent `transactions` store, `resolveUser`, `auth`, a trusted `resolveClientIp`, and a `rateLimiter`. `auth` is the `createAuth` result; `resolveUser` maps the verified `{ issuer, subject }` to an application user or `null`.
211
+
212
+ The returned handler accepts GET `/start` and GET `/callback` paths. Mount it at the callback URL's origin and route both paths to it; register the exact callback URL with the identity provider. It uses state, nonce, PKCE, and one-use transaction consumption. Schedule its `cleanupExpired()` method.
167
213
 
168
214
  ### WebAuthn and passkeys
169
215
 
170
- Import `createWebAuthn` from `@shtora/auth/webauthn`. Registration requires an authenticated user flow and a stable, non-PII application user ID of at most 64 UTF-8 bytes. Configure the exact expected origin and RP ID, persistent one-use challenge storage, unique credential IDs, compare-and-set counter updates, trusted client-IP resolution, and a request rate limiter. Schedule the returned `cleanupExpired()` method.
216
+ Import `createWebAuthn` and its types from `@shtora/auth/webauthn`. Required options are the exact `expectedOrigin` (HTTPS, or localhost HTTP for development), matching `rpID`, `rpName`, persistent one-use `challenges` and `credentials` stores, `resolveRegistrationUser`, `auth`, a trusted `resolveClientIp`, and a `rateLimiter`. `auth` is the `createAuth` result. Registration requires an authenticated application user with a stable, non-PII ID of at most 64 UTF-8 bytes.
171
217
 
172
- ## Cloudflare D1 adapter
218
+ Forward POST `/webauthn/registration/options`, `/webauthn/registration/verify`, `/webauthn/authentication/options`, and `/webauthn/authentication/verify` to the returned handler. It verifies the configured origin and RP ID, requires user verification, and uses one-use challenges. Credential IDs must be unique and counter updates atomic compare-and-set operations. Schedule the returned `cleanupExpired()` method.
173
219
 
174
- `@shtora/auth/cloudflare` exports D1 session, action-token, and fixed-window rate-limit adapters. The core entry point does not import Cloudflare code. Rate-limit keys are hashed before storage; the counters remain scoped to one D1 database and consume D1 write capacity. Cloudflare's Workers Rate Limit binding is location-local and approximate, not a globally strict limiter.
220
+ ## Public entry points
175
221
 
176
- Run all SQL migrations in order. `cleanupD1AuthState` removes bounded batches of expired sessions, action tokens, and rate-limit records; set `rateLimitMaxWindowSeconds` to at least the longest configured rate-limit window. See the [D1 Worker API](https://developers.cloudflare.com/d1/worker-api/d1-database/) and [Workers Rate Limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
222
+ - `@shtora/auth`: `createAuth` and the core types for users, credentials, sessions, rate limiting, policies, and optional email verification/password reset.
223
+ - `@shtora/auth/cloudflare`: `D1Database`, D1 session/action-token stores, core and request rate limiters, and D1 auth-state cleanup.
224
+ - `@shtora/auth/oidc`: `createOidc` and OIDC configuration, transaction-store, and handler types.
225
+ - `@shtora/auth/webauthn`: `createWebAuthn` and WebAuthn challenge-store, credential-store, options, and user types.
177
226
 
178
- ## Development
227
+ Install `openid-client` for `@shtora/auth/oidc` and `@simplewebauthn/server` for `@shtora/auth/webauthn`; these optional peer dependencies are not needed by the core entry point.
228
+
229
+ ## Cloudflare D1 adapter
230
+
231
+ `@shtora/auth/cloudflare` exports `createD1SessionStore`, `createD1ActionTokenStore`, `createD1RateLimiter`, `createD1RequestRateLimiter`, and `cleanupD1AuthState`. `createD1RateLimiter` implements the core auth limiter for registration, login, email verification, and password reset; `createD1RequestRateLimiter` implements the separate request limiter required by OIDC and WebAuthn using `{ limit, windowSeconds }`. Both use hashed keys in the same `shtora_rate_limits` table.
232
+
233
+ The npm package omits SQL files; apply migrations in filename order from the [pinned repository source](https://github.com/orgShtora-1/auth/tree/694089c260cdcc229b78b20a6b3a90f92eb38fd6/migrations), using `wrangler d1 execute <database-name> --remote --file <migration.sql>`. If schemas diverge in a later release, use the repository commit matching your installed package version. `cleanupD1AuthState` deletes bounded batches of expired sessions, action tokens, and rate-limit records; set `rateLimitMaxWindowSeconds` to at least the longest configured rate-limit window. D1 counters share one database and consume D1 write capacity. Cloudflare's Workers Rate Limit binding is location-local and approximate, not a globally strict limiter. See the [D1 Worker API](https://developers.cloudflare.com/d1/worker-api/d1-database/) and [Workers Rate Limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
179
234
 
180
- ```sh
181
- bun install
182
- bun run typecheck
183
- bun test
184
- bun run build
185
- ```
186
235
 
187
- Changes to auth behavior should include tests for the affected boundary or state transition. Keep package documentation in English, update this README when public contracts change, and avoid adding generated output to commits. See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
package/dist/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { clearedSessionCookie, readSessionToken, sessionCookie } from "./cookies.js";
2
2
  import { createSessionToken, hashSessionToken } from "./crypto.js";
3
- import { parseActionTokenRequest, parseCredentials, parseEmailRequest, parsePasswordReset, readJson, RequestError } from "./request.js";
3
+ import { parseActionTokenRequest, parseEmailRequest, parseLoginCredentials, parsePasswordReset, parseRegistrationCredentials, readJson, RequestError } from "./request.js";
4
4
  const defaultBasePath = "/api/auth";
5
5
  const defaultCookieName = "__Host-shtora.session";
6
6
  const defaultSessionTtl = 60 * 60 * 24 * 7;
@@ -9,12 +9,42 @@ const defaultMaxRequestBytes = 8192;
9
9
  const defaultVerificationTokenTtl = 60 * 60;
10
10
  const defaultResetTokenTtl = 15 * 60;
11
11
  const maxActionTokenTtl = 60 * 60 * 24;
12
+ const defaultPasswordPolicy = { minLength: 1, maxLength: 4096 };
13
+ const hardMaxPasswordLength = 4096;
12
14
  export function createAuth(options) {
13
15
  const basePath = options.basePath ?? defaultBasePath;
14
16
  const cookieName = options.cookieName ?? defaultCookieName;
15
17
  const sessionTtlSeconds = options.sessionTtlSeconds ?? defaultSessionTtl;
16
18
  const maxRequestBytes = options.maxRequestBytes ?? defaultMaxRequestBytes;
17
19
  const requireOrigin = options.requireOrigin ?? true;
20
+ const registrationMode = options.identity?.registration ?? "email";
21
+ const loginMode = options.identity?.login ?? "email";
22
+ const passwordPolicy = { ...defaultPasswordPolicy, ...options.passwordPolicy };
23
+ if (!["email", "username", "both", "either"].includes(registrationMode) ||
24
+ !["email", "username", "either"].includes(loginMode)) {
25
+ throw new TypeError("identity modes are invalid");
26
+ }
27
+ if ((registrationMode === "email" && loginMode !== "email") ||
28
+ (registrationMode === "username" && loginMode !== "username") ||
29
+ (registrationMode === "either" && loginMode !== "either")) {
30
+ throw new TypeError("identity login mode is incompatible with registration mode");
31
+ }
32
+ if (!Number.isInteger(passwordPolicy.minLength) || passwordPolicy.minLength < 1 ||
33
+ !Number.isInteger(passwordPolicy.maxLength) || passwordPolicy.maxLength > hardMaxPasswordLength ||
34
+ passwordPolicy.maxLength < passwordPolicy.minLength) {
35
+ throw new RangeError("passwordPolicy lengths must be integers between 1 and 4096 with minLength <= maxLength");
36
+ }
37
+ for (const flag of [
38
+ options.passwordPolicy?.requireUppercase,
39
+ options.passwordPolicy?.requireLowercase,
40
+ options.passwordPolicy?.requireSpecial,
41
+ ]) {
42
+ if (flag !== undefined && typeof flag !== "boolean")
43
+ throw new TypeError("passwordPolicy requirements must be booleans");
44
+ }
45
+ if (options.emailVerification && registrationMode !== "email" && registrationMode !== "both") {
46
+ throw new TypeError("emailVerification requires registration mode 'email' or 'both'");
47
+ }
18
48
  if (!basePath.startsWith("/") || basePath.endsWith("/") || basePath.includes("?")) {
19
49
  throw new TypeError("basePath must start with '/' and must not end with '/'");
20
50
  }
@@ -35,6 +65,26 @@ export function createAuth(options) {
35
65
  throw new RangeError("tokenTtlSeconds must be between 1 and 86400");
36
66
  }
37
67
  }
68
+ function passwordAllowed(password) {
69
+ let length = 0;
70
+ for (const _character of password)
71
+ length += 1;
72
+ return length >= passwordPolicy.minLength &&
73
+ length <= passwordPolicy.maxLength &&
74
+ (!options.passwordPolicy?.requireUppercase || /\p{Lu}/u.test(password)) &&
75
+ (!options.passwordPolicy?.requireLowercase || /\p{Ll}/u.test(password)) &&
76
+ (!options.passwordPolicy?.requireSpecial || /[^\p{L}\p{N}\p{White_Space}]/u.test(password));
77
+ }
78
+ function requirePasswordPolicy(password) {
79
+ if (!passwordAllowed(password))
80
+ throw new RequestError(400, "invalid_request");
81
+ }
82
+ function requireEmail(user) {
83
+ if (typeof user.email !== "string" || user.email.length === 0) {
84
+ throw new TypeError("UserStore returned a user without an email");
85
+ }
86
+ return user.email;
87
+ }
38
88
  async function isAllowed(policy, value, request) {
39
89
  return policy ? policy.allow({ value, request }) : true;
40
90
  }
@@ -101,11 +151,30 @@ export function createAuth(options) {
101
151
  return false;
102
152
  }
103
153
  }
104
- function publicUser(user) {
105
- if (typeof user.id !== "string" || user.id.length === 0 || typeof user.email !== "string") {
106
- throw new TypeError("UserStore returned an invalid user");
154
+ function assertUserIdentity(user) {
155
+ const validEmail = user.email === undefined ||
156
+ (typeof user.email === "string" && user.email.length <= 254 && user.email.includes("@") &&
157
+ !/\s/.test(user.email) && user.email === user.email.toLowerCase());
158
+ const validUsername = user.username === undefined ||
159
+ (typeof user.username === "string" && /^[a-z0-9._-]{3,32}$/.test(user.username));
160
+ const hasEmail = user.email !== undefined;
161
+ const hasUsername = user.username !== undefined;
162
+ const modeSatisfied = (registrationMode === "email" && hasEmail && !hasUsername) ||
163
+ (registrationMode === "username" && hasUsername && !hasEmail) ||
164
+ (registrationMode === "both" && hasEmail && hasUsername) ||
165
+ (registrationMode === "either" && hasEmail !== hasUsername);
166
+ if (typeof user.id !== "string" || user.id.length === 0 || !validEmail || !validUsername || !modeSatisfied) {
167
+ throw new TypeError("UserStore returned an invalid user identity");
107
168
  }
108
- return { id: user.id, email: user.email };
169
+ }
170
+ function publicUser(user) {
171
+ return {
172
+ id: user.id,
173
+ ...(["email", "both"].includes(registrationMode) && user.email !== undefined ? { email: user.email } : {}),
174
+ ...(["username", "both"].includes(registrationMode) && user.username !== undefined ? { username: user.username } : {}),
175
+ ...(registrationMode === "either" && user.email !== undefined ? { email: user.email } : {}),
176
+ ...(registrationMode === "either" && user.username !== undefined ? { username: user.username } : {}),
177
+ };
109
178
  }
110
179
  async function getSession(request) {
111
180
  try {
@@ -125,9 +194,10 @@ export function createAuth(options) {
125
194
  await options.sessions.deleteByTokenHash(tokenHash);
126
195
  return null;
127
196
  }
197
+ assertUserIdentity(user);
128
198
  if (options.emailVerification && !options.emailVerification.isVerified(user))
129
199
  return null;
130
- if (!(await isAllowed(options.emailPolicy, user.email, request)))
200
+ if (options.emailPolicy && (!user.email || !(await isAllowed(options.emailPolicy, user.email, request))))
131
201
  return null;
132
202
  if (options.ipPolicy) {
133
203
  const ip = await clientIp(request);
@@ -142,7 +212,7 @@ export function createAuth(options) {
142
212
  throw error;
143
213
  }
144
214
  }
145
- async function rateLimit(action, email, request) {
215
+ async function rateLimit(action, identifier, request) {
146
216
  if (!options.rateLimiter)
147
217
  return null;
148
218
  const ip = await clientIp(request);
@@ -151,7 +221,7 @@ export function createAuth(options) {
151
221
  return response({ error: "invalid_credentials" }, 401);
152
222
  return response({ status: "accepted" }, 202);
153
223
  }
154
- const result = await options.rateLimiter.check({ action, email, ip });
224
+ const result = await options.rateLimiter.check({ action, identifier, ip });
155
225
  if (result.allowed)
156
226
  return null;
157
227
  const seconds = Number.isInteger(result.retryAfterSeconds) && result.retryAfterSeconds > 0
@@ -171,7 +241,7 @@ export function createAuth(options) {
171
241
  }
172
242
  }
173
243
  async function passPolicies(email, request) {
174
- if (!(await isAllowed(options.emailPolicy, email, request)))
244
+ if (options.emailPolicy && (!email || !(await isAllowed(options.emailPolicy, email, request))))
175
245
  return false;
176
246
  if (!options.ipPolicy)
177
247
  return true;
@@ -179,18 +249,23 @@ export function createAuth(options) {
179
249
  return ip !== null && isAllowed(options.ipPolicy, ip, request);
180
250
  }
181
251
  async function register(request) {
182
- const credentials = parseCredentials(await readJson(request, maxRequestBytes));
183
- const limited = await rateLimit("register", credentials.email, request);
252
+ const credentials = parseRegistrationCredentials(await readJson(request, maxRequestBytes), registrationMode);
253
+ requirePasswordPolicy(credentials.password);
254
+ const identifier = credentials.email !== undefined
255
+ ? { type: "email", value: credentials.email }
256
+ : { type: "username", value: credentials.username };
257
+ const limited = await rateLimit("register", identifier, request);
184
258
  if (limited)
185
259
  return limited;
186
260
  if (!(await passPolicies(credentials.email, request)))
187
261
  return response({ status: "accepted" }, 202);
188
- const created = await options.users.create({ email: credentials.email, password: credentials.password });
262
+ const created = await options.users.create(credentials);
189
263
  if (created.created) {
190
264
  if (!created.user)
191
265
  throw new TypeError("UserStore must return the created user");
266
+ assertUserIdentity(created.user);
192
267
  if (options.emailVerification) {
193
- await issueActionToken("email-verification", created.user.id, created.user.email, options.emailVerification.tokens, options.emailVerification, defaultVerificationTokenTtl, "email-verification.send");
268
+ await issueActionToken("email-verification", created.user.id, requireEmail(created.user), options.emailVerification.tokens, options.emailVerification, defaultVerificationTokenTtl, "email-verification.send");
194
269
  }
195
270
  }
196
271
  return response({ status: "accepted" }, 202);
@@ -200,14 +275,14 @@ export function createAuth(options) {
200
275
  if (!flow)
201
276
  return response({ error: "not_found" }, 404);
202
277
  const email = parseEmailRequest(await readJson(request, maxRequestBytes));
203
- const limited = await rateLimit("email-verification", email, request);
278
+ const limited = await rateLimit("email-verification", { type: "email", value: email }, request);
204
279
  if (limited)
205
280
  return limited;
206
281
  if (!(await passPolicies(email, request)))
207
282
  return response({ status: "accepted" }, 202);
208
283
  const user = await options.users.findByEmail(email);
209
284
  if (user && !flow.isVerified(user)) {
210
- await issueActionToken("email-verification", user.id, user.email, flow.tokens, flow, defaultVerificationTokenTtl, "email-verification.send");
285
+ await issueActionToken("email-verification", user.id, requireEmail(user), flow.tokens, flow, defaultVerificationTokenTtl, "email-verification.send");
211
286
  }
212
287
  return response({ status: "accepted" }, 202);
213
288
  }
@@ -234,14 +309,14 @@ export function createAuth(options) {
234
309
  if (!flow)
235
310
  return response({ error: "not_found" }, 404);
236
311
  const email = parseEmailRequest(await readJson(request, maxRequestBytes));
237
- const limited = await rateLimit("password-reset", email, request);
312
+ const limited = await rateLimit("password-reset", { type: "email", value: email }, request);
238
313
  if (limited)
239
314
  return limited;
240
315
  if (!(await passPolicies(email, request)))
241
316
  return response({ status: "accepted" }, 202);
242
317
  const user = await options.users.findByEmail(email);
243
318
  if (user) {
244
- await issueActionToken("password-reset", user.id, user.email, flow.tokens, flow, defaultResetTokenTtl, "password-reset.send");
319
+ await issueActionToken("password-reset", user.id, requireEmail(user), flow.tokens, flow, defaultResetTokenTtl, "password-reset.send");
245
320
  }
246
321
  return response({ status: "accepted" }, 202);
247
322
  }
@@ -250,6 +325,7 @@ export function createAuth(options) {
250
325
  if (!flow)
251
326
  return response({ error: "not_found" }, 404);
252
327
  const { token, password } = parsePasswordReset(await readJson(request, maxRequestBytes));
328
+ requirePasswordPolicy(password);
253
329
  const consumed = await flow.tokens.consume({
254
330
  purpose: "password-reset",
255
331
  tokenHash: await hashSessionToken(token),
@@ -265,6 +341,7 @@ export function createAuth(options) {
265
341
  return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
266
342
  }
267
343
  async function createSessionForUser(user) {
344
+ assertUserIdentity(user);
268
345
  const token = createSessionToken();
269
346
  const tokenHash = await hashSessionToken(token);
270
347
  const now = Date.now();
@@ -285,8 +362,10 @@ export function createAuth(options) {
285
362
  if (!hostIsAllowed(request))
286
363
  return response({ error: "invalid_host" }, 403);
287
364
  const user = await options.users.findById(userId);
288
- if (!user ||
289
- (options.emailVerification && !options.emailVerification.isVerified(user)) ||
365
+ if (!user)
366
+ return response({ error: "unauthenticated" }, 401);
367
+ assertUserIdentity(user);
368
+ if ((options.emailVerification && !options.emailVerification.isVerified(user)) ||
290
369
  !(await passPolicies(user.email, request))) {
291
370
  return response({ error: "unauthenticated" }, 401);
292
371
  }
@@ -299,15 +378,17 @@ export function createAuth(options) {
299
378
  }
300
379
  }
301
380
  async function login(request) {
302
- const { email, password } = parseCredentials(await readJson(request, maxRequestBytes));
303
- const limited = await rateLimit("login", email, request);
381
+ const { identifier, password } = parseLoginCredentials(await readJson(request, maxRequestBytes), loginMode);
382
+ const limited = await rateLimit("login", identifier, request);
304
383
  if (limited)
305
384
  return limited;
306
- if (!(await passPolicies(email, request)))
307
- return response({ error: "invalid_credentials" }, 401);
308
- const user = await options.users.findByEmail(email);
309
- const verified = await options.credentials.verify({ email, password, user });
310
- if (!user || !verified || (options.emailVerification && !options.emailVerification.isVerified(user))) {
385
+ const user = await options.users.findByIdentifier(identifier);
386
+ const verified = await options.credentials.verify({ identifier, password, user });
387
+ if (user)
388
+ assertUserIdentity(user);
389
+ const policyAllowed = await passPolicies(identifier.type === "email" ? identifier.value : user?.email, request);
390
+ if (!user || !verified || !policyAllowed ||
391
+ (options.emailVerification && !options.emailVerification.isVerified(user))) {
311
392
  return response({ error: "invalid_credentials" }, 401);
312
393
  }
313
394
  return createSessionForUser(user);
@@ -26,7 +26,7 @@ export interface D1RateLimitWindow {
26
26
  windowSeconds: number;
27
27
  }
28
28
  export interface D1RateLimiterOptions {
29
- email: D1RateLimitWindow;
29
+ identifier: D1RateLimitWindow;
30
30
  ip: D1RateLimitWindow;
31
31
  /** Epoch milliseconds; injectable to make window boundaries deterministic in tests. */
32
32
  now?: () => number;
@@ -57,13 +57,13 @@ function toStoredSession(row) {
57
57
  };
58
58
  }
59
59
  export function createD1RateLimiter(db, options) {
60
- validateWindow("email", options.email);
60
+ validateWindow("identifier", options.identifier);
61
61
  validateWindow("ip", options.ip);
62
62
  const now = options.now ?? Date.now;
63
63
  return {
64
- async check({ action, email, ip }) {
64
+ async check({ action, identifier, ip }) {
65
65
  const dimensions = [
66
- { name: "email", raw: email, config: options.email },
66
+ { name: `identifier:${identifier.type}`, raw: identifier.value, config: options.identifier },
67
67
  { name: "ip", raw: ip, config: options.ip },
68
68
  ];
69
69
  const digests = await Promise.all(dimensions.map(({ name, raw }) => hashKey(`${action}\0${name}\0${raw}`)));
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { createAuth } from "./auth.js";
2
- export type { AccessPolicy, AuthActionTokenDelivery, AuthActionTokenPurpose, AuthActionTokenStore, AuthApi, AuthenticatedUser, AuthOptions, AuthRateLimiter, AuthRequestRateLimitAction, AuthRequestRateLimiter, AuthUser, CredentialVerifier, EmailVerificationOptions, ExpiredRecordStore, PasswordResetOptions, SessionStore, StoredSession, UserStore, } from "./types.js";
2
+ export type { AccessPolicy, AuthActionTokenDelivery, AuthActionTokenPurpose, AuthActionTokenStore, AuthApi, AuthenticatedUser, AuthOptions, AuthRateLimiter, AuthRequestRateLimitAction, AuthIdentifier, AuthUser, CredentialVerifier, EmailVerificationOptions, LoginIdentityMode, PasswordPolicy, RegistrationIdentityMode, ExpiredRecordStore, PasswordResetOptions, SessionStore, StoredSession, UserStore, } from "./types.js";
package/dist/request.d.ts CHANGED
@@ -8,8 +8,16 @@ export declare function parseEmailRequest(value: unknown): string;
8
8
  export declare function parseActionTokenRequest(value: unknown): string;
9
9
  export declare function parseEmail(value: unknown): string;
10
10
  export declare function parseActionToken(value: unknown): string;
11
- export declare function parseCredentials(value: unknown): {
12
- email: string;
11
+ export declare function parseRegistrationCredentials(value: unknown, mode: "email" | "username" | "both" | "either"): {
12
+ email?: string;
13
+ username?: string;
14
+ password: string;
15
+ };
16
+ export declare function parseLoginCredentials(value: unknown, mode: "email" | "username" | "either"): {
17
+ identifier: {
18
+ type: "email" | "username";
19
+ value: string;
20
+ };
13
21
  password: string;
14
22
  };
15
23
  export declare function parsePasswordReset(value: unknown): {
package/dist/request.js CHANGED
@@ -77,17 +77,56 @@ export function parseActionToken(value) {
77
77
  }
78
78
  return value;
79
79
  }
80
- export function parseCredentials(value) {
81
- const { email, password } = objectFields(value);
82
- if (typeof password !== "string" || password.length === 0 || password.length > 4096) {
80
+ function parsePassword(value) {
81
+ if (typeof value !== "string" || value.length === 0)
83
82
  throw new RequestError(400, "invalid_request");
83
+ let codePoints = 0;
84
+ for (const _character of value) {
85
+ codePoints += 1;
86
+ if (codePoints > 4096)
87
+ throw new RequestError(400, "invalid_request");
84
88
  }
85
- return { email: parseEmail(email), password };
89
+ return value;
86
90
  }
87
- export function parsePasswordReset(value) {
88
- const { token, password } = objectFields(value);
89
- if (typeof password !== "string" || password.length === 0 || password.length > 4096) {
91
+ function parseUsername(value) {
92
+ if (typeof value !== "string" || !/^[A-Za-z0-9._-]{3,32}$/.test(value)) {
93
+ throw new RequestError(400, "invalid_request");
94
+ }
95
+ return value.toLowerCase();
96
+ }
97
+ export function parseRegistrationCredentials(value, mode) {
98
+ const fields = objectFields(value);
99
+ const password = parsePassword(fields.password);
100
+ const hasEmail = fields.email !== undefined;
101
+ const hasUsername = fields.username !== undefined;
102
+ if ((mode === "email" && (hasUsername || !hasEmail)) ||
103
+ (mode === "username" && (hasEmail || !hasUsername)) ||
104
+ (mode === "both" && (!hasEmail || !hasUsername)) ||
105
+ (mode === "either" && hasEmail === hasUsername)) {
90
106
  throw new RequestError(400, "invalid_request");
91
107
  }
92
- return { token: parseActionToken(token), password };
108
+ const email = hasEmail ? parseEmail(fields.email) : undefined;
109
+ const username = hasUsername ? parseUsername(fields.username) : undefined;
110
+ return { ...(email === undefined ? {} : { email }), ...(username === undefined ? {} : { username }), password };
111
+ }
112
+ export function parseLoginCredentials(value, mode) {
113
+ const fields = objectFields(value);
114
+ const password = parsePassword(fields.password);
115
+ const hasEmail = fields.email !== undefined;
116
+ const hasUsername = fields.username !== undefined;
117
+ if ((mode === "email" && (hasUsername || !hasEmail)) ||
118
+ (mode === "username" && (hasEmail || !hasUsername)) ||
119
+ (mode === "either" && hasEmail === hasUsername)) {
120
+ throw new RequestError(400, "invalid_request");
121
+ }
122
+ return {
123
+ identifier: hasEmail
124
+ ? { type: "email", value: parseEmail(fields.email) }
125
+ : { type: "username", value: parseUsername(fields.username) },
126
+ password,
127
+ };
128
+ }
129
+ export function parsePasswordReset(value) {
130
+ const { token, password } = objectFields(value);
131
+ return { token: parseActionToken(token), password: parsePassword(password) };
93
132
  }
package/dist/types.d.ts CHANGED
@@ -1,13 +1,32 @@
1
1
  export interface AuthUser {
2
2
  readonly id: string;
3
- readonly email: string;
3
+ readonly email?: string;
4
+ readonly username?: string;
5
+ }
6
+ export type AuthIdentifier = {
7
+ readonly type: "email";
8
+ readonly value: string;
9
+ } | {
10
+ readonly type: "username";
11
+ readonly value: string;
12
+ };
13
+ export type RegistrationIdentityMode = "email" | "username" | "both" | "either";
14
+ export type LoginIdentityMode = "email" | "username" | "either";
15
+ export interface PasswordPolicy {
16
+ minLength?: number;
17
+ maxLength?: number;
18
+ requireUppercase?: boolean;
19
+ requireLowercase?: boolean;
20
+ requireSpecial?: boolean;
4
21
  }
5
22
  export interface UserStore<User extends AuthUser = AuthUser> {
23
+ findByIdentifier(identifier: AuthIdentifier): Promise<User | null>;
6
24
  findByEmail(email: string): Promise<User | null>;
7
25
  findById(id: string): Promise<User | null>;
8
- /** Enforce normalized-email uniqueness atomically; hash the password before persistence. */
26
+ /** Enforce canonical email/username uniqueness atomically; hash the password before persistence. */
9
27
  create(input: {
10
- email: string;
28
+ email?: string;
29
+ username?: string;
11
30
  password: string;
12
31
  }): Promise<{
13
32
  created: true;
@@ -18,7 +37,7 @@ export interface UserStore<User extends AuthUser = AuthUser> {
18
37
  }
19
38
  export interface CredentialVerifier<User extends AuthUser = AuthUser> {
20
39
  verify(input: {
21
- email: string;
40
+ identifier: AuthIdentifier;
22
41
  password: string;
23
42
  user: User | null;
24
43
  }): Promise<boolean>;
@@ -75,6 +94,11 @@ export interface AuthOptions<User extends AuthUser = AuthUser> {
75
94
  users: UserStore<User>;
76
95
  credentials: CredentialVerifier<User>;
77
96
  sessions: SessionStore;
97
+ identity?: {
98
+ registration?: RegistrationIdentityMode;
99
+ login?: LoginIdentityMode;
100
+ };
101
+ passwordPolicy?: PasswordPolicy;
78
102
  emailPolicy?: AccessPolicy;
79
103
  resolveClientIp?: (request: Request) => string | null | Promise<string | null>;
80
104
  ipPolicy?: AccessPolicy;
@@ -110,7 +134,7 @@ export interface SessionStore extends ExpiredRecordStore {
110
134
  export interface AuthRateLimiter {
111
135
  check(input: {
112
136
  action: "register" | "login" | "email-verification" | "password-reset";
113
- email: string;
137
+ identifier: AuthIdentifier;
114
138
  ip: string;
115
139
  }): Promise<{
116
140
  allowed: boolean;
@@ -135,7 +159,8 @@ export interface AccessPolicy {
135
159
  }
136
160
  export interface AuthenticatedUser {
137
161
  readonly id: string;
138
- readonly email: string;
162
+ readonly email?: string;
163
+ readonly username?: string;
139
164
  }
140
165
  export interface AuthApi {
141
166
  handle(request: Request): Promise<Response>;
package/package.json CHANGED
@@ -20,7 +20,7 @@
20
20
  "email-verification",
21
21
  "password-reset"
22
22
  ],
23
- "version": "0.1.0",
23
+ "version": "0.1.1",
24
24
  "type": "module",
25
25
  "exports": {
26
26
  ".": {
@@ -63,7 +63,9 @@
63
63
  "optional": true
64
64
  }
65
65
  },
66
- "files": ["dist", "migrations"],
66
+ "files": [
67
+ "dist"
68
+ ],
67
69
  "sideEffects": false,
68
70
  "scripts": {
69
71
  "build": "tsc -p tsconfig.build.json",
@@ -1,39 +0,0 @@
1
- CREATE TABLE IF NOT EXISTS shtora_sessions (
2
- id TEXT PRIMARY KEY NOT NULL,
3
- token_hash TEXT NOT NULL UNIQUE,
4
- user_id TEXT NOT NULL,
5
- created_at_ms INTEGER NOT NULL,
6
- expires_at_ms INTEGER NOT NULL
7
- );
8
-
9
- CREATE INDEX IF NOT EXISTS shtora_sessions_user_created
10
- ON shtora_sessions (user_id, created_at_ms, id);
11
-
12
- CREATE TABLE IF NOT EXISTS shtora_rate_limits (
13
- key_hash TEXT PRIMARY KEY NOT NULL,
14
- window_start_ms INTEGER NOT NULL,
15
- count INTEGER NOT NULL CHECK (count > 0)
16
- );
17
-
18
- CREATE TABLE IF NOT EXISTS shtora_action_tokens (
19
- purpose TEXT NOT NULL,
20
- user_id TEXT NOT NULL,
21
- token_hash TEXT NOT NULL,
22
- expires_at_ms INTEGER NOT NULL,
23
- PRIMARY KEY (purpose, token_hash)
24
- );
25
-
26
- CREATE INDEX IF NOT EXISTS shtora_action_tokens_expiry
27
- ON shtora_action_tokens (expires_at_ms);
28
- CREATE INDEX IF NOT EXISTS shtora_action_tokens_user_purpose
29
- ON shtora_action_tokens (purpose, user_id);
30
-
31
- -- Preferred cleanup path: schedule cleanupD1AuthState(db, { rateLimitMaxWindowSeconds }).
32
- -- It uses bounded batches for expired sessions/action tokens and completed rate-limit windows.
33
- -- Manual equivalent for rate counters:
34
- -- DELETE FROM shtora_rate_limits WHERE window_start_ms < :now_ms - :max_window_ms;
35
- -- Set :max_window_ms to the longest configured rate-limit window in milliseconds.
36
- -- Manual equivalent for action tokens and sessions:
37
- -- DELETE FROM shtora_action_tokens WHERE expires_at_ms <= :now_ms;
38
- -- DELETE FROM shtora_sessions WHERE expires_at_ms <= :now_ms;
39
- -- Apply 0002_session_expiry_index.sql and 0003_rate_limit_expiry_index.sql for cleanup indexes.
@@ -1,2 +0,0 @@
1
- CREATE INDEX IF NOT EXISTS shtora_sessions_expiry
2
- ON shtora_sessions (expires_at_ms);
@@ -1,2 +0,0 @@
1
- CREATE INDEX IF NOT EXISTS shtora_rate_limits_window_start
2
- ON shtora_rate_limits (window_start_ms);