@everystack/auth 0.2.2 → 0.2.4

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.4",
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/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