Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ used to signal breaking changes.

## [Unreleased]

### Added

- `getFeatureFlagsRuntimeClient` returns a shared WorkOS Feature Flags runtime
client for server-side flag evaluation.
- `authkitLoader` can now populate `auth.featureFlags` from a Feature Flags
runtime client via the `featureFlags.runtimeClient` option.
- `authkitLoader` now supports `onFeatureFlagsError` for reporting runtime
feature-flag evaluation failures before falling back to JWT claims.

### Changed

- Minimum `@workos-inc/node` is now `^8.13.0` for the Feature Flags runtime
client APIs.

## [0.11.0] - 2026-04-27

### Added
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,67 @@ export function App() {
}
```

### Evaluate feature flags

By default, `authkitLoader` reads `featureFlags` from the `feature_flags`
claim in the WorkOS access token. This is convenient for small flag sets, but
changes are reflected only after the user's access token refreshes.

Use the Feature Flags runtime client when you need server-side flag evaluation
that stays in sync independently of the user's session. The runtime client keeps
flag configuration in memory and syncs changes in the background, so create one
shared instance per server process rather than one client per request.

```tsx
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
import { authkitLoader, getFeatureFlagsRuntimeClient } from '@workos-inc/authkit-react-router';

const featureFlags = getFeatureFlagsRuntimeClient();

export const loader = (args: LoaderFunctionArgs) =>
authkitLoader(args, {
featureFlags: {
runtimeClient: featureFlags,
waitUntilReady: { timeoutMs: 5000 },
},
onFeatureFlagsError: ({ error }) => {
console.error('Feature flags runtime client failed:', error);
},
});

export function Dashboard() {
const { featureFlags } = useLoaderData<typeof loader>();
const hasAdvancedAnalytics = featureFlags?.includes('advanced-analytics');

return hasAdvancedAnalytics ? <AdvancedAnalytics /> : <BasicAnalytics />;
}
```

After opting in, downstream route code can continue reading
`auth.featureFlags` as before, but the values normally come from the runtime
client instead of the JWT. The JWT claim is used only as a fallback if runtime
evaluation fails.

#### Source of `auth.featureFlags`

`authkitLoader` preserves the existing token-based behavior unless you opt in to
the runtime client:

- Without `featureFlags.runtimeClient`, `auth.featureFlags` is read from the
access token's `feature_flags` claim.
- With `featureFlags.runtimeClient`, `auth.featureFlags` is evaluated by the
runtime client using the signed-in user's `userId` and current
`organizationId`.
- If runtime evaluation fails, `authkitLoader` falls back to the access token's
`feature_flags` claim so authentication can continue. Use
`onFeatureFlagsError` to report this fallback to your monitoring system. When
`debug: true` is enabled, this fallback also emits a warning.

The `getFeatureFlagsRuntimeClient` helper returns the same runtime client for
every call in the current server process. Options passed to
`getFeatureFlagsRuntimeClient(options)` are only used when the client is created
for the first time.

### Sign-in and sign-up routes

`getSignInUrl` and `getSignUpUrl` return a `{ url, headers }` pair. The
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"format": "prettier --write \"{src,__tests__}/**/*.{js,ts,tsx}\""
},
"dependencies": {
"@workos-inc/node": "^8.9.0",
"@workos-inc/node": "^8.13.0",
"iron-session": "^8.0.1",
"jose": "^5.2.3",
"tslib": "^2.8.1",
Expand All @@ -44,7 +44,7 @@
"@types/jest": "^29.5.14",
"@types/node": "^24.10.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@workos-inc/node": "^8.9.0",
"@workos-inc/node": "^8.13.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-require-extensions": "^0.1.3",
Expand Down
29 changes: 29 additions & 0 deletions src/feature-flags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { FeatureFlagsRuntimeClient } from '@workos-inc/node';
import { getWorkOS } from './workos.js';

describe('feature flags', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.resetModules();
});

it('memoizes the feature flags runtime client', async () => {
const runtimeClient = {
close: jest.fn(),
getAllFlags: jest.fn(),
getFlag: jest.fn(),
getStats: jest.fn(),
isEnabled: jest.fn(),
waitUntilReady: jest.fn(),
} as unknown as FeatureFlagsRuntimeClient;
const createRuntimeClient = jest
.spyOn(getWorkOS().featureFlags, 'createRuntimeClient')
.mockReturnValue(runtimeClient);
const { getFeatureFlagsRuntimeClient } = await import('./feature-flags.js');

expect(getFeatureFlagsRuntimeClient({ pollingIntervalMs: 5000 })).toBe(runtimeClient);
expect(getFeatureFlagsRuntimeClient({ pollingIntervalMs: 30000 })).toBe(runtimeClient);
expect(createRuntimeClient).toHaveBeenCalledTimes(1);
expect(createRuntimeClient).toHaveBeenCalledWith({ pollingIntervalMs: 5000 });
});
});
14 changes: 14 additions & 0 deletions src/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { FeatureFlagsRuntimeClient, RuntimeClientOptions } from '@workos-inc/node';
import { lazy } from './utils.js';
import { getWorkOS } from './workos.js';

/**
* Returns a shared WorkOS Feature Flags runtime client.
*
* The runtime client keeps feature flag state in sync in the background, so it
* should be created once per server process instead of once per request.
* Options are only used when the client is created for the first time.
*/
export const getFeatureFlagsRuntimeClient = lazy(
(options?: RuntimeClientOptions): FeatureFlagsRuntimeClient => getWorkOS().featureFlags.createRuntimeClient(options),
);
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization, withAuth } from './auth.js';
import { authLoader } from './authkit-callback-route.js';
import { configure, getConfig } from './config.js';
import { getFeatureFlagsRuntimeClient } from './feature-flags.js';
import { authkitLoader, refreshSession, saveSession } from './session.js';
import { getWorkOS } from './workos.js';

Expand All @@ -10,6 +11,7 @@ export {
configure,
withAuth,
getConfig,
getFeatureFlagsRuntimeClient,
getSignInUrl,
getSignUpUrl,
getWorkOS,
Expand Down
17 changes: 16 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SessionStorage, SessionIdStorageStrategy, data, SessionData } from 'react-router';
import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node';
import type { AuthenticationResponse, FeatureFlagsRuntimeClient, OauthTokens, User } from '@workos-inc/node';
import * as v from 'valibot';

export type DataWithResponseInit<T> = ReturnType<typeof data<T>>;
Expand Down Expand Up @@ -44,6 +44,19 @@ export interface RefreshSuccessOptions {
organizationId: string | null;
}

export interface FeatureFlagsErrorOptions {
error: unknown;
request: Request;
user: User;
organizationId: string | null;
tokenFeatureFlags: string[];
}

export interface AuthKitFeatureFlagsOptions {
runtimeClient: FeatureFlagsRuntimeClient;
waitUntilReady?: boolean | { timeoutMs?: number };
}

export interface Impersonator {
email: string;
reason: string | null;
Expand Down Expand Up @@ -152,6 +165,8 @@ export type State = v.InferOutput<typeof StateSchema>;
export type AuthKitLoaderOptions = {
ensureSignedIn?: boolean;
debug?: boolean;
featureFlags?: AuthKitFeatureFlagsOptions;
onFeatureFlagsError?: (options: FeatureFlagsErrorOptions) => void | Promise<void>;
onSessionRefreshError?: (options: RefreshErrorOptions) => void | Response | Promise<void | Response>;
onSessionRefreshSuccess?: (options: RefreshSuccessOptions) => void | Promise<void>;
} & (
Expand Down
129 changes: 128 additions & 1 deletion src/session.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LoaderFunctionArgs, Session as ReactRouterSession, redirect } from 'react-router';
import { AuthenticationResponse, type User } from '@workos-inc/node';
import { AuthenticationResponse, type FeatureFlagsRuntimeClient, type User } from '@workos-inc/node';
import * as ironSession from 'iron-session';
import * as jose from 'jose';
import {
Expand Down Expand Up @@ -30,6 +30,9 @@ const fakeWorkosInstance = {
getJwksUrl: jest.fn((clientId: string) => `https://auth.workos.com/oauth/jwks/${clientId}`),
authenticateWithRefreshToken: jest.fn(),
},
featureFlags: {
createRuntimeClient: jest.fn(),
},
};

jest.mock('./workos.js', () => ({
Expand Down Expand Up @@ -482,6 +485,130 @@ describe('session', () => {
});
});

it('should populate featureFlags from the runtime client when configured', async () => {
const runtimeClient = {
waitUntilReady: jest.fn().mockResolvedValue(undefined),
getAllFlags: jest.fn().mockReturnValue({
'runtime-flag': true,
'disabled-runtime-flag': false,
}),
} as unknown as FeatureFlagsRuntimeClient;

const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), {
featureFlags: {
runtimeClient,
waitUntilReady: { timeoutMs: 100 },
},
});

expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith({ timeoutMs: 100 });
expect(runtimeClient.getAllFlags).toHaveBeenCalledWith({
userId: mockSessionData.user.id,
organizationId: 'org-123',
});
expect(data).toEqual(
expect.objectContaining({
featureFlags: ['runtime-flag'],
}),
);
});

it('should call onFeatureFlagsError and fall back when waitUntilReady fails', async () => {
const error = new Error('runtime not ready');
const onFeatureFlagsError = jest.fn();
const request = createMockRequest();
const runtimeClient = {
waitUntilReady: jest.fn().mockRejectedValue(error),
getAllFlags: jest.fn(),
} as unknown as FeatureFlagsRuntimeClient;

const { data } = await authkitLoader(createLoaderArgs(request), {
onFeatureFlagsError,
featureFlags: {
runtimeClient,
waitUntilReady: true,
},
});

expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith(undefined);
expect(runtimeClient.getAllFlags).not.toHaveBeenCalled();
expect(data).toEqual(
expect.objectContaining({
featureFlags: ['flag-1', 'flag-2'],
}),
);
expect(onFeatureFlagsError).toHaveBeenCalledWith({
error,
request,
user: mockSessionData.user,
organizationId: 'org-123',
tokenFeatureFlags: ['flag-1', 'flag-2'],
});
});

it('should call onFeatureFlagsError and fall back when getAllFlags fails', async () => {
const error = new Error('runtime client closed');
const onFeatureFlagsError = jest.fn();
const request = createMockRequest();
const runtimeClient = {
waitUntilReady: jest.fn().mockResolvedValue(undefined),
getAllFlags: jest.fn().mockImplementation(() => {
throw error;
}),
} as unknown as FeatureFlagsRuntimeClient;

const { data } = await authkitLoader(createLoaderArgs(request), {
onFeatureFlagsError,
featureFlags: {
runtimeClient,
waitUntilReady: true,
},
});

expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith(undefined);
expect(runtimeClient.getAllFlags).toHaveBeenCalledWith({
userId: mockSessionData.user.id,
organizationId: 'org-123',
});
expect(data).toEqual(
expect.objectContaining({
featureFlags: ['flag-1', 'flag-2'],
}),
);
expect(onFeatureFlagsError).toHaveBeenCalledWith({
error,
request,
user: mockSessionData.user,
organizationId: 'org-123',
tokenFeatureFlags: ['flag-1', 'flag-2'],
});
});

it('should log runtime evaluation failures when debug is enabled', async () => {
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const runtimeClient = {
waitUntilReady: jest.fn().mockRejectedValue(new Error('runtime not ready')),
getAllFlags: jest.fn(),
} as unknown as FeatureFlagsRuntimeClient;

await authkitLoader(createLoaderArgs(createMockRequest()), {
debug: true,
featureFlags: {
runtimeClient,
waitUntilReady: true,
},
});

expect(warnSpy).toHaveBeenCalledWith(
'[AuthKit] Failed to evaluate feature flags with the WorkOS runtime client. Falling back to access token feature flags.',
expect.any(Error),
);

logSpy.mockRestore();
warnSpy.mockRestore();
});

it('should handle custom loader data', async () => {
const customLoader = jest.fn().mockReturnValue({
customData: 'test-value',
Expand Down
Loading