@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 +65 -17
- package/dist/auth.js +107 -26
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/request.d.ts +10 -2
- package/dist/request.js +47 -8
- package/dist/types.d.ts +31 -6
- package/package.json +4 -2
- package/migrations/0001_shtora_auth.sql +0 -39
- package/migrations/0002_session_expiry_index.sql +0 -2
- package/migrations/0003_rate_limit_expiry_index.sql +0 -2
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
|
-
|
|
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
|
-
|
|
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.
|
|
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`.
|
|
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`.
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
## Public entry points
|
|
175
221
|
|
|
176
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
183
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
(
|
|
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 {
|
|
303
|
-
const limited = await rateLimit("login",
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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);
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface D1RateLimitWindow {
|
|
|
26
26
|
windowSeconds: number;
|
|
27
27
|
}
|
|
28
28
|
export interface D1RateLimiterOptions {
|
|
29
|
-
|
|
29
|
+
identifier: D1RateLimitWindow;
|
|
30
30
|
ip: D1RateLimitWindow;
|
|
31
31
|
/** Epoch milliseconds; injectable to make window boundaries deterministic in tests. */
|
|
32
32
|
now?: () => number;
|
package/dist/cloudflare.js
CHANGED
|
@@ -57,13 +57,13 @@ function toStoredSession(row) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
export function createD1RateLimiter(db, options) {
|
|
60
|
-
validateWindow("
|
|
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,
|
|
64
|
+
async check({ action, identifier, ip }) {
|
|
65
65
|
const dimensions = [
|
|
66
|
-
{ name:
|
|
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,
|
|
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
|
|
12
|
-
email
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
89
|
+
return value;
|
|
86
90
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
26
|
+
/** Enforce canonical email/username uniqueness atomically; hash the password before persistence. */
|
|
9
27
|
create(input: {
|
|
10
|
-
email
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": [
|
|
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.
|