@shtora/auth 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +187 -3
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +429 -0
- package/dist/cloudflare.d.ts +51 -0
- package/dist/cloudflare.js +156 -0
- package/dist/cookies.d.ts +3 -0
- package/dist/cookies.js +25 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/oidc.d.ts +44 -0
- package/dist/oidc.js +192 -0
- package/dist/request.d.ts +18 -0
- package/dist/request.js +93 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.js +1 -0
- package/dist/webauthn.d.ts +68 -0
- package/dist/webauthn.js +302 -0
- package/migrations/0001_shtora_auth.sql +39 -0
- package/migrations/0002_session_expiry_index.sql +2 -0
- package/migrations/0003_rate_limit_expiry_index.sql +2 -0
- package/package.json +78 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 airshakur88
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @shtora/auth
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @shtora/auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install optional protocol dependencies only when you use those entry points:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add openid-client # @shtora/auth/oidc
|
|
17
|
+
bun add @simplewebauthn/server # @shtora/auth/webauthn
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Next.js App Router
|
|
21
|
+
|
|
22
|
+
Create the handler with application-owned adapters. `users`, `credentials`, `sessions`, `rateLimiter`, and `resolveClientIp` below are implementations in your application, not exports from this package.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/server/auth.ts
|
|
26
|
+
import { createAuth } from "@shtora/auth";
|
|
27
|
+
import {
|
|
28
|
+
blockedEmails,
|
|
29
|
+
blockedIps,
|
|
30
|
+
credentials,
|
|
31
|
+
rateLimiter,
|
|
32
|
+
resolveClientIp,
|
|
33
|
+
sessions,
|
|
34
|
+
users,
|
|
35
|
+
} from "./auth-adapters";
|
|
36
|
+
|
|
37
|
+
export const auth = createAuth({
|
|
38
|
+
users,
|
|
39
|
+
credentials,
|
|
40
|
+
sessions,
|
|
41
|
+
rateLimiter,
|
|
42
|
+
resolveClientIp,
|
|
43
|
+
allowedHosts: ["app.example.com"],
|
|
44
|
+
emailPolicy: {
|
|
45
|
+
async allow({ value }) {
|
|
46
|
+
return !blockedEmails.has(value);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
ipPolicy: {
|
|
50
|
+
async allow({ value }) {
|
|
51
|
+
return !blockedIps.has(value);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Forward the supported methods from a Route Handler:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// app/api/auth/[...auth]/route.ts
|
|
61
|
+
import { auth } from "@/server/auth";
|
|
62
|
+
|
|
63
|
+
export const GET = (request: Request) => auth.handle(request);
|
|
64
|
+
export const POST = (request: Request) => auth.handle(request);
|
|
65
|
+
export const DELETE = (request: Request) => auth.handle(request);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use the real hostname in `allowedHosts`. Resolve client IPs only from a trusted hosting-platform or proxy value; do not parse arbitrary `X-Forwarded-For` input. Configure a shared, persistent rate limiter for public authentication endpoints.
|
|
69
|
+
|
|
70
|
+
## Cloudflare Workers and D1
|
|
71
|
+
|
|
72
|
+
Import D1 adapters from the dedicated entry point. The application still supplies its own user and credential adapters; D1 stores sessions and rate-limit counters in this example.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createAuth } from "@shtora/auth";
|
|
76
|
+
import {
|
|
77
|
+
cleanupD1AuthState,
|
|
78
|
+
createD1RateLimiter,
|
|
79
|
+
createD1SessionStore,
|
|
80
|
+
type D1Database,
|
|
81
|
+
} from "@shtora/auth/cloudflare";
|
|
82
|
+
import { credentials, users } from "./auth-adapters";
|
|
83
|
+
|
|
84
|
+
interface Env {
|
|
85
|
+
DB: D1Database;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let auth: ReturnType<typeof createAuth> | undefined;
|
|
89
|
+
|
|
90
|
+
function getAuth(env: Env) {
|
|
91
|
+
return auth ??= createAuth({
|
|
92
|
+
users,
|
|
93
|
+
credentials,
|
|
94
|
+
sessions: createD1SessionStore(env.DB),
|
|
95
|
+
rateLimiter: createD1RateLimiter(env.DB, {
|
|
96
|
+
email: { limit: 5, windowSeconds: 60 },
|
|
97
|
+
ip: { limit: 100, windowSeconds: 60 },
|
|
98
|
+
}),
|
|
99
|
+
resolveClientIp: (request) => request.headers.get("cf-connecting-ip"),
|
|
100
|
+
allowedHosts: ["auth.example.com"],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default {
|
|
105
|
+
fetch(request: Request, env: Env): Promise<Response> {
|
|
106
|
+
return getAuth(env).handle(request);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async scheduled(_controller: unknown, env: Env): Promise<void> {
|
|
110
|
+
await cleanupD1AuthState(env.DB, { rateLimitMaxWindowSeconds: 60 });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
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.
|
|
116
|
+
|
|
117
|
+
## Routes
|
|
118
|
+
|
|
119
|
+
The default base path is `/api/auth`; set `basePath` to change it.
|
|
120
|
+
|
|
121
|
+
| Method | Path | Behavior |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `POST` | `/api/auth/register` | Creates an account. Duplicate and policy-blocked registrations share the same `202` response. |
|
|
124
|
+
| `POST` | `/api/auth/login` | Verifies credentials and sets a session cookie. |
|
|
125
|
+
| `POST` | `/api/auth/logout` | Revokes the current session and clears the cookie after successful revocation. |
|
|
126
|
+
| `GET` | `/api/auth/session` | Returns the authenticated user or `401`. |
|
|
127
|
+
| `GET` | `/api/auth/sessions` | Lists the user's active sessions without token hashes. |
|
|
128
|
+
| `DELETE` | `/api/auth/sessions/:id` | Revokes a session owned by the current user. |
|
|
129
|
+
| `DELETE` | `/api/auth/sessions` | Revokes all sessions for the current user. |
|
|
130
|
+
| `POST` | `/api/auth/email-verification/request` | Optional; sends a verification message with an enumeration-resistant response. |
|
|
131
|
+
| `POST` | `/api/auth/email-verification/confirm` | Optional; consumes a one-time token and marks the account verified. |
|
|
132
|
+
| `POST` | `/api/auth/password-reset/request` | Optional; sends a reset message with an enumeration-resistant response. |
|
|
133
|
+
| `POST` | `/api/auth/password-reset/confirm` | Optional; revokes sessions, then delegates the password update to the application. |
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
## Email verification and password reset
|
|
138
|
+
|
|
139
|
+
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
|
+
|
|
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.
|
|
142
|
+
|
|
143
|
+
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
|
+
|
|
145
|
+
## External identity handlers
|
|
146
|
+
|
|
147
|
+
`auth.createSessionResponse(request, userId)` creates a package session after an external identity handler has verified its protocol response. It reloads the application user and applies configured host, email, IP, and verification policies. The caller remains responsible for protocol state, CSRF protection, and rate limiting.
|
|
148
|
+
|
|
149
|
+
## Security and storage contracts
|
|
150
|
+
|
|
151
|
+
- The default `__Host-shtora.session` cookie is `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, and has no `Domain`. Cookie names must use the `__Host-` prefix.
|
|
152
|
+
- Session cookies contain a random 256-bit token. `SessionStore` persists only its SHA-256 hash. Use storage with reliable revocation consistency; `deleteByIdForUser` must match both session and user IDs.
|
|
153
|
+
- The default session lifetime is seven days (maximum one year). Request bodies are limited to 8 KiB by default; oversized cookie headers are treated as unauthenticated.
|
|
154
|
+
- The application must hash passwords before storing them. `CredentialVerifier.verify` should do equivalent work for missing users to reduce account-enumeration timing differences. Never return password material through `AuthUser`.
|
|
155
|
+
- Rate limiting is optional for core routes and should be configured for public deployments. OIDC and WebAuthn require a trusted client-IP resolver and request rate limiter; they fail closed when the IP is unavailable.
|
|
156
|
+
- `resolveClientIp` must use a trusted proxy or platform header. Never trust arbitrary forwarded headers.
|
|
157
|
+
- `allowedHosts` is an optional exact-authority allowlist. If omitted, reject unexpected hosts at the platform or proxy.
|
|
158
|
+
- `onError({ operation, error })` observes unexpected adapter, policy, and delivery errors. HTTP responses remain generic. Avoid logging unreviewed adapter errors; they may contain sensitive values. Direct `getSession()` and `createSessionResponse()` calls reject adapter errors after notifying the observer.
|
|
159
|
+
|
|
160
|
+
The application also owns persistent storage for action tokens, OIDC transactions, WebAuthn challenges, and credentials. Their `consume` operations must be atomic and one-use; WebAuthn credential IDs must be unique and counter updates conditional on the stored counter. Expired-record cleanup methods must honor their supplied limit. See the exported TypeScript interfaces for the complete contracts.
|
|
161
|
+
|
|
162
|
+
## Optional protocols
|
|
163
|
+
|
|
164
|
+
### OIDC
|
|
165
|
+
|
|
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.
|
|
167
|
+
|
|
168
|
+
### WebAuthn and passkeys
|
|
169
|
+
|
|
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.
|
|
171
|
+
|
|
172
|
+
## Cloudflare D1 adapter
|
|
173
|
+
|
|
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.
|
|
175
|
+
|
|
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/).
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
bun install
|
|
182
|
+
bun run typecheck
|
|
183
|
+
bun test
|
|
184
|
+
bun run build
|
|
185
|
+
```
|
|
186
|
+
|
|
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.d.ts
ADDED
package/dist/auth.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { clearedSessionCookie, readSessionToken, sessionCookie } from "./cookies.js";
|
|
2
|
+
import { createSessionToken, hashSessionToken } from "./crypto.js";
|
|
3
|
+
import { parseActionTokenRequest, parseCredentials, parseEmailRequest, parsePasswordReset, readJson, RequestError } from "./request.js";
|
|
4
|
+
const defaultBasePath = "/api/auth";
|
|
5
|
+
const defaultCookieName = "__Host-shtora.session";
|
|
6
|
+
const defaultSessionTtl = 60 * 60 * 24 * 7;
|
|
7
|
+
const maxSessionTtl = 60 * 60 * 24 * 365;
|
|
8
|
+
const defaultMaxRequestBytes = 8192;
|
|
9
|
+
const defaultVerificationTokenTtl = 60 * 60;
|
|
10
|
+
const defaultResetTokenTtl = 15 * 60;
|
|
11
|
+
const maxActionTokenTtl = 60 * 60 * 24;
|
|
12
|
+
export function createAuth(options) {
|
|
13
|
+
const basePath = options.basePath ?? defaultBasePath;
|
|
14
|
+
const cookieName = options.cookieName ?? defaultCookieName;
|
|
15
|
+
const sessionTtlSeconds = options.sessionTtlSeconds ?? defaultSessionTtl;
|
|
16
|
+
const maxRequestBytes = options.maxRequestBytes ?? defaultMaxRequestBytes;
|
|
17
|
+
const requireOrigin = options.requireOrigin ?? true;
|
|
18
|
+
if (!basePath.startsWith("/") || basePath.endsWith("/") || basePath.includes("?")) {
|
|
19
|
+
throw new TypeError("basePath must start with '/' and must not end with '/'");
|
|
20
|
+
}
|
|
21
|
+
if (!/^__Host-[A-Za-z0-9._-]+$/.test(cookieName)) {
|
|
22
|
+
throw new TypeError("cookieName must use the __Host- prefix");
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 1 || sessionTtlSeconds > maxSessionTtl) {
|
|
25
|
+
throw new RangeError("sessionTtlSeconds must be between 1 and 31536000");
|
|
26
|
+
}
|
|
27
|
+
if (!Number.isInteger(maxRequestBytes) || maxRequestBytes < 1 || maxRequestBytes > 1024 * 1024) {
|
|
28
|
+
throw new RangeError("maxRequestBytes must be between 1 and 1048576");
|
|
29
|
+
}
|
|
30
|
+
if ((options.ipPolicy || options.rateLimiter) && !options.resolveClientIp) {
|
|
31
|
+
throw new TypeError("resolveClientIp is required when ipPolicy or rateLimiter is configured");
|
|
32
|
+
}
|
|
33
|
+
for (const ttl of [options.emailVerification?.tokenTtlSeconds, options.passwordReset?.tokenTtlSeconds]) {
|
|
34
|
+
if (ttl !== undefined && (!Number.isInteger(ttl) || ttl < 1 || ttl > maxActionTokenTtl)) {
|
|
35
|
+
throw new RangeError("tokenTtlSeconds must be between 1 and 86400");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function isAllowed(policy, value, request) {
|
|
39
|
+
return policy ? policy.allow({ value, request }) : true;
|
|
40
|
+
}
|
|
41
|
+
async function clientIp(request) {
|
|
42
|
+
const ip = await options.resolveClientIp(request);
|
|
43
|
+
return typeof ip === "string" && ip.length > 0 ? ip : null;
|
|
44
|
+
}
|
|
45
|
+
async function report(operation, error) {
|
|
46
|
+
try {
|
|
47
|
+
await options.onError?.({ operation, error });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Observers must not affect the public result.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function response(body, status, headers) {
|
|
54
|
+
const responseHeaders = new Headers(headers);
|
|
55
|
+
responseHeaders.set("Cache-Control", "no-store");
|
|
56
|
+
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
|
|
57
|
+
responseHeaders.set("X-Content-Type-Options", "nosniff");
|
|
58
|
+
responseHeaders.set("Vary", "Cookie");
|
|
59
|
+
return new Response(JSON.stringify(body), { status, headers: responseHeaders });
|
|
60
|
+
}
|
|
61
|
+
function errorResponse(error) {
|
|
62
|
+
if (error instanceof RequestError)
|
|
63
|
+
return response({ error: error.code }, error.status);
|
|
64
|
+
return response({ error: "internal_error" }, 500);
|
|
65
|
+
}
|
|
66
|
+
function hostIsAllowed(request) {
|
|
67
|
+
if (!options.allowedHosts)
|
|
68
|
+
return true;
|
|
69
|
+
const host = request.headers.get("host");
|
|
70
|
+
if (!host || host.includes(","))
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = new URL(`https://${host}`);
|
|
74
|
+
return parsed.pathname === "/" && !parsed.username && !parsed.password &&
|
|
75
|
+
options.allowedHosts.some((allowed) => allowed.toLowerCase() === parsed.host.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function originIsAllowed(request) {
|
|
82
|
+
const origin = request.headers.get("origin");
|
|
83
|
+
if (!origin)
|
|
84
|
+
return !requireOrigin;
|
|
85
|
+
try {
|
|
86
|
+
const parsedOrigin = new URL(origin);
|
|
87
|
+
const normalizedOrigin = parsedOrigin.origin.toLowerCase();
|
|
88
|
+
if (parsedOrigin.username ||
|
|
89
|
+
parsedOrigin.password ||
|
|
90
|
+
parsedOrigin.pathname !== "/" ||
|
|
91
|
+
parsedOrigin.search ||
|
|
92
|
+
parsedOrigin.hash ||
|
|
93
|
+
origin.toLowerCase() !== normalizedOrigin)
|
|
94
|
+
return false;
|
|
95
|
+
const requestUrl = new URL(request.url);
|
|
96
|
+
const host = request.headers.get("host");
|
|
97
|
+
const expectedOrigin = host ? new URL(`${requestUrl.protocol}//${host}`).origin : requestUrl.origin;
|
|
98
|
+
return normalizedOrigin === expectedOrigin.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
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");
|
|
107
|
+
}
|
|
108
|
+
return { id: user.id, email: user.email };
|
|
109
|
+
}
|
|
110
|
+
async function getSession(request) {
|
|
111
|
+
try {
|
|
112
|
+
const token = readSessionToken(request.headers.get("cookie"), cookieName);
|
|
113
|
+
if (!token)
|
|
114
|
+
return null;
|
|
115
|
+
const tokenHash = await hashSessionToken(token);
|
|
116
|
+
const stored = await options.sessions.findByTokenHash(tokenHash);
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (!stored || stored.tokenHash !== tokenHash || !Number.isFinite(stored.expiresAtMs) || stored.expiresAtMs <= now) {
|
|
119
|
+
if (stored?.tokenHash === tokenHash)
|
|
120
|
+
await options.sessions.deleteByTokenHash(tokenHash);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const user = await options.users.findById(stored.userId);
|
|
124
|
+
if (!user) {
|
|
125
|
+
await options.sessions.deleteByTokenHash(tokenHash);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (options.emailVerification && !options.emailVerification.isVerified(user))
|
|
129
|
+
return null;
|
|
130
|
+
if (!(await isAllowed(options.emailPolicy, user.email, request)))
|
|
131
|
+
return null;
|
|
132
|
+
if (options.ipPolicy) {
|
|
133
|
+
const ip = await clientIp(request);
|
|
134
|
+
if (!ip || !(await isAllowed(options.ipPolicy, ip, request)))
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return publicUser(user);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!(error instanceof RequestError))
|
|
141
|
+
await report("getSession", error);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function rateLimit(action, email, request) {
|
|
146
|
+
if (!options.rateLimiter)
|
|
147
|
+
return null;
|
|
148
|
+
const ip = await clientIp(request);
|
|
149
|
+
if (!ip) {
|
|
150
|
+
if (action === "login")
|
|
151
|
+
return response({ error: "invalid_credentials" }, 401);
|
|
152
|
+
return response({ status: "accepted" }, 202);
|
|
153
|
+
}
|
|
154
|
+
const result = await options.rateLimiter.check({ action, email, ip });
|
|
155
|
+
if (result.allowed)
|
|
156
|
+
return null;
|
|
157
|
+
const seconds = Number.isInteger(result.retryAfterSeconds) && result.retryAfterSeconds > 0
|
|
158
|
+
? result.retryAfterSeconds
|
|
159
|
+
: 60;
|
|
160
|
+
return response({ error: "rate_limited" }, 429, { "Retry-After": String(seconds) });
|
|
161
|
+
}
|
|
162
|
+
async function issueActionToken(purpose, userId, email, tokens, delivery, defaultTtlSeconds, operation) {
|
|
163
|
+
try {
|
|
164
|
+
const token = createSessionToken();
|
|
165
|
+
const expiresAtMs = Date.now() + (delivery.tokenTtlSeconds ?? defaultTtlSeconds) * 1000;
|
|
166
|
+
await tokens.create({ purpose, userId, tokenHash: await hashSessionToken(token), expiresAtMs });
|
|
167
|
+
await delivery.send({ email, token, expiresAtMs });
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
await report(operation, error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function passPolicies(email, request) {
|
|
174
|
+
if (!(await isAllowed(options.emailPolicy, email, request)))
|
|
175
|
+
return false;
|
|
176
|
+
if (!options.ipPolicy)
|
|
177
|
+
return true;
|
|
178
|
+
const ip = await clientIp(request);
|
|
179
|
+
return ip !== null && isAllowed(options.ipPolicy, ip, request);
|
|
180
|
+
}
|
|
181
|
+
async function register(request) {
|
|
182
|
+
const credentials = parseCredentials(await readJson(request, maxRequestBytes));
|
|
183
|
+
const limited = await rateLimit("register", credentials.email, request);
|
|
184
|
+
if (limited)
|
|
185
|
+
return limited;
|
|
186
|
+
if (!(await passPolicies(credentials.email, request)))
|
|
187
|
+
return response({ status: "accepted" }, 202);
|
|
188
|
+
const created = await options.users.create({ email: credentials.email, password: credentials.password });
|
|
189
|
+
if (created.created) {
|
|
190
|
+
if (!created.user)
|
|
191
|
+
throw new TypeError("UserStore must return the created user");
|
|
192
|
+
if (options.emailVerification) {
|
|
193
|
+
await issueActionToken("email-verification", created.user.id, created.user.email, options.emailVerification.tokens, options.emailVerification, defaultVerificationTokenTtl, "email-verification.send");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return response({ status: "accepted" }, 202);
|
|
197
|
+
}
|
|
198
|
+
async function requestEmailVerification(request) {
|
|
199
|
+
const flow = options.emailVerification;
|
|
200
|
+
if (!flow)
|
|
201
|
+
return response({ error: "not_found" }, 404);
|
|
202
|
+
const email = parseEmailRequest(await readJson(request, maxRequestBytes));
|
|
203
|
+
const limited = await rateLimit("email-verification", email, request);
|
|
204
|
+
if (limited)
|
|
205
|
+
return limited;
|
|
206
|
+
if (!(await passPolicies(email, request)))
|
|
207
|
+
return response({ status: "accepted" }, 202);
|
|
208
|
+
const user = await options.users.findByEmail(email);
|
|
209
|
+
if (user && !flow.isVerified(user)) {
|
|
210
|
+
await issueActionToken("email-verification", user.id, user.email, flow.tokens, flow, defaultVerificationTokenTtl, "email-verification.send");
|
|
211
|
+
}
|
|
212
|
+
return response({ status: "accepted" }, 202);
|
|
213
|
+
}
|
|
214
|
+
async function confirmEmailVerification(request) {
|
|
215
|
+
const flow = options.emailVerification;
|
|
216
|
+
if (!flow)
|
|
217
|
+
return response({ error: "not_found" }, 404);
|
|
218
|
+
const token = parseActionTokenRequest(await readJson(request, maxRequestBytes));
|
|
219
|
+
const consumed = await flow.tokens.consume({
|
|
220
|
+
purpose: "email-verification",
|
|
221
|
+
tokenHash: await hashSessionToken(token),
|
|
222
|
+
nowMs: Date.now(),
|
|
223
|
+
});
|
|
224
|
+
if (!consumed)
|
|
225
|
+
return response({ error: "invalid_token" }, 400);
|
|
226
|
+
const user = await options.users.findById(consumed.userId);
|
|
227
|
+
if (!user)
|
|
228
|
+
return response({ error: "invalid_token" }, 400);
|
|
229
|
+
await flow.markVerified(user.id);
|
|
230
|
+
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
|
|
231
|
+
}
|
|
232
|
+
async function requestPasswordReset(request) {
|
|
233
|
+
const flow = options.passwordReset;
|
|
234
|
+
if (!flow)
|
|
235
|
+
return response({ error: "not_found" }, 404);
|
|
236
|
+
const email = parseEmailRequest(await readJson(request, maxRequestBytes));
|
|
237
|
+
const limited = await rateLimit("password-reset", email, request);
|
|
238
|
+
if (limited)
|
|
239
|
+
return limited;
|
|
240
|
+
if (!(await passPolicies(email, request)))
|
|
241
|
+
return response({ status: "accepted" }, 202);
|
|
242
|
+
const user = await options.users.findByEmail(email);
|
|
243
|
+
if (user) {
|
|
244
|
+
await issueActionToken("password-reset", user.id, user.email, flow.tokens, flow, defaultResetTokenTtl, "password-reset.send");
|
|
245
|
+
}
|
|
246
|
+
return response({ status: "accepted" }, 202);
|
|
247
|
+
}
|
|
248
|
+
async function confirmPasswordReset(request) {
|
|
249
|
+
const flow = options.passwordReset;
|
|
250
|
+
if (!flow)
|
|
251
|
+
return response({ error: "not_found" }, 404);
|
|
252
|
+
const { token, password } = parsePasswordReset(await readJson(request, maxRequestBytes));
|
|
253
|
+
const consumed = await flow.tokens.consume({
|
|
254
|
+
purpose: "password-reset",
|
|
255
|
+
tokenHash: await hashSessionToken(token),
|
|
256
|
+
nowMs: Date.now(),
|
|
257
|
+
});
|
|
258
|
+
if (!consumed)
|
|
259
|
+
return response({ error: "invalid_token" }, 400);
|
|
260
|
+
const user = await options.users.findById(consumed.userId);
|
|
261
|
+
if (!user)
|
|
262
|
+
return response({ error: "invalid_token" }, 400);
|
|
263
|
+
await options.sessions.deleteByUserId(user.id);
|
|
264
|
+
await flow.setPassword({ userId: user.id, password });
|
|
265
|
+
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
|
|
266
|
+
}
|
|
267
|
+
async function createSessionForUser(user) {
|
|
268
|
+
const token = createSessionToken();
|
|
269
|
+
const tokenHash = await hashSessionToken(token);
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
const session = {
|
|
272
|
+
id: createSessionToken(),
|
|
273
|
+
tokenHash,
|
|
274
|
+
userId: user.id,
|
|
275
|
+
createdAtMs: now,
|
|
276
|
+
expiresAtMs: now + sessionTtlSeconds * 1000,
|
|
277
|
+
};
|
|
278
|
+
await options.sessions.create(session);
|
|
279
|
+
return response({ user: publicUser(user) }, 200, {
|
|
280
|
+
"Set-Cookie": sessionCookie(token, cookieName, sessionTtlSeconds, now),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async function createSessionResponse(request, userId) {
|
|
284
|
+
try {
|
|
285
|
+
if (!hostIsAllowed(request))
|
|
286
|
+
return response({ error: "invalid_host" }, 403);
|
|
287
|
+
const user = await options.users.findById(userId);
|
|
288
|
+
if (!user ||
|
|
289
|
+
(options.emailVerification && !options.emailVerification.isVerified(user)) ||
|
|
290
|
+
!(await passPolicies(user.email, request))) {
|
|
291
|
+
return response({ error: "unauthenticated" }, 401);
|
|
292
|
+
}
|
|
293
|
+
return await createSessionForUser(user);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (!(error instanceof RequestError))
|
|
297
|
+
await report("createSessionResponse", error);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function login(request) {
|
|
302
|
+
const { email, password } = parseCredentials(await readJson(request, maxRequestBytes));
|
|
303
|
+
const limited = await rateLimit("login", email, request);
|
|
304
|
+
if (limited)
|
|
305
|
+
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))) {
|
|
311
|
+
return response({ error: "invalid_credentials" }, 401);
|
|
312
|
+
}
|
|
313
|
+
return createSessionForUser(user);
|
|
314
|
+
}
|
|
315
|
+
async function logout(request) {
|
|
316
|
+
const token = readSessionToken(request.headers.get("cookie"), cookieName);
|
|
317
|
+
if (token)
|
|
318
|
+
await options.sessions.deleteByTokenHash(await hashSessionToken(token));
|
|
319
|
+
return new Response(null, {
|
|
320
|
+
status: 204,
|
|
321
|
+
headers: { "Cache-Control": "no-store", "Set-Cookie": clearedSessionCookie(cookieName) },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async function cleanupExpired() {
|
|
325
|
+
const stores = new Set([options.sessions]);
|
|
326
|
+
if (options.emailVerification)
|
|
327
|
+
stores.add(options.emailVerification.tokens);
|
|
328
|
+
if (options.passwordReset)
|
|
329
|
+
stores.add(options.passwordReset.tokens);
|
|
330
|
+
const nowMs = Date.now();
|
|
331
|
+
try {
|
|
332
|
+
await Promise.all([...stores].map((store) => store.deleteExpired({ nowMs, limit: 500 })));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
await report("cleanupExpired", error);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function handle(request) {
|
|
340
|
+
if (!hostIsAllowed(request))
|
|
341
|
+
return response({ error: "invalid_host" }, 403);
|
|
342
|
+
const { pathname } = new URL(request.url);
|
|
343
|
+
if (!pathname.startsWith(`${basePath}/`))
|
|
344
|
+
return response({ error: "not_found" }, 404);
|
|
345
|
+
const route = pathname.slice(basePath.length);
|
|
346
|
+
const method = request.method.toUpperCase();
|
|
347
|
+
if (route === "/session" && method === "GET") {
|
|
348
|
+
if (!originIsAllowed(request) && request.headers.has("origin"))
|
|
349
|
+
return response({ error: "invalid_origin" }, 403);
|
|
350
|
+
try {
|
|
351
|
+
const user = await getSession(request);
|
|
352
|
+
return user ? response({ user }, 200) : response({ error: "unauthenticated" }, 401);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return errorResponse(error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (route === "/sessions" || /^\/sessions\/[^/]+$/.test(route)) {
|
|
359
|
+
if ((route === "/sessions" && !["GET", "DELETE"].includes(method)) ||
|
|
360
|
+
(route !== "/sessions" && method !== "DELETE")) {
|
|
361
|
+
return response({ error: "method_not_allowed" }, 405, { Allow: route === "/sessions" ? "GET, DELETE" : "DELETE" });
|
|
362
|
+
}
|
|
363
|
+
if ((method !== "GET" || request.headers.has("origin")) && !originIsAllowed(request)) {
|
|
364
|
+
return response({ error: "invalid_origin" }, 403);
|
|
365
|
+
}
|
|
366
|
+
let user;
|
|
367
|
+
try {
|
|
368
|
+
user = await getSession(request);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
return errorResponse(error);
|
|
372
|
+
}
|
|
373
|
+
if (!user)
|
|
374
|
+
return response({ error: "unauthenticated" }, 401);
|
|
375
|
+
try {
|
|
376
|
+
if (route === "/sessions" && method === "GET") {
|
|
377
|
+
const sessions = (await options.sessions.listByUserId(user.id)).filter((session) => session.expiresAtMs > Date.now());
|
|
378
|
+
return response({ sessions: sessions.map(({ id, createdAtMs, expiresAtMs }) => ({ id, createdAtMs, expiresAtMs })) }, 200);
|
|
379
|
+
}
|
|
380
|
+
if (route === "/sessions") {
|
|
381
|
+
await options.sessions.deleteByUserId(user.id);
|
|
382
|
+
return new Response(null, {
|
|
383
|
+
status: 204,
|
|
384
|
+
headers: { "Set-Cookie": clearedSessionCookie(cookieName) },
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
await options.sessions.deleteByIdForUser(route.slice("/sessions/".length), user.id);
|
|
388
|
+
return new Response(null, { status: 204 });
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (!(error instanceof RequestError))
|
|
392
|
+
await report(`sessions.${method.toLowerCase()}`, error);
|
|
393
|
+
return errorResponse(error);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const emailVerificationRoute = route === "/email-verification/request" || route === "/email-verification/confirm";
|
|
397
|
+
const passwordResetRoute = route === "/password-reset/request" || route === "/password-reset/confirm";
|
|
398
|
+
const enabled = ["/register", "/login", "/logout"].includes(route) ||
|
|
399
|
+
(emailVerificationRoute && !!options.emailVerification) ||
|
|
400
|
+
(passwordResetRoute && !!options.passwordReset);
|
|
401
|
+
if (!enabled)
|
|
402
|
+
return response({ error: "not_found" }, 404);
|
|
403
|
+
if (method !== "POST")
|
|
404
|
+
return response({ error: "method_not_allowed" }, 405, { Allow: "POST" });
|
|
405
|
+
if (!originIsAllowed(request))
|
|
406
|
+
return response({ error: "invalid_origin" }, 403);
|
|
407
|
+
try {
|
|
408
|
+
if (route === "/register")
|
|
409
|
+
return await register(request);
|
|
410
|
+
if (route === "/login")
|
|
411
|
+
return await login(request);
|
|
412
|
+
if (route === "/logout")
|
|
413
|
+
return await logout(request);
|
|
414
|
+
if (route === "/email-verification/request")
|
|
415
|
+
return await requestEmailVerification(request);
|
|
416
|
+
if (route === "/email-verification/confirm")
|
|
417
|
+
return await confirmEmailVerification(request);
|
|
418
|
+
if (route === "/password-reset/request")
|
|
419
|
+
return await requestPasswordReset(request);
|
|
420
|
+
return await confirmPasswordReset(request);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (!(error instanceof RequestError))
|
|
424
|
+
await report(route.slice(1), error);
|
|
425
|
+
return errorResponse(error);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { handle, cleanupExpired, getSession, createSessionResponse };
|
|
429
|
+
}
|