@everystack/auth 0.2.2 → 0.2.5
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/package.json +1 -1
- package/src/client/index.tsx +20 -2
- package/src/cookies.ts +7 -3
- package/src/index.ts +28 -0
- package/src/plugin.ts +31 -20
package/package.json
CHANGED
package/src/client/index.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* const { user, token, signIn, signUp, signOut, isLoading } = useAuth();
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
14
|
+
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
|
15
15
|
import { refreshAuthToken } from '../api';
|
|
16
16
|
|
|
17
17
|
export interface AuthUser {
|
|
@@ -81,11 +81,26 @@ export function isTokenExpired(token: string): boolean {
|
|
|
81
81
|
} catch { return true; }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export
|
|
84
|
+
export interface AuthProviderProps {
|
|
85
|
+
children: React.ReactNode;
|
|
86
|
+
/**
|
|
87
|
+
* Called after a successful auth transition — the seam for product-analytics
|
|
88
|
+
* emitters (e.g. createAnalyticsClient().track('login')). Optional; kept generic
|
|
89
|
+
* so the auth package never depends on the analytics client.
|
|
90
|
+
*/
|
|
91
|
+
onAuthEvent?: (event: 'login' | 'signup' | 'logout', user: AuthUser | null) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function AuthProvider({ children, onAuthEvent }: AuthProviderProps): React.ReactElement {
|
|
85
95
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
86
96
|
const [token, setToken] = useState<string | null>(null);
|
|
87
97
|
const [isLoading, setIsLoading] = useState(true);
|
|
88
98
|
|
|
99
|
+
// Ref-backed so signIn/signUp/signOut stay stable (no dep churn) yet always
|
|
100
|
+
// call the latest handler.
|
|
101
|
+
const onAuthEventRef = useRef(onAuthEvent);
|
|
102
|
+
onAuthEventRef.current = onAuthEvent;
|
|
103
|
+
|
|
89
104
|
useEffect(() => {
|
|
90
105
|
const init = async () => {
|
|
91
106
|
const savedToken = storage.getToken();
|
|
@@ -129,6 +144,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
|
|
|
129
144
|
storage.save(body.token, body.refreshToken, body.user);
|
|
130
145
|
setToken(body.token);
|
|
131
146
|
setUser(body.user);
|
|
147
|
+
onAuthEventRef.current?.('login', body.user);
|
|
132
148
|
}, []);
|
|
133
149
|
|
|
134
150
|
const signUp = useCallback(async (email: string, password: string, username: string, displayName: string) => {
|
|
@@ -147,11 +163,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
|
|
|
147
163
|
storage.save(body.token, body.refreshToken, body.user);
|
|
148
164
|
setToken(body.token);
|
|
149
165
|
setUser(body.user);
|
|
166
|
+
onAuthEventRef.current?.('signup', body.user);
|
|
150
167
|
}, []);
|
|
151
168
|
|
|
152
169
|
const signOut = useCallback(() => {
|
|
153
170
|
// Clear server-side cookies (best-effort, don't block UI)
|
|
154
171
|
fetch('/api/auth/signout', { method: 'POST' }).catch(() => {});
|
|
172
|
+
onAuthEventRef.current?.('logout', storage.getUser());
|
|
155
173
|
storage.clear();
|
|
156
174
|
setToken(null);
|
|
157
175
|
setUser(null);
|
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
|
@@ -180,6 +180,32 @@ function csrfCheck(request: Request, cookies?: CookieConfig): Response | null {
|
|
|
180
180
|
return validateCsrfOrigin(request, cookies.allowedOrigins);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Fail-closed cookie config check, run once at handler construction.
|
|
185
|
+
*
|
|
186
|
+
* Cookie-authenticated requests carry a CSRF surface (the cookie is auto-sent).
|
|
187
|
+
* The Origin allowlist is the defense-in-depth behind SameSite — and it silently
|
|
188
|
+
* no-ops when `allowedOrigins` is empty, so enabling cookies without it would turn
|
|
189
|
+
* the CSRF check off without any signal. We refuse to start instead: a misconfig
|
|
190
|
+
* dies at deploy, not silently-allows until a request happens to hit the check.
|
|
191
|
+
*
|
|
192
|
+
* The `cookies: true` shorthand cannot carry an allowlist, so it is rejected —
|
|
193
|
+
* there is no construction-time same-origin to derive. Pass an explicit allowlist
|
|
194
|
+
* or omit cookies entirely for Bearer-only (mobile/API) auth.
|
|
195
|
+
*/
|
|
196
|
+
function assertSafeCookieConfig(cookies: CookieConfig | undefined): void {
|
|
197
|
+
if (!cookies) return; // cookie auth disabled (false/undefined) — Bearer-only, no CSRF surface
|
|
198
|
+
const allowedOrigins = cookies === true ? undefined : cookies.allowedOrigins;
|
|
199
|
+
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'[everystack/auth] Cookie authentication is enabled but cookies.allowedOrigins is empty. '
|
|
202
|
+
+ 'Cookie-authenticated requests carry a CSRF surface; an explicit Origin allowlist is '
|
|
203
|
+
+ 'required. Pass cookies: { allowedOrigins: ["https://your-web-origin"] } instead of '
|
|
204
|
+
+ 'cookies: true, or omit cookies for Bearer-only (mobile/API) auth.'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
183
209
|
/**
|
|
184
210
|
* Add mode-independent email confirmation and password management handlers.
|
|
185
211
|
* Called after base handlers are created (works with both node and postgres modes).
|
|
@@ -389,6 +415,8 @@ export function createAuthHandlers(
|
|
|
389
415
|
jwtSecret: Uint8Array,
|
|
390
416
|
options?: AuthOptions
|
|
391
417
|
): AuthHandlers {
|
|
418
|
+
assertSafeCookieConfig(options?.cookies);
|
|
419
|
+
|
|
392
420
|
if (options?.mode === 'postgres') {
|
|
393
421
|
const handlers = createPostgresAuthHandlers(db, schema, jwtSecret, options);
|
|
394
422
|
addEmailPasswordFlows(handlers, db, schema.users, options);
|
package/src/plugin.ts
CHANGED
|
@@ -176,33 +176,44 @@ export function authPlugin(options: AuthPluginOptions = {}): Plugin {
|
|
|
176
176
|
|
|
177
177
|
// --- Wire enrichClaims ref ---
|
|
178
178
|
const authCtx = ctx as AuthContext;
|
|
179
|
-
if (authCtx.enrichClaimsRef && clientCallbacks) {
|
|
179
|
+
if (authCtx.enrichClaimsRef && (clientCallbacks || options.audience)) {
|
|
180
180
|
authCtx.enrichClaimsRef.current = async (
|
|
181
181
|
baseClaims: Record<string, unknown>,
|
|
182
182
|
request: Request,
|
|
183
183
|
): Promise<Record<string, unknown>> => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
184
|
+
if (clientCallbacks) {
|
|
185
|
+
// Check baseClaims first (refresh path — client_id from session data)
|
|
186
|
+
let clientId = baseClaims.client_id as string | undefined;
|
|
187
|
+
|
|
188
|
+
// Fall back to request body (signin/signup path)
|
|
189
|
+
if (!clientId) {
|
|
190
|
+
try {
|
|
191
|
+
const body = await request.clone().json();
|
|
192
|
+
clientId = body?.client_id;
|
|
193
|
+
} catch {
|
|
194
|
+
// No JSON body or no client_id
|
|
195
|
+
}
|
|
194
196
|
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (!clientId) return {};
|
|
198
197
|
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
if (clientId) {
|
|
199
|
+
// An explicit client_id is an assertion of client-credential auth:
|
|
200
|
+
// unknown or revoked clients get no claims, so under an audience
|
|
201
|
+
// config their tokens fail verification — never a silent downgrade
|
|
202
|
+
// to first-party.
|
|
203
|
+
const client = await clientCallbacks.resolve(clientId);
|
|
204
|
+
if (!client || !client.active) return {};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
client_id: clientId,
|
|
208
|
+
aud: client.audience ?? options.audience,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
201
212
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
};
|
|
213
|
+
// First-party signin/refresh: tokens this app issues for its own
|
|
214
|
+
// users carry the app audience — the audience layer rejects any
|
|
215
|
+
// token without it, including the ones we mint ourselves.
|
|
216
|
+
return options.audience ? { aud: options.audience } : {};
|
|
206
217
|
};
|
|
207
218
|
}
|
|
208
219
|
|