@everystack/auth 0.2.4 → 0.2.6
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 +681 -0
- package/README.md +5 -1
- package/package.json +19 -9
- package/src/cookies.ts +7 -3
- package/src/index.ts +51 -6
- package/src/postgres.ts +458 -151
- package/src/sql.ts +219 -0
package/README.md
CHANGED
|
@@ -247,4 +247,8 @@ Part of [everystack](https://github.com/scalable-technology/everystack) — a se
|
|
|
247
247
|
|
|
248
248
|
## License
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
|
|
251
|
+
|
|
252
|
+
A commercial license is available for organizations that cannot or do not wish to
|
|
253
|
+
comply with the AGPL-3.0 terms. For commercial licensing, contact
|
|
254
|
+
licensing@scalable.technology.
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "JWT authentication handlers, password hashing, and React auth context",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scalable-technology/everystack.git",
|
|
10
|
+
"directory": "packages/auth"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/scalable-technology/everystack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/scalable-technology/everystack/issues"
|
|
15
|
+
},
|
|
6
16
|
"publishConfig": {
|
|
7
17
|
"access": "public"
|
|
8
18
|
},
|
|
@@ -75,11 +85,6 @@
|
|
|
75
85
|
"default": "./src/testing.ts"
|
|
76
86
|
}
|
|
77
87
|
},
|
|
78
|
-
"scripts": {
|
|
79
|
-
"test": "jest",
|
|
80
|
-
"build": "tsc --build",
|
|
81
|
-
"lint": "tsc --noEmit"
|
|
82
|
-
},
|
|
83
88
|
"dependencies": {
|
|
84
89
|
"bcryptjs": "3.0.3"
|
|
85
90
|
},
|
|
@@ -101,7 +106,6 @@
|
|
|
101
106
|
}
|
|
102
107
|
},
|
|
103
108
|
"devDependencies": {
|
|
104
|
-
"@everystack/api": "workspace:*",
|
|
105
109
|
"@types/bcryptjs": "3.0.0",
|
|
106
110
|
"@types/jest": "29.5.14",
|
|
107
111
|
"@types/node": "22.19.18",
|
|
@@ -110,6 +114,12 @@
|
|
|
110
114
|
"jest": "29.7.0",
|
|
111
115
|
"postgres": "3.4.9",
|
|
112
116
|
"ts-jest": "29.4.9",
|
|
113
|
-
"typescript": "5.9.3"
|
|
117
|
+
"typescript": "5.9.3",
|
|
118
|
+
"@everystack/api": "0.2.9"
|
|
119
|
+
},
|
|
120
|
+
"scripts": {
|
|
121
|
+
"test": "jest",
|
|
122
|
+
"build": "tsc --build",
|
|
123
|
+
"lint": "tsc --noEmit"
|
|
114
124
|
}
|
|
115
|
-
}
|
|
125
|
+
}
|
package/src/cookies.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Access token cookie is sent on all requests (Path=/).
|
|
5
5
|
* Refresh token cookie is scoped to the refresh endpoint only (Path=/api/auth/refresh).
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Access token is SameSite=Lax — it must ride top-level SSR navigations into the
|
|
8
|
+
* app. Refresh token is SameSite=Strict — it is only ever hit by XHR at its scoped
|
|
9
|
+
* path, never a navigation target, so Strict kills its CSRF surface by construction
|
|
10
|
+
* (a cross-site page can't make the browser attach it at all).
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
export const COOKIE_NAMES = {
|
|
@@ -44,7 +48,7 @@ export function serializeAuthCookies(
|
|
|
44
48
|
|
|
45
49
|
return [
|
|
46
50
|
`${COOKIE_NAMES.token}=${token}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=${basePath}; Max-Age=${tokenMaxAge}${domain}`,
|
|
47
|
-
`${COOKIE_NAMES.refresh}=${refreshToken}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=
|
|
51
|
+
`${COOKIE_NAMES.refresh}=${refreshToken}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/api/auth/refresh; Max-Age=${refreshMaxAge}${domain}`,
|
|
48
52
|
];
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -58,7 +62,7 @@ export function clearAuthCookies(opts?: CookieOptions): string[] {
|
|
|
58
62
|
|
|
59
63
|
return [
|
|
60
64
|
`${COOKIE_NAMES.token}=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=${basePath}; Max-Age=0${domain}`,
|
|
61
|
-
`${COOKIE_NAMES.refresh}=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=
|
|
65
|
+
`${COOKIE_NAMES.refresh}=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/api/auth/refresh; Max-Age=0${domain}`,
|
|
62
66
|
];
|
|
63
67
|
}
|
|
64
68
|
|
package/src/index.ts
CHANGED
|
@@ -147,10 +147,22 @@ export interface AuthOptions {
|
|
|
147
147
|
/** One-time token operations for email confirmation and password recovery.
|
|
148
148
|
* Required for confirmEmail, forgotPassword, and resetPassword handlers. */
|
|
149
149
|
tokens?: TokenOperations;
|
|
150
|
-
/** Called after successful signup when tokens are configured. App sends confirmation email.
|
|
150
|
+
/** Called after successful signup when tokens are configured. App sends confirmation email.
|
|
151
|
+
* Receives a token MINTED IN THE API — owner-only, so this breaks under least-privilege.
|
|
152
|
+
* Postgres least-priv path uses `onConfirmationRequested` instead (mint on the worker). */
|
|
151
153
|
onConfirmationRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
152
|
-
/** Called on forgot-password
|
|
154
|
+
/** Called on forgot-password with a token MINTED IN THE API (owner-only — breaks under
|
|
155
|
+
* least-privilege). Postgres least-priv path uses `onRecoveryRequested` instead. */
|
|
153
156
|
onRecoveryRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
157
|
+
/** Least-privilege confirmation: called after signup with only the new user's identity.
|
|
158
|
+
* The API does NOT mint a token — the app enqueues a job; the privileged worker mints the
|
|
159
|
+
* one-time token and sends the email. Takes precedence over onConfirmationRequired. */
|
|
160
|
+
onConfirmationRequested?: (user: { id: string; email: string }) => Promise<void>;
|
|
161
|
+
/** Least-privilege recovery: called on forgot-password with ONLY the email. The API does
|
|
162
|
+
* not read the user table or mint a token — it enqueues a job; the privileged worker
|
|
163
|
+
* looks up the user, mints the recovery token, and sends the email. Always responds the
|
|
164
|
+
* same to the client (no user enumeration). Takes precedence over onRecoveryRequired. */
|
|
165
|
+
onRecoveryRequested?: (email: string) => Promise<void>;
|
|
154
166
|
/** Called after email confirmed via one-time token — app updates user record. */
|
|
155
167
|
onEmailConfirmed?: (userId: string) => Promise<void>;
|
|
156
168
|
}
|
|
@@ -180,9 +192,40 @@ function csrfCheck(request: Request, cookies?: CookieConfig): Response | null {
|
|
|
180
192
|
return validateCsrfOrigin(request, cookies.allowedOrigins);
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Fail-closed cookie config check, run once at handler construction.
|
|
197
|
+
*
|
|
198
|
+
* Cookie-authenticated requests carry a CSRF surface (the cookie is auto-sent).
|
|
199
|
+
* The Origin allowlist is the defense-in-depth behind SameSite — and it silently
|
|
200
|
+
* no-ops when `allowedOrigins` is empty, so enabling cookies without it would turn
|
|
201
|
+
* the CSRF check off without any signal. We refuse to start instead: a misconfig
|
|
202
|
+
* dies at deploy, not silently-allows until a request happens to hit the check.
|
|
203
|
+
*
|
|
204
|
+
* The `cookies: true` shorthand cannot carry an allowlist, so it is rejected —
|
|
205
|
+
* there is no construction-time same-origin to derive. Pass an explicit allowlist
|
|
206
|
+
* or omit cookies entirely for Bearer-only (mobile/API) auth.
|
|
207
|
+
*/
|
|
208
|
+
function assertSafeCookieConfig(cookies: CookieConfig | undefined): void {
|
|
209
|
+
if (!cookies) return; // cookie auth disabled (false/undefined) — Bearer-only, no CSRF surface
|
|
210
|
+
const allowedOrigins = cookies === true ? undefined : cookies.allowedOrigins;
|
|
211
|
+
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'[everystack/auth] Cookie authentication is enabled but cookies.allowedOrigins is empty. '
|
|
214
|
+
+ 'Cookie-authenticated requests carry a CSRF surface; an explicit Origin allowlist is '
|
|
215
|
+
+ 'required. Pass cookies: { allowedOrigins: ["https://your-web-origin"] } instead of '
|
|
216
|
+
+ 'cookies: true, or omit cookies for Bearer-only (mobile/API) auth.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
183
221
|
/**
|
|
184
222
|
* Add mode-independent email confirmation and password management handlers.
|
|
185
223
|
* Called after base handlers are created (works with both node and postgres modes).
|
|
224
|
+
*
|
|
225
|
+
* Uses `??=` so a handler already provided by the base (e.g. the least-privilege
|
|
226
|
+
* postgres confirmEmail/resetPassword that run via SECDEF wrappers under withRole)
|
|
227
|
+
* is NOT overwritten by the generic owner-connection version. Node mode provides
|
|
228
|
+
* none of these, so it gets the generic implementations.
|
|
186
229
|
*/
|
|
187
230
|
function addEmailPasswordFlows(
|
|
188
231
|
handlers: AuthHandlers,
|
|
@@ -191,7 +234,7 @@ function addEmailPasswordFlows(
|
|
|
191
234
|
options?: AuthOptions,
|
|
192
235
|
): void {
|
|
193
236
|
if (options?.tokens) {
|
|
194
|
-
handlers.confirmEmail
|
|
237
|
+
handlers.confirmEmail ??= async (request: Request): Promise<Response> => {
|
|
195
238
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
196
239
|
if (csrfError) return csrfError;
|
|
197
240
|
|
|
@@ -218,7 +261,7 @@ function addEmailPasswordFlows(
|
|
|
218
261
|
}
|
|
219
262
|
};
|
|
220
263
|
|
|
221
|
-
handlers.forgotPassword
|
|
264
|
+
handlers.forgotPassword ??= async (request: Request): Promise<Response> => {
|
|
222
265
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
223
266
|
if (csrfError) return csrfError;
|
|
224
267
|
|
|
@@ -253,7 +296,7 @@ function addEmailPasswordFlows(
|
|
|
253
296
|
return Response.json({ sent: true });
|
|
254
297
|
};
|
|
255
298
|
|
|
256
|
-
handlers.resetPassword
|
|
299
|
+
handlers.resetPassword ??= async (request: Request): Promise<Response> => {
|
|
257
300
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
258
301
|
if (csrfError) return csrfError;
|
|
259
302
|
|
|
@@ -287,7 +330,7 @@ function addEmailPasswordFlows(
|
|
|
287
330
|
};
|
|
288
331
|
}
|
|
289
332
|
|
|
290
|
-
handlers.changePassword
|
|
333
|
+
handlers.changePassword ??= async (request: Request): Promise<Response> => {
|
|
291
334
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
292
335
|
if (csrfError) return csrfError;
|
|
293
336
|
|
|
@@ -389,6 +432,8 @@ export function createAuthHandlers(
|
|
|
389
432
|
jwtSecret: Uint8Array,
|
|
390
433
|
options?: AuthOptions
|
|
391
434
|
): AuthHandlers {
|
|
435
|
+
assertSafeCookieConfig(options?.cookies);
|
|
436
|
+
|
|
392
437
|
if (options?.mode === 'postgres') {
|
|
393
438
|
const handlers = createPostgresAuthHandlers(db, schema, jwtSecret, options);
|
|
394
439
|
addEmailPasswordFlows(handlers, db, schema.users, options);
|