Skip to content

fix(provider): read organizationId from access token after client initialization#98

Open
nicknisi wants to merge 1 commit into
mainfrom
fix/issue-87
Open

fix(provider): read organizationId from access token after client initialization#98
nicknisi wants to merge 1 commit into
mainfrom
fix/issue-87

Conversation

@nicknisi
Copy link
Copy Markdown
Member

Summary

After createClient() resolves, the .then() callback was setting state with only isLoading: false and user, spreading from initialState which has organizationId: null. This overwrote any organizationId (and other auth metadata) that had been correctly set by the onRefresh/handleRefresh callback moments earlier due to React state batching.

The fix reads auth metadata (organizationId, role, roles, permissions, featureFlags) directly from the access token's JWT claims in the .then() callback, using the existing getClaims() helper. This ensures state is fully populated regardless of callback ordering. A try/catch handles unauthenticated sessions gracefully.

Manual reproduction steps

  1. Configure AuthKitProvider with an onRefresh callback that logs response.organizationId
  2. Sign in with a user that has an organization selected during the WorkOS login flow
  3. After redirect back, check useAuth().organizationId

On main: organizationId is null even though onRefresh received the correct value — the .then() callback overwrites it
On this branch: organizationId is correctly populated from the access token JWT claims

What was tested

Automated

3 vitest unit tests added and passing:

  1. organizationId is populated from JWT claims after createClient resolves
  2. Auth metadata (role, permissions, featureFlags) comes from token claims, not initialState defaults
  3. onRefresh-set organizationId is not overwritten by the .then() callback

Manual

  • Built SDK and linked to example app via npm link
  • Started example app, signed in through AuthKit hosted login flow
  • Verified all useAuth() fields are properly populated on the Account page
  • Independent scenario script verified: getClaims() correctly extracts org_id from JWT, provider source uses getClaims in .then() callback, old bug pattern removed, catch block handles unauthenticated sessions
  • 9 scenario assertions pass

Verification

Before

before.png

After

after.png

Issue

Closes #87

Follow-ups

  • @types/react-dom@^19 in devDependencies conflicts with @types/react@18.3.3 — version mismatch may cause type-checking issues in development (pre-existing, not introduced by this fix)
  • Single commit bundles bug fix and test infrastructure setup (vitest, testing-library, jsdom) — could be split for cleaner history but acceptable given focused scope

…tialization

After createClient() resolves, the .then() callback previously spread
initialState (which has organizationId: null) into the new state, thereby
overwriting any organizationId that onRefresh had already set.

Now the .then() callback reads JWT claims via getClaims() from the access
token and includes organizationId, role, roles, permissions, and
featureFlags in the setState call.

Adds vitest + @testing-library/react and three tests proving:
1. organizationId is populated from access token claims
2. Auth metadata comes from JWT claims, not initialState defaults
3. onRefresh-set organizationId is not overwritten

Closes #87
Copy link
Copy Markdown
Member Author

@nicknisi nicknisi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Findings

Warnings

  • [Convention] @types/react-dom@^19 conflicts with @types/react@18.3.3 in devDependencies — version mismatch may cause type-checking issues in development (package.json:33)

Info

  • [Principle 10] Single commit bundles bug fix and test infrastructure setup (vitest, testing-library, jsdom). Ideally separate commits for fix vs test infra, but acceptable given focused scope.

Automated review by case/reviewer agent

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR fixes a state-overwrite bug where the .then() callback after createClient() was spreading initialState (which has organizationId: null), stomping on values set by a concurrent handleRefresh call due to React state batching. The fix reads org_id, role, roles, permissions, and featureFlags directly from the access token JWT claims in the same .then() callback, ensuring state is fully populated regardless of callback ordering.

  • src/provider.tsx: replaces the setState({ ...initialState, isLoading: false, user }) pattern with an async getClaims()-based read inside a try/catch; the catch path handles unauthenticated sessions where getAccessToken() throws.
  • src/__tests__/provider.test.tsx: adds three vitest tests — JWT-claims population, regression guard against initialState defaults, and non-overwrite of onRefresh-set values — along with the supporting vitest/jsdom/testing-library dev dependencies.

Confidence Score: 4/5

The fix is correct and well-scoped; the main watch-out is a broad catch block that silently absorbs any error from getAccessToken(), not just unauthenticated sessions.

The core logic change is sound — spreading ...prev instead of ...initialState and sourcing organizationId from JWT claims eliminates the overwrite. The broad catch {} is the only material concern: a network error or unexpected runtime exception during getAccessToken() would leave the app silently in a not logged in state with no diagnostic path.

The try/catch block in src/provider.tsx (lines 100-122) deserves a second look to ensure only LoginRequiredError is swallowed.

Important Files Changed

Filename Overview
src/provider.tsx Core fix: reads JWT claims in the .then() callback via getClaims() so organizationId and other auth metadata survive React state batching; introduces a broad catch block that silently handles all getAccessToken() failures
src/tests/provider.test.tsx Adds three vitest tests covering JWT-claims population and the non-overwrite regression; the third test exercises onRefresh after initialization rather than the concurrent race described in the bug, but the substantive fix is validated by test 1
vitest.config.ts Minimal vitest config wiring jsdom as the test environment; no issues
package.json Adds vitest, jsdom, @testing-library/react, and related dev dependencies; @types/react-dom@^19 conflicts with @types/react@18.3.3 (pre-existing, noted in PR description)

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Provider as AuthKitProvider
    participant SDK as authkit-js createClient
    participant Claims as getClaims()

    Browser->>Provider: mount
    Provider->>Provider: setState(initialState)
    Provider->>SDK: "createClient(clientId, { onRefresh: handleRefresh })"

    Note over SDK,Provider: onRefresh may fire during token refresh (before .then() resolves)
    SDK-->>Provider: handleRefresh(response)
    Provider->>Claims: getClaims(response.accessToken)
    Claims-->>Provider: role, permissions, featureFlags
    Provider->>Provider: "setState({...prev, user, organizationId, role, ...})"

    SDK-->>Provider: Promise resolves with client
    Provider->>SDK: client.getAccessToken()
    alt authenticated session
        SDK-->>Provider: accessToken
        Provider->>Claims: getClaims(accessToken)
        Claims-->>Provider: org_id, role, permissions, featureFlags
        Provider->>Provider: "setState({...prev, isLoading:false, organizationId, role, ...})"
    else unauthenticated (LoginRequiredError)
        SDK-->>Provider: throws LoginRequiredError
        Provider->>Provider: "setState({...prev, isLoading:false, user:null})"
    end
Loading

Reviews (1): Last reviewed commit: "fix(provider): read organizationId from ..." | Re-trigger Greptile

Comment thread src/provider.tsx
Comment on lines +119 to +122
} catch {
// No active session – just mark loading as done
setState((prev) => ({ ...prev, isLoading: false, user }));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The catch block captures every exception thrown by await client.getAccessToken(), not only LoginRequiredError. A real error — network failure, unexpected runtime exception, token-decode crash — would be silently absorbed and the app would appear unauthenticated with no way to distinguish it from a normal "not logged in" state. Narrowing the guard makes the intent explicit and lets genuine errors surface.

Suggested change
} catch {
// No active session – just mark loading as done
setState((prev) => ({ ...prev, isLoading: false, user }));
}
} catch (err) {
if (!(err instanceof LoginRequiredError)) {
throw err;
}
// No active session – just mark loading as done
setState((prev) => ({ ...prev, isLoading: false, user }));
}

Comment thread src/provider.tsx
Comment on lines +100 to +108
try {
const accessToken = await client.getAccessToken();
const {
org_id: organizationId = null,
role = null,
roles = null,
permissions = [],
feature_flags: featureFlags = [],
} = getClaims(accessToken);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 JWT claims read without iss/aud validation

getClaims(accessToken) base64-decodes the token payload to extract auth metadata but does not verify the signature or validate the iss and aud claims. A tampered or mis-issued token would be accepted and its claims written into React state. The same pattern already exists in handleRefresh; both call sites should either rely on the library performing those checks internally, or explicitly validate before trusting the claims.

Rule Used: JWTs should always be validated before use and the... (source)

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

useAuth() returns organizationId: null after onRefresh callback receives valid organizationId

1 participant