@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -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 function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
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
- * Both are HttpOnly + Secure + SameSite=Lax.
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=Lax; Path=/api/auth/refresh; Max-Age=${refreshMaxAge}${domain}`,
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=Lax; Path=/api/auth/refresh; Max-Age=0${domain}`,
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
- // Check baseClaims first (refresh path — client_id from session data)
185
- let clientId = baseClaims.client_id as string | undefined;
186
-
187
- // Fall back to request body (signin/signup path)
188
- if (!clientId) {
189
- try {
190
- const body = await request.clone().json();
191
- clientId = body?.client_id;
192
- } catch {
193
- // No JSON body or no client_id
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
- const client = await clientCallbacks.resolve(clientId);
200
- if (!client || !client.active) return {};
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
- return {
203
- client_id: clientId,
204
- aud: client.audience ?? options.audience,
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