diff --git a/CHANGELOG.md b/CHANGELOG.md index 1111357..ccbf293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2d30fde..76a49c2 100644 --- a/README.md +++ b/README.md @@ -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(); + const hasAdvancedAnalytics = featureFlags?.includes('advanced-analytics'); + + return hasAdvancedAnalytics ? : ; +} +``` + +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 diff --git a/package-lock.json b/package-lock.json index 7366365..7a1a531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.11.0", "license": "MIT", "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", @@ -21,7 +21,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", diff --git a/package.json b/package.json index b9c4154..caf928f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/feature-flags.spec.ts b/src/feature-flags.spec.ts new file mode 100644 index 0000000..887abbf --- /dev/null +++ b/src/feature-flags.spec.ts @@ -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 }); + }); +}); diff --git a/src/feature-flags.ts b/src/feature-flags.ts new file mode 100644 index 0000000..935c93d --- /dev/null +++ b/src/feature-flags.ts @@ -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), +); diff --git a/src/index.ts b/src/index.ts index 2a086c7..4688010 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -10,6 +11,7 @@ export { configure, withAuth, getConfig, + getFeatureFlagsRuntimeClient, getSignInUrl, getSignUpUrl, getWorkOS, diff --git a/src/interfaces.ts b/src/interfaces.ts index 0393298..58a7d6a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -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 = ReturnType>; @@ -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; @@ -152,6 +165,8 @@ export type State = v.InferOutput; export type AuthKitLoaderOptions = { ensureSignedIn?: boolean; debug?: boolean; + featureFlags?: AuthKitFeatureFlagsOptions; + onFeatureFlagsError?: (options: FeatureFlagsErrorOptions) => void | Promise; onSessionRefreshError?: (options: RefreshErrorOptions) => void | Response | Promise; onSessionRefreshSuccess?: (options: RefreshSuccessOptions) => void | Promise; } & ( diff --git a/src/session.spec.ts b/src/session.spec.ts index d406fd8..48f7781 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -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 { @@ -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', () => ({ @@ -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', diff --git a/src/session.ts b/src/session.ts index b88e9f3..d90f181 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,9 +2,11 @@ import { data, redirect, type LoaderFunctionArgs, type SessionData } from 'react import { getAuthorizationUrl } from './get-authorization-url.js'; import type { AccessToken, + AuthKitFeatureFlagsOptions, AuthKitLoaderOptions, AuthorizedData, DataWithResponseInit, + FeatureFlagsErrorOptions, Session, UnauthorizedData, UnwrapData, @@ -17,7 +19,7 @@ import { getConfig } from './config.js'; import { getPKCECleanupCookieStrings } from './pkce.js'; import { configureSessionStorage, getSessionStorage } from './sessionStorage.js'; import { isDataWithResponseInit, isJsonResponse, isRedirect, isResponse } from './utils.js'; -import type { AuthenticationResponse } from '@workos-inc/node'; +import type { AuthenticationResponse, EvaluationContext } from '@workos-inc/node'; // must be a type since this is a subtype of response // interfaces must conform to the types they extend @@ -341,8 +343,10 @@ export async function authkitLoader( debug = false, onSessionRefreshSuccess, onSessionRefreshError, + onFeatureFlagsError, storage, cookie, + featureFlags: featureFlagsOptions, } = typeof loaderOrOptions === 'object' ? loaderOrOptions : options; const cookieName = cookie?.name ?? getConfig('cookieName'); @@ -395,7 +399,7 @@ export async function authkitLoader( roles = null, permissions = [], entitlements = [], - featureFlags = [], + featureFlags: tokenFeatureFlags = [], } = getClaimsFromAccessToken(session.accessToken); const { impersonator = null } = session; @@ -410,6 +414,17 @@ export async function authkitLoader( }); } + const featureFlags = await getFeatureFlags({ + options: featureFlagsOptions, + tokenFeatureFlags, + request, + user: session.user, + userId: session.user?.id, + organizationId, + debug, + onFeatureFlagsError, + }); + const auth: AuthorizedData = { user: session.user, sessionId, @@ -461,6 +476,75 @@ export async function authkitLoader( } } +async function getFeatureFlags({ + options, + tokenFeatureFlags, + request, + user, + userId, + organizationId, + debug, + onFeatureFlagsError, +}: { + options?: AuthKitFeatureFlagsOptions; + tokenFeatureFlags: string[]; + request: Request; + user: FeatureFlagsErrorOptions['user']; + userId?: string; + organizationId: string | null; + debug: boolean; + onFeatureFlagsError?: (options: FeatureFlagsErrorOptions) => void | Promise; +}) { + if (!options) { + return tokenFeatureFlags; + } + + try { + if (options.waitUntilReady) { + await options.runtimeClient.waitUntilReady(options.waitUntilReady === true ? undefined : options.waitUntilReady); + } + + const context: EvaluationContext = {}; + if (userId) { + context.userId = userId; + } + if (organizationId) { + context.organizationId = organizationId; + } + + return Object.entries(options.runtimeClient.getAllFlags(context)) + .filter(([, enabled]) => enabled) + .map(([flag]) => flag); + } catch (error) { + if (onFeatureFlagsError) { + try { + await onFeatureFlagsError({ + error, + request, + user, + organizationId, + tokenFeatureFlags, + }); + } catch (callbackError) { + // istanbul ignore next + if (debug) { + console.warn('[AuthKit] Feature flags error callback failed.', callbackError); + } + } + } + + // istanbul ignore next + if (debug) { + console.warn( + '[AuthKit] Failed to evaluate feature flags with the WorkOS runtime client. Falling back to access token feature flags.', + error, + ); + } + + return tokenFeatureFlags; + } +} + async function handleAuthLoader( loader: AuthLoader | AuthorizedAuthLoader | undefined, args: LoaderFunctionArgs, diff --git a/src/utils.spec.ts b/src/utils.spec.ts index fc45eb8..96b491e 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -14,6 +14,16 @@ describe('utils', () => { expect(lazyFn()).toBe('test'); expect(fn).toHaveBeenCalledTimes(1); }); + + it('should pass arguments only to the first call', () => { + const fn = jest.fn((value: string) => value.toUpperCase()); + const lazyFn = lazy(fn); + + expect(lazyFn('first')).toBe('FIRST'); + expect(lazyFn('second')).toBe('FIRST'); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('first'); + }); }); describe('isRedirect', () => { diff --git a/src/utils.ts b/src/utils.ts index 29b742d..55c5f4f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,12 +7,12 @@ import { DataWithResponseInit } from './interfaces.js'; * @param fn - The function to be called once. * @returns A function that can only be called once. */ -export function lazy(fn: () => T): () => T { +export function lazy(fn: (...args: Args) => T): (...args: Args) => T { let called = false; let result: T; - return () => { + return (...args: Args) => { if (!called) { - result = fn(); + result = fn(...args); called = true; } return result;