Skip to content

Commit 62cbda8

Browse files
motatoesclaude
andauthored
fix: retry session refresh when organizationId is missing (#2564)
When a user signs up, there's a race condition where the webhook that creates the user's organization may not have processed yet. This results in the JWT not having an org_id claim, causing WorkOS API calls to fail with "organization_id must be a string". This change adds retry logic to requireAuth() that: - Detects when organizationId is missing from the session - Refreshes the session up to 3 times with 1s delay between attempts - Gives the webhook time to process and assign the organization - Throws a clear error if org is still missing after retries Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8c4c433 commit 62cbda8

File tree

1 file changed

+67
-2
lines changed

1 file changed

+67
-2
lines changed

ui/src/api/helpers.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,37 @@
1-
import { withAuth } from '@/authkit/ssr/session';
1+
import { withAuth, getSessionFromCookie, saveSession } from '@/authkit/ssr/session';
2+
import { getWorkOS } from '@/authkit/ssr/workos';
3+
import { getConfig } from '@/authkit/ssr/config';
4+
import { decodeJwt } from 'jose';
5+
import type { AccessToken } from '@workos-inc/node';
6+
7+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
8+
9+
/**
10+
* Refreshes the session and returns the new organizationId if available.
11+
* This is used when the session doesn't have an org_id yet (e.g., webhook delay).
12+
*/
13+
async function refreshSessionForOrg(): Promise<string | undefined> {
14+
const session = await getSessionFromCookie();
15+
if (!session?.refreshToken) {
16+
return undefined;
17+
}
18+
19+
try {
20+
const { accessToken, refreshToken, user, impersonator } =
21+
await getWorkOS().userManagement.authenticateWithRefreshToken({
22+
clientId: getConfig('clientId'),
23+
refreshToken: session.refreshToken,
24+
});
25+
26+
await saveSession({ accessToken, refreshToken, user, impersonator });
27+
28+
const { org_id: organizationId } = decodeJwt<AccessToken>(accessToken);
29+
return organizationId;
30+
} catch (error) {
31+
console.error('Failed to refresh session for org:', error);
32+
return undefined;
33+
}
34+
}
235

336
function redirectWithFallback(redirectUri: string, headers?: Headers) {
437
const newHeaders = headers ? new Headers(headers) : new Headers();
@@ -18,16 +51,48 @@ function redirectWithFallback(redirectUri: string, headers?: Headers) {
1851
* Requires authentication and returns session-derived user identity.
1952
* Use this instead of accepting userId/organizationId from client input
2053
* to prevent IDOR vulnerabilities.
54+
*
55+
* If organizationId is missing (e.g., webhook hasn't processed yet),
56+
* this will retry by refreshing the session up to 3 times.
2157
*/
2258
export async function requireAuth() {
2359
const auth = await withAuth();
2460
if (!auth.user) {
2561
throw new Error('Unauthorized');
2662
}
63+
64+
let organizationId = auth.organizationId;
65+
66+
// If no organizationId, the webhook may not have processed yet.
67+
// Retry by refreshing the session to get updated JWT claims.
68+
if (!organizationId) {
69+
const MAX_RETRIES = 3;
70+
const RETRY_DELAY_MS = 1000;
71+
72+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
73+
console.log(`No organizationId in session, refreshing (attempt ${attempt}/${MAX_RETRIES})...`);
74+
75+
organizationId = await refreshSessionForOrg();
76+
77+
if (organizationId) {
78+
console.log(`Organization found after ${attempt} refresh attempt(s): ${organizationId}`);
79+
break;
80+
}
81+
82+
if (attempt < MAX_RETRIES) {
83+
await sleep(RETRY_DELAY_MS);
84+
}
85+
}
86+
}
87+
88+
if (!organizationId) {
89+
throw new Error('No organization available. Please try logging out and back in.');
90+
}
91+
2792
return {
2893
userId: auth.user.id,
2994
email: auth.user.email!,
30-
organizationId: auth.organizationId!,
95+
organizationId,
3196
};
3297
}
3398

0 commit comments

Comments
 (0)