@workos-inc/authkit-nextjs 2.4.6 → 2.6.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.
@@ -1,204 +1,151 @@
1
- import { useCallback, useEffect, useReducer, useRef } from 'react';
2
- import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
2
  import { useAuth } from './authkit-provider.js';
4
- import { decodeJwt } from '../jwt.js';
5
-
6
- const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
7
- const MIN_REFRESH_DELAY_SECONDS = 15; // minimum delay before refreshing token
8
- const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60; // 24 hours
9
- const RETRY_DELAY_SECONDS = 300; // 5 minutes
10
-
11
- interface TokenState {
12
- token: string | undefined;
3
+ import { tokenStore } from './tokenStore.js';
4
+
5
+ export interface UseAccessTokenReturn {
6
+ /**
7
+ * Current access token. May be stale when tab is inactive.
8
+ * Use this for display purposes or where eventual consistency is acceptable.
9
+ */
10
+ accessToken: string | undefined;
11
+ /**
12
+ * Loading state for initial token fetch
13
+ */
13
14
  loading: boolean;
15
+ /**
16
+ * Error from the last token operation
17
+ */
14
18
  error: Error | null;
15
- }
16
-
17
- type TokenAction =
18
- | { type: 'FETCH_START' }
19
- | { type: 'FETCH_SUCCESS'; token: string | undefined }
20
- | { type: 'FETCH_ERROR'; error: Error }
21
- | { type: 'RESET' };
22
-
23
- function tokenReducer(state: TokenState, action: TokenAction): TokenState {
24
- switch (action.type) {
25
- case 'FETCH_START':
26
- return { ...state, loading: true, error: null };
27
- case 'FETCH_SUCCESS':
28
- return { ...state, loading: false, token: action.token, error: null };
29
- case 'FETCH_ERROR':
30
- return { ...state, loading: false, error: action.error };
31
- case 'RESET':
32
- return { ...state, token: undefined, loading: false, error: null };
33
- // istanbul ignore next
34
- default:
35
- return state;
36
- }
37
- }
38
-
39
- function getRefreshDelay(timeUntilExpiry: number) {
40
- const idealDelay = (timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000;
41
- return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000);
42
- }
43
-
44
- function parseTokenPayload(token: string | undefined) {
45
- // istanbul ignore next
46
- if (!token) {
47
- return null;
48
- }
49
-
50
- try {
51
- const { payload } = decodeJwt(token);
52
- const now = Math.floor(Date.now() / 1000);
53
-
54
- // istanbul ignore next - if the token does not have an exp claim, we cannot determine expiry
55
- if (typeof payload.exp !== 'number') {
56
- return null;
57
- }
58
-
59
- return {
60
- payload,
61
- expiresAt: payload.exp,
62
- isExpiring: payload.exp < now + TOKEN_EXPIRY_BUFFER_SECONDS,
63
- timeUntilExpiry: payload.exp - now,
64
- };
65
- } catch {
66
- // istanbul ignore next
67
- return null;
68
- }
19
+ /**
20
+ * Manually trigger a token refresh
21
+ */
22
+ refresh: () => Promise<string | undefined>;
23
+ /**
24
+ * Get a guaranteed fresh access token. Automatically refreshes if needed.
25
+ * Use this for API calls where token freshness is critical.
26
+ * @returns Promise resolving to fresh token or undefined if not authenticated
27
+ * @throws Error if refresh fails
28
+ */
29
+ getAccessToken: () => Promise<string | undefined>;
69
30
  }
70
31
 
71
32
  /**
72
33
  * A hook that manages access tokens with automatic refresh.
73
34
  */
74
- export function useAccessToken() {
75
- const { user, sessionId, refreshAuth } = useAuth();
35
+ export function useAccessToken(): UseAccessTokenReturn {
36
+ const { user, sessionId } = useAuth();
76
37
  const userId = user?.id;
77
- const [state, dispatch] = useReducer(tokenReducer, {
78
- token: undefined,
79
- loading: false,
80
- error: null,
38
+ const userRef = useRef(user);
39
+ userRef.current = user;
40
+ const prevSessionRef = useRef(sessionId);
41
+ const prevUserIdRef = useRef(userId);
42
+
43
+ const tokenState = useSyncExternalStore(tokenStore.subscribe, tokenStore.getSnapshot, tokenStore.getServerSnapshot);
44
+
45
+ // Track if we're waiting for the initial token fetch for the current user
46
+ // Initialize synchronously to prevent first-paint flash
47
+ const [isInitialTokenLoading, setIsInitialTokenLoading] = useState(() => {
48
+ // Only show loading if we have a user but no token yet
49
+ return Boolean(user && !tokenState.token && !tokenState.error);
81
50
  });
82
51
 
83
- const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
84
- const fetchingRef = useRef(false);
85
-
86
- const clearRefreshTimeout = useCallback(() => {
87
- if (refreshTimeoutRef.current) {
88
- clearTimeout(refreshTimeoutRef.current);
89
- refreshTimeoutRef.current = undefined;
90
- }
91
- }, []);
92
-
93
- // Store the current token in a ref to avoid stale closures
94
- const currentTokenRef = useRef<string | undefined>(state.token);
95
- currentTokenRef.current = state.token;
96
-
97
- // Store updateToken in a ref to break circular dependency
98
- const updateTokenRef = useRef<() => Promise<string | undefined>>();
99
-
100
- // Centralized timer scheduling function
101
- const scheduleNextRefresh = useCallback(
102
- (delay: number) => {
103
- clearRefreshTimeout();
104
- refreshTimeoutRef.current = setTimeout(() => {
105
- if (updateTokenRef.current) {
106
- updateTokenRef.current();
107
- }
108
- }, delay);
109
- },
110
- [clearRefreshTimeout],
111
- );
112
-
113
- const updateToken = useCallback(async () => {
114
- // istanbul ignore next - safety guard against concurrent fetches
115
- if (fetchingRef.current) {
52
+ useEffect(() => {
53
+ if (!user) {
54
+ tokenStore.clearToken();
55
+ setIsInitialTokenLoading(false);
116
56
  return;
117
57
  }
118
58
 
119
- fetchingRef.current = true;
59
+ // Only clear token if user or session actually changed (not on initial mount)
60
+ const sessionChanged = prevSessionRef.current !== undefined && prevSessionRef.current !== sessionId;
61
+ const userChanged = prevUserIdRef.current !== undefined && prevUserIdRef.current !== userId;
120
62
 
121
- try {
122
- let token = await getAccessTokenAction();
123
- if (token) {
124
- const tokenData = parseTokenPayload(token);
125
- if (!tokenData || tokenData.isExpiring) {
126
- token = await refreshAccessTokenAction();
127
- }
128
- }
63
+ if (sessionChanged || userChanged) {
64
+ tokenStore.clearToken();
65
+ }
129
66
 
130
- // Only update state if token has changed
131
- if (token !== currentTokenRef.current) {
132
- dispatch({ type: 'FETCH_SUCCESS', token });
133
- }
67
+ prevSessionRef.current = sessionId;
68
+ prevUserIdRef.current = userId;
134
69
 
135
- if (token) {
136
- const tokenData = parseTokenPayload(token);
137
- if (tokenData) {
138
- const delay = getRefreshDelay(tokenData.timeUntilExpiry);
139
- scheduleNextRefresh(delay);
140
- }
141
- }
70
+ // Check if getAccessTokenSilently will actually fetch (not just return cached)
71
+ const currentToken = tokenStore.getSnapshot().token;
72
+ const tokenData = currentToken ? tokenStore.parseToken(currentToken) : null;
73
+ const willActuallyFetch = !currentToken || (tokenData && tokenData.isExpiring);
142
74
 
143
- return token;
144
- } catch (error) {
145
- dispatch({ type: 'FETCH_ERROR', error: error instanceof Error ? error : new Error(String(error)) });
146
- scheduleNextRefresh(RETRY_DELAY_SECONDS * 1000);
147
- } finally {
148
- fetchingRef.current = false;
75
+ // Only show loading if we're actually going to fetch
76
+ if (willActuallyFetch) {
77
+ setIsInitialTokenLoading(true);
149
78
  }
150
- }, [scheduleNextRefresh]);
151
79
 
152
- // Assign updateToken to ref for use in scheduleNextRefresh
153
- updateTokenRef.current = updateToken;
80
+ /* istanbul ignore next */
81
+ tokenStore
82
+ .getAccessTokenSilently()
83
+ .catch(() => {
84
+ // Error is handled in the store
85
+ })
86
+ .finally(() => {
87
+ // Only clear loading if we were actually loading
88
+ if (willActuallyFetch) {
89
+ setIsInitialTokenLoading(false);
90
+ }
91
+ });
92
+ }, [userId, sessionId]);
154
93
 
155
- const refresh = useCallback(async () => {
156
- if (fetchingRef.current) {
94
+ useEffect(() => {
95
+ if (!user || typeof document === 'undefined') {
157
96
  return;
158
97
  }
159
98
 
160
- fetchingRef.current = true;
161
- dispatch({ type: 'FETCH_START' });
99
+ /* istanbul ignore next */
100
+ const refreshIfNeeded = () => {
101
+ tokenStore.getAccessTokenSilently().catch(() => {
102
+ // Error is handled in the store
103
+ });
104
+ };
162
105
 
163
- try {
164
- await refreshAuth();
165
- const token = await getAccessTokenAction();
106
+ /* istanbul ignore next */
107
+ const handleWake = (event: Event) => {
108
+ if (event.type !== 'visibilitychange' || document.visibilityState === 'visible') {
109
+ refreshIfNeeded();
110
+ }
111
+ };
166
112
 
167
- dispatch({ type: 'FETCH_SUCCESS', token });
113
+ document.addEventListener('visibilitychange', handleWake);
114
+ window.addEventListener('focus', handleWake);
115
+ window.addEventListener('online', handleWake);
116
+ window.addEventListener('pageshow', handleWake);
168
117
 
169
- if (token) {
170
- const tokenData = parseTokenPayload(token);
171
- if (tokenData) {
172
- const delay = getRefreshDelay(tokenData.timeUntilExpiry);
173
- scheduleNextRefresh(delay);
174
- }
175
- }
118
+ return () => {
119
+ document.removeEventListener('visibilitychange', handleWake);
120
+ window.removeEventListener('focus', handleWake);
121
+ window.removeEventListener('online', handleWake);
122
+ window.removeEventListener('pageshow', handleWake);
123
+ };
124
+ }, [userId, sessionId]);
176
125
 
177
- return token;
178
- } catch (error) {
179
- const typedError = error instanceof Error ? error : new Error(String(error));
180
- dispatch({ type: 'FETCH_ERROR', error: typedError });
181
- scheduleNextRefresh(RETRY_DELAY_SECONDS * 1000);
182
- } finally {
183
- fetchingRef.current = false;
126
+ const getAccessToken = useCallback(async (): Promise<string | undefined> => {
127
+ if (!userRef.current) {
128
+ return undefined;
184
129
  }
185
- }, [refreshAuth, scheduleNextRefresh, updateToken]);
130
+ return tokenStore.getAccessToken();
131
+ }, []);
186
132
 
187
- useEffect(() => {
188
- if (!user) {
189
- dispatch({ type: 'RESET' });
190
- clearRefreshTimeout();
191
- return;
133
+ // Stable refresh function
134
+ const refresh = useCallback(async (): Promise<string | undefined> => {
135
+ if (!userRef.current) {
136
+ return undefined;
192
137
  }
193
- updateToken();
138
+ return tokenStore.refreshToken();
139
+ }, []);
194
140
 
195
- return clearRefreshTimeout;
196
- }, [userId, sessionId, clearRefreshTimeout]);
141
+ // Combine loading states: initial token fetch OR token store is loading
142
+ const isLoading = isInitialTokenLoading || tokenState.loading;
197
143
 
198
144
  return {
199
- accessToken: state.token,
200
- loading: state.loading,
201
- error: state.error,
145
+ accessToken: tokenState.token,
146
+ loading: isLoading,
147
+ error: tokenState.error,
202
148
  refresh,
149
+ getAccessToken,
203
150
  };
204
151
  }
@@ -11,6 +11,7 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
11
11
  organizationId,
12
12
  redirectUri = headersList.get('x-redirect-uri'),
13
13
  loginHint,
14
+ prompt,
14
15
  } = options;
15
16
 
16
17
  return getWorkOS().userManagement.getAuthorizationUrl({
@@ -21,6 +22,7 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
21
22
  screenHint,
22
23
  organizationId,
23
24
  loginHint,
25
+ prompt,
24
26
  });
25
27
  }
26
28
 
package/src/interfaces.ts CHANGED
@@ -63,6 +63,7 @@ export interface GetAuthURLOptions {
63
63
  organizationId?: string;
64
64
  redirectUri?: string;
65
65
  loginHint?: string;
66
+ prompt?: 'consent';
66
67
  }
67
68
 
68
69
  export interface AuthkitMiddlewareAuth {
package/src/session.ts CHANGED
@@ -415,7 +415,7 @@ async function verifyAccessToken(accessToken: string) {
415
415
  }
416
416
  }
417
417
 
418
- async function getSessionFromCookie(request?: NextRequest) {
418
+ export async function getSessionFromCookie(request?: NextRequest) {
419
419
  const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
420
420
  let cookie;
421
421
 
package/src/workos.ts CHANGED
@@ -2,7 +2,7 @@ import { WorkOS } from '@workos-inc/node';
2
2
  import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js';
3
3
  import { lazy } from './utils.js';
4
4
 
5
- export const VERSION = '2.4.6';
5
+ export const VERSION = '2.6.0';
6
6
 
7
7
  const options = {
8
8
  apiHostname: WORKOS_API_HOSTNAME,