From 9d4f3d74502aadf7e9b8a535d7bc7e1697f662c2 Mon Sep 17 00:00:00 2001 From: Benjamin Truong Date: Tue, 11 Aug 2026 14:38:34 -0700 Subject: [PATCH 1/4] feat[client-js]: Adds opt-in React Router loaders for @okta/okta-client-javascript Adds a `@okta/okta-react/client-js` subpath exporting createFetchLoader, createTokenLoader, and createLoginCallbackLoader - React Router v6.4+ data-router loader factories that wrap @okta/auth-foundation, @okta/oauth2-flows, and @okta/spa-platform (0.6.0 beta) as an alternative to the okta-auth-js-based API. createLoginCallbackLoader resumes the auth code flow via AuthorizationCodeFlowOrchestrator.resumeFlow(), which exchanges the code and stores the resulting credential itself, then redirects to the original URI. This replaces the component for apps using the new SDK's data router. The new peer SDKs are declared as optional peerDependencies so the default @okta/okta-react bundle has no dependency on them; a dedicated Rollup target and ESLint import-boundary rule keep the two bundles isolated from each other. Jest's jsdom environment doesn't implement a spec-compliant Fetch API, which the data router's loader/redirect handling depends on - added undici (plus its Node-native web API polyfills) via a new setupFiles entry so the loader tests exercise real Request/Response objects. --- README.md | 93 +++++++++++++++++++ build.js | 18 +++- jest.config.js | 4 + package.json | 34 ++++++- rollup.config.js | 32 +++++++ src/.eslintrc.js | 26 ++++++ src/client-js/createFetchLoader.ts | 40 ++++++++ src/client-js/createLoginCallbackLoader.ts | 39 ++++++++ src/client-js/createTokenLoader.ts | 37 ++++++++ src/client-js/index.ts | 16 ++++ test/jest/clientJs/createFetchLoader.test.tsx | 78 ++++++++++++++++ .../createLoginCallbackLoader.test.tsx | 57 ++++++++++++ test/jest/clientJs/createTokenLoader.test.tsx | 83 +++++++++++++++++ test/jest/polyfills.ts | 31 +++++++ test/jest/setup.ts | 5 + yarn.lock | 44 ++++++++- 16 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 src/client-js/createFetchLoader.ts create mode 100644 src/client-js/createLoginCallbackLoader.ts create mode 100644 src/client-js/createTokenLoader.ts create mode 100644 src/client-js/index.ts create mode 100644 test/jest/clientJs/createFetchLoader.test.tsx create mode 100644 test/jest/clientJs/createLoginCallbackLoader.test.tsx create mode 100644 test/jest/clientJs/createTokenLoader.test.tsx create mode 100644 test/jest/polyfills.ts diff --git a/README.md b/README.md index 83cf3ea4..2acdc970 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ * [Getting started](#getting-started) * [Installation](#installation) * [Usage](#usage) +* [Using `@okta/okta-react/client-js` (opt-in, beta)](#using-oktaokta-reactclient-js-opt-in-beta) * [Reference](#reference) * [Migrating between versions](#migrating-between-versions) * [Contributing](#contributing) @@ -383,6 +384,98 @@ export default MessageList = () => { }; ``` +## Using `@okta/okta-react/client-js` (opt-in, beta) + +> :warning: **Beta** :warning:
This subpath and the `@okta/okta-client-javascript` packages it wraps +(`@okta/auth-foundation`, `@okta/oauth2-flows`, `@okta/spa-platform`) are in beta. APIs may change in a +minor release. This is entirely opt-in — it adds no imports, code, or dependencies to the default +`@okta/okta-react` bundle. + +`@okta/okta-react/client-js` provides [React Router](#) v6.4+ [loader](https://reactrouter.com/en/main/route/loader) +factories for apps using [`@okta/okta-client-javascript`](https://github.com/okta/okta-client-javascript) instead +of `@okta/okta-auth-js`. Unlike the rest of this SDK, this subpath does not provide a React Context, hooks, or +components — `@okta/okta-client-javascript` has no persistent global auth state to provide; authentication is +checked and refreshed per-request. Instead, you construct the SDK's client instances yourself (typically as +module-level singletons) and pass them into the loader factories below, wiring the returned loaders into your +own route definitions. + +> :warning: **Requires React Router v6.4+** :warning:
These loaders only work with a [data router](https://reactrouter.com/en/main/routers/picking-a-router) +(`createBrowserRouter`, `createMemoryRouter`, etc.) and its `RouterProvider`. They are not compatible with +`react-router-dom` v5 or the non-data APIs of v6 (`` + ``). + +### Installation + +```bash +npm install --save @okta/okta-react +npm install --save @okta/auth-foundation @okta/oauth2-flows @okta/spa-platform +``` + +### Constructing the SDK singletons + +```javascript +// src/auth.js +import { FetchClient } from '@okta/spa-platform/fetch'; +import { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform/orchestrator'; +import { AuthorizationCodeFlow } from '@okta/spa-platform/flows'; + +const config = { + issuer: 'https://{yourOktaDomain}/oauth2/default', + clientId: '{clientId}', + redirectUri: window.location.origin + '/login/callback', +}; + +export const fetchClient = new FetchClient(config); +const signInFlow = new AuthorizationCodeFlow(config); +export const tokenOrchestrator = new AuthorizationCodeFlowOrchestrator(signInFlow); +``` + +### Wiring loaders into your router + +```jsx +// src/router.js +import { createBrowserRouter } from 'react-router-dom'; +import { createFetchLoader, createTokenLoader, createLoginCallbackLoader } from '@okta/okta-react/client-js'; +import { fetchClient, tokenOrchestrator } from './auth'; +import Home from './Home'; +import Protected from './Protected'; +import Messages from './Messages'; + +export const router = createBrowserRouter([ + { path: '/', element: }, + { + path: '/protected', + element: , + loader: createTokenLoader(tokenOrchestrator), + }, + { + path: '/messages', + element: , + loader: createFetchLoader(fetchClient, () => '/api/messages'), + }, + { + path: '/login/callback', + loader: createLoginCallbackLoader(tokenOrchestrator), + }, +]); +``` + +```jsx +// src/App.js +import { RouterProvider } from 'react-router-dom'; +import { router } from './router'; + +export default function App() { + return ; +} +``` + +`createTokenLoader` throws a `401` `Response` if a valid token can't be obtained, which React Router will +surface to the nearest [`errorElement`](https://reactrouter.com/en/main/route/error-element). `createFetchLoader` +returns the `fetchClient.fetch()` response directly so it can be consumed with `useLoaderData()`. +`createLoginCallbackLoader` resumes the sign-in flow via `orchestrator.resumeFlow()` (which stores the resulting +credential itself) and redirects back to the original URI — it replaces the `` component used with +`@okta/okta-auth-js`. + ## Reference ### `Security` diff --git a/build.js b/build.js index 5a19633e..c5d20863 100644 --- a/build.js +++ b/build.js @@ -38,11 +38,27 @@ delete packageJSON.workspaces; // remove yarn workspace section // Remove "build/" from the entrypoint paths. ['main', 'module', 'types'].forEach(function(key) { - if (packageJSON[key]) { + if (packageJSON[key]) { packageJSON[key] = packageJSON[key].replace(`${NPM_DIR}/`, ''); } }); +// Remove "build/" from the `exports` map paths (leaves the leading "./" intact). +function stripDistPrefix(value) { + if (typeof value === 'string') { + return value.replace(`${NPM_DIR}/`, ''); + } + if (value && typeof value === 'object') { + Object.keys(value).forEach(function(key) { + value[key] = stripDistPrefix(value[key]); + }); + } + return value; +} +if (packageJSON.exports) { + stripDistPrefix(packageJSON.exports); +} + fs.writeFileSync(`./${NPM_DIR}/package.json`, JSON.stringify(packageJSON, null, 4)); shell.echo(chalk.green(`End building`)); diff --git a/jest.config.js b/jest.config.js index 071bd962..e083ddbd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,8 +26,12 @@ module.exports = { './test/jest' ], setupFiles: [ + './test/jest/polyfills.ts', './test/jest/setup.ts' ], + setupFilesAfterEnv: [ + '@testing-library/jest-dom' + ], testEnvironment: 'jsdom', transform: { '^.+\\.tsx?$': ['ts-jest', { diff --git a/package.json b/package.json index 7410a636..36ac9b15 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,19 @@ "main": "dist/bundles/okta-react.cjs.js", "module": "dist/bundles/okta-react.esm.js", "types": "dist/bundles/types", + "exports": { + ".": { + "types": "./dist/bundles/types/index.d.ts", + "import": "./dist/bundles/okta-react.esm.js", + "require": "./dist/bundles/okta-react.cjs.js" + }, + "./client-js": { + "types": "./dist/bundles/types/client-js/index.d.ts", + "import": "./dist/bundles/client-js.esm.js", + "require": "./dist/bundles/client-js.cjs.js" + }, + "./package.json": "./package.json" + }, "author": "", "license": "Apache-2.0", "bugs": { @@ -57,17 +70,34 @@ }, "peerDependencies": { "@okta/okta-auth-js": "^5.3.1 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@okta/auth-foundation": "^0.6.0", + "@okta/oauth2-flows": "^0.6.0", + "@okta/spa-platform": "^0.6.0", "react": ">=16.8.0", "react-dom": ">=16.8.0", "react-router-dom": ">=5.1.0" }, + "peerDependenciesMeta": { + "@okta/auth-foundation": { + "optional": true + }, + "@okta/oauth2-flows": { + "optional": true + }, + "@okta/spa-platform": { + "optional": true + } + }, "devDependencies": { "@babel/cli": "^7.19.3", "@babel/core": "^7.19.3", "@babel/plugin-transform-runtime": "^7.19.1", "@babel/preset-env": "^7.19.3", "@babel/preset-react": "^7.18.6", + "@okta/auth-foundation": "^0.6.0", + "@okta/oauth2-flows": "^0.6.0", "@okta/okta-auth-js": "^7.14.5", + "@okta/spa-platform": "^0.6.0", "@rollup/plugin-babel": "^7.1.0", "@rollup/plugin-replace": "^6.0.3", "@testing-library/jest-dom": "^5.16.2", @@ -106,13 +136,15 @@ "react": "^16.9.0", "react-dom": "^16.9.0", "react-router-dom": "5.2.0", + "react-router-dom-v6": "npm:react-router-dom@^6.4.0", "rollup": "^4.62.3", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.37.0", "shelljs": "^0.10.0", "ts-jest": "^29.1.1", - "typescript": "^4.0.5" + "typescript": "^5.6.3", + "undici": "^7.29.0" }, "jest-junit": { "outputDirectory": "./test-reports/unit/", diff --git a/rollup.config.js b/rollup.config.js index e8f91f09..5362d352 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -112,5 +112,37 @@ export default [ sourcemap: true } ] + }, + { + input: 'src/client-js/index.ts', + external, + plugins: [ + ...commonPlugins, + babel({ + babelHelpers: 'runtime', + presets: [ + '@babel/preset-env', + '@babel/preset-react' + ], + plugins: [ + '@babel/plugin-transform-runtime' + ], + extensions + }), + ], + output: [ + { + format: 'cjs', + file: 'dist/bundles/client-js.cjs.js', + exports: 'named', + sourcemap: true + }, + { + format: 'esm', + file: 'dist/bundles/client-js.esm.js', + exports: 'named', + sourcemap: true + } + ] } ]; diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 5ea9989f..fd9c5f07 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -16,6 +16,23 @@ module.exports = { // https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-extraneous-dependencies.md 'import/no-extraneous-dependencies': ['error', { 'devDependencies': false + }], + // `src/client-js/**` is the optional, independently-bundled `@okta/okta-react/client-js` entry point. + // The default `okta-react` bundle must have zero import edges into it. + // https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-restricted-paths.md + 'import/no-restricted-paths': ['error', { + zones: [{ + target: './src', + from: './src/client-js', + message: 'src/client-js/** is a separate, optional bundle - do not import it from the default okta-react entry point.' + }] + }], + // ...nor may the default bundle depend on the client-js peer SDKs directly. + 'no-restricted-imports': ['error', { + paths: ['@okta/auth-foundation', '@okta/oauth2-flows', '@okta/spa-platform'].map(name => ({ + name, + message: `${name} may only be imported from src/client-js/** - the default okta-react bundle must not depend on it.` + })) }] }, settings: { @@ -24,4 +41,13 @@ module.exports = { '@typescript-eslint/parser': ['.ts', '.tsx'] } }, + overrides: [ + { + files: ['client-js/**/*'], + rules: { + 'import/no-restricted-paths': 'off', + 'no-restricted-imports': 'off' + } + } + ] } diff --git a/src/client-js/createFetchLoader.ts b/src/client-js/createFetchLoader.ts new file mode 100644 index 00000000..b9f0a70d --- /dev/null +++ b/src/client-js/createFetchLoader.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +// Imported from @okta/auth-foundation (not @okta/spa-platform, whose FetchClient re-export doesn't +// surface the inherited `fetch` method to the type checker) - spa-platform's concrete FetchClient +// instances are structurally assignable here since they extend this base class. +import type { FetchClient } from '@okta/auth-foundation'; + +export interface LoaderArgs { + request: Request; + params: Record; +} + +export type GetResource = (args: LoaderArgs) => string | URL | Request; + +/** + * Wraps a {@link FetchClient} in a React Router (v6.4+) loader-compatible function. + * + * `fetchClient.fetch()` already resolves a matching credential, refreshes it if needed, or performs a full + * re-authentication redirect if not - this loader does no auth logic of its own, it just fetches and returns + * the raw `Response`, which React Router auto-parses when read via `useLoaderData()`. + */ +export function createFetchLoader( + fetchClient: FetchClient, + getResource: GetResource, + init?: RequestInit, +) { + return async (args: LoaderArgs): Promise => { + return fetchClient.fetch(getResource(args), init); + }; +} diff --git a/src/client-js/createLoginCallbackLoader.ts b/src/client-js/createLoginCallbackLoader.ts new file mode 100644 index 00000000..8b4e5cbc --- /dev/null +++ b/src/client-js/createLoginCallbackLoader.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import type { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; + +/** + * Wraps an {@link AuthorizationCodeFlowOrchestrator} in a React Router (v6.4+) loader-compatible function - the + * loader-based replacement for the `` component. Runs on the OAuth redirect-callback route; + * no rendered component is needed. + * + * `orchestrator.resumeFlow()` completes the authorization code exchange and stores the resulting credential + * itself - this loader does no storage of its own, it just redirects once that's done. + * + * The `context` returned by `orchestrator.resumeFlow()` is whatever `meta` object was passed to `flow.start()` + * (or the orchestrator's `login_prompt_required` listener) when the flow began - `originalUri` is a convention, + * not a guarantee, so consumers who pass their own `meta` shape should read `context` themselves instead of + * using this loader. + * + * Constructing the redirect `Response` directly (rather than importing `redirect()` from `react-router-dom`) + * keeps this subpath's runtime code framework-agnostic - it needs no `react-router-dom` import at all, only + * the ambient `Request`/`Response` globals. + */ +export function createLoginCallbackLoader(orchestrator: AuthorizationCodeFlowOrchestrator) { + return async ({ request }: { request: Request }): Promise => { + const context = await orchestrator.resumeFlow(request.url); + const { originalUri } = context; + + return new Response(null, { status: 302, headers: { Location: originalUri ?? '/' } }); + }; +} diff --git a/src/client-js/createTokenLoader.ts b/src/client-js/createTokenLoader.ts new file mode 100644 index 00000000..2be6c5cc --- /dev/null +++ b/src/client-js/createTokenLoader.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import type { Token, TokenOrchestrator } from '@okta/auth-foundation'; +import type { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; + +/** + * Wraps an {@link AuthorizationCodeFlowOrchestrator} in a React Router (v6.4+) loader-compatible function, + * for consumers who want a raw {@link Token} (e.g. to attach an `Authorization` header themselves) rather + * than a `fetchClient`-mediated `Response`. + * + * `orchestrator.getToken()` already resolves a matching credential, refreshes it if needed, or performs a + * full re-authentication redirect if not - if a redirect occurs, the returned promise never resolves because + * the page navigates away, same as {@link createFetchLoader}. The `401` throw below only covers the + * (default-off, `avoidPrompting: true`) case where the orchestrator declines to redirect and returns `null`. + */ +export function createTokenLoader( + orchestrator: AuthorizationCodeFlowOrchestrator, + params?: TokenOrchestrator.AuthorizeParams, +) { + return async (): Promise => { + const token = await orchestrator.getToken(params); + if (!token) { + throw new Response('Unauthorized', { status: 401 }); + } + return token; + }; +} diff --git a/src/client-js/index.ts b/src/client-js/index.ts new file mode 100644 index 00000000..784d2ae8 --- /dev/null +++ b/src/client-js/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +export { createFetchLoader } from './createFetchLoader'; +export type { GetResource, LoaderArgs } from './createFetchLoader'; +export { createTokenLoader } from './createTokenLoader'; +export { createLoginCallbackLoader } from './createLoginCallbackLoader'; diff --git a/test/jest/clientJs/createFetchLoader.test.tsx b/test/jest/clientJs/createFetchLoader.test.tsx new file mode 100644 index 00000000..99d6c844 --- /dev/null +++ b/test/jest/clientJs/createFetchLoader.test.tsx @@ -0,0 +1,78 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider, useLoaderData } from 'react-router-dom-v6'; +import { createFetchLoader } from '../../../src/client-js/createFetchLoader'; + +describe('createFetchLoader', () => { + it('fetches the resource via fetchClient.fetch() and exposes the parsed Response through useLoaderData', async () => { + const fetchClient = { + fetch: jest.fn().mockResolvedValue(new Response(JSON.stringify({ id: '123' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }; + + function Page() { + const data = useLoaderData() as { id: string }; + return

{data.id}

; + } + + const router = createMemoryRouter( + [{ + path: '/', + element: , + loader: createFetchLoader(fetchClient as any, () => '/api/resource'), + }], + { initialEntries: ['/'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('123')).toBeInTheDocument()); + expect(fetchClient.fetch).toHaveBeenCalledWith('/api/resource', undefined); + }); + + it('derives the resource from request/params and forwards init through to fetchClient.fetch()', async () => { + const fetchClient = { + fetch: jest.fn().mockResolvedValue(new Response(null, { status: 204 })), + }; + const getResource = jest.fn(({ params }: any) => `/api/users/${params.userId}`); + const init = { headers: { 'X-Test': '1' } }; + + function Page() { + useLoaderData(); + return

done

; + } + + const router = createMemoryRouter( + [{ + path: '/users/:userId', + element: , + loader: createFetchLoader(fetchClient as any, getResource, init), + }], + { initialEntries: ['/users/abc'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('done')).toBeInTheDocument()); + expect(getResource).toHaveBeenCalledWith( + expect.objectContaining({ params: expect.objectContaining({ userId: 'abc' }) }) + ); + expect(fetchClient.fetch).toHaveBeenCalledWith('/api/users/abc', init); + }); +}); diff --git a/test/jest/clientJs/createLoginCallbackLoader.test.tsx b/test/jest/clientJs/createLoginCallbackLoader.test.tsx new file mode 100644 index 00000000..6383029a --- /dev/null +++ b/test/jest/clientJs/createLoginCallbackLoader.test.tsx @@ -0,0 +1,57 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom-v6'; +import { createLoginCallbackLoader } from '../../../src/client-js/createLoginCallbackLoader'; + +describe('createLoginCallbackLoader', () => { + it('resumes the flow via orchestrator.resumeFlow() and redirects to originalUri', async () => { + const orchestrator = { + resumeFlow: jest.fn().mockResolvedValue({ tags: ['main'], originalUri: '/protected' }), + }; + + const router = createMemoryRouter( + [ + { path: '/login/callback', loader: createLoginCallbackLoader(orchestrator as any) }, + { path: '/protected', element:

protected page

}, + ], + { initialEntries: ['/login/callback?code=abc&state=xyz'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('protected page')).toBeInTheDocument()); + expect(orchestrator.resumeFlow).toHaveBeenCalledWith(expect.stringContaining('/login/callback?code=abc&state=xyz')); + }); + + it('redirects to "/" when the resumed context has no originalUri', async () => { + const orchestrator = { + resumeFlow: jest.fn().mockResolvedValue({}), + }; + + const router = createMemoryRouter( + [ + { path: '/login/callback', loader: createLoginCallbackLoader(orchestrator as any) }, + { path: '/', element:

home page

}, + ], + { initialEntries: ['/login/callback?code=abc&state=xyz'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('home page')).toBeInTheDocument()); + }); +}); diff --git a/test/jest/clientJs/createTokenLoader.test.tsx b/test/jest/clientJs/createTokenLoader.test.tsx new file mode 100644 index 00000000..6e6a0391 --- /dev/null +++ b/test/jest/clientJs/createTokenLoader.test.tsx @@ -0,0 +1,83 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider, useLoaderData, useRouteError, isRouteErrorResponse } from 'react-router-dom-v6'; +import { createTokenLoader } from '../../../src/client-js/createTokenLoader'; + +describe('createTokenLoader', () => { + it('resolves the token via orchestrator.getToken()', async () => { + const token = { accessToken: 'abc123' }; + const orchestrator = { getToken: jest.fn().mockResolvedValue(token) }; + + function Page() { + const data = useLoaderData() as typeof token; + return

{data.accessToken}

; + } + + const router = createMemoryRouter( + [{ path: '/', element: , loader: createTokenLoader(orchestrator as any) }], + { initialEntries: ['/'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('abc123')).toBeInTheDocument()); + expect(orchestrator.getToken).toHaveBeenCalledWith(undefined); + }); + + it('passes the provided params through to orchestrator.getToken()', async () => { + const orchestrator = { getToken: jest.fn().mockResolvedValue({ accessToken: 'xyz' }) }; + const params = { scopes: ['openid'] }; + + function Page() { + useLoaderData(); + return

done

; + } + + const router = createMemoryRouter( + [{ path: '/', element: , loader: createTokenLoader(orchestrator as any, params) }], + { initialEntries: ['/'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('done')).toBeInTheDocument()); + expect(orchestrator.getToken).toHaveBeenCalledWith(params); + }); + + it('throws a 401 Response when getToken() resolves null', async () => { + const orchestrator = { getToken: jest.fn().mockResolvedValue(null) }; + + function ErrorBoundary() { + const error = useRouteError(); + return

{isRouteErrorResponse(error) ? error.status : 'unknown'}

; + } + + const router = createMemoryRouter( + [{ + path: '/', + element:

never rendered

, + loader: createTokenLoader(orchestrator as any), + errorElement: , + }], + { initialEntries: ['/'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('401')).toBeInTheDocument()); + }); +}); diff --git a/test/jest/polyfills.ts b/test/jest/polyfills.ts new file mode 100644 index 00000000..7ddc82e8 --- /dev/null +++ b/test/jest/polyfills.ts @@ -0,0 +1,31 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { TextDecoder, TextEncoder } from 'util'; +import { ReadableStream, TransformStream, WritableStream } from 'stream/web'; +import { MessageChannel, MessagePort } from 'worker_threads'; +import { performance } from 'perf_hooks'; + +// jsdom (testEnvironment) sandboxes its own global scope, hiding the Node-native web APIs that +// undici's real Fetch implementation needs at import time - unlike browser-oriented ponyfills +// (e.g. whatwg-fetch), undici's Response.body is a genuine ReadableStream, which react-router-dom's +// `isResponse()` duck-type check (and its auto JSON-parsing/redirect detection) requires. +Object.assign(global, { + TextDecoder, + TextEncoder, + ReadableStream, + TransformStream, + WritableStream, + MessageChannel, + MessagePort, + performance, +}); diff --git a/test/jest/setup.ts b/test/jest/setup.ts index 4ed7a977..2efd9a9f 100644 --- a/test/jest/setup.ts +++ b/test/jest/setup.ts @@ -12,8 +12,13 @@ import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; +import { fetch, Headers, Request, Response } from 'undici'; Enzyme.configure({ adapter: new Adapter() }); +// jsdom (testEnvironment) doesn't implement the Fetch API - React Router v6.4+'s data router +// internals (createMemoryRouter et al.) rely on the global Request/Response/fetch. +Object.assign(global, { fetch, Headers, Request, Response }); + // eslint-disable-next-line @typescript-eslint/no-empty-function global.console.warn = function() {}; diff --git a/yarn.lock b/yarn.lock index 198ce613..c5c85a00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1586,6 +1586,16 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@okta/auth-foundation@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@okta/auth-foundation/-/auth-foundation-0.6.0.tgz#25b729bc8d15de53025e08d619f4ec19e072687f" + integrity sha512-AueDPbgox+E0To/SYrxpjQwiyI2HwxNqp1aWn/90ZonevTiJAk+o5kSp9i+Y7j08aUEc1NGXKJj989I+ZXsRbQ== + +"@okta/oauth2-flows@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@okta/oauth2-flows/-/oauth2-flows-0.6.0.tgz#1df28e8167b67912dfca31bd755593aa1f66425d" + integrity sha512-wCReTJegsaRYeycPcXeuyuPlQ2G5ZA6ttTJ/Is4rvBDCOQk8vMEq+B8Sd3n1v8tOddWP2HTRiIffu/92PqXRRQ== + "@okta/okta-auth-js@*", "@okta/okta-auth-js@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@okta/okta-auth-js/-/okta-auth-js-7.14.5.tgz#698c2a97b13331fe8e2d2ec5ca0e5fd925d5bd1a" @@ -1654,6 +1664,11 @@ optionalDependencies: fsevents "*" +"@okta/spa-platform@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@okta/spa-platform/-/spa-platform-0.6.0.tgz#ac232752117835f7815459e7ce7d1f622c241953" + integrity sha512-VCQbvbnBtpwXBMg98ysRMyvdgjqj03wqPYEda4wyHEo31NN+KwzbwOaNAlxzg0+04MT2iDB0S+krA6MYGXA59w== + "@peculiar/asn1-schema@^2.7.0": version "2.8.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz#69699f84259b2161607cabfc34e512a4023dbef9" @@ -1723,6 +1738,11 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@remix-run/router@1.23.3": + version "1.23.3" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.3.tgz#957c098d4393d301a8aa7dccf3ef28ea5430e36a" + integrity sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q== + "@rolldown/pluginutils@1.0.0-beta.27": version "1.0.0-beta.27" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" @@ -8328,6 +8348,14 @@ react-refresh@^0.17.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== +"react-router-dom-v6@npm:react-router-dom@^6.4.0": + version "6.30.4" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.4.tgz#f7167bf3da6c7d9132130ea985dd06def25e84d5" + integrity sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q== + dependencies: + "@remix-run/router" "1.23.3" + react-router "6.30.4" + react-router-dom@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" @@ -8416,6 +8444,13 @@ react-router@6.3.0: dependencies: history "^5.2.0" +react-router@6.30.4: + version "6.30.4" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.4.tgz#638f35176527bd243d96d81d35d33b757bad46c2" + integrity sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA== + dependencies: + "@remix-run/router" "1.23.3" + react-test-renderer@^16.0.0-0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" @@ -9743,11 +9778,16 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@^4.0.5, typescript@^4.5.4: +typescript@^4.5.4: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.6.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + u2f-api-polyfill@0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/u2f-api-polyfill/-/u2f-api-polyfill-0.4.3.tgz#b7ad165a6f962558517a867c5c4bf9399fcf7e98" @@ -9786,7 +9826,7 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== -undici@^7.19.0: +undici@^7.19.0, undici@^7.29.0: version "7.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f" integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw== From 0aca968323a2891d5524403069c1297efb19bf01 Mon Sep 17 00:00:00 2001 From: Benjamin Truong Date: Wed, 12 Aug 2026 12:52:46 -0700 Subject: [PATCH 2/4] docs[client-js]: Explains the no-authState token model Expands the client-js section to contrast the authState/React-context model used elsewhere in this SDK with client-js's evaluate-at-point-of-use approach, including a before/after example and concrete differences around staleness, effect dependencies, bootstrapping, and subscriptions. Also fixes the SDK construction example: FetchClient takes a TokenOrchestrator as its first constructor argument, not a bare config object, so tokenOrchestrator must be constructed first. --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2acdc970..93b94b94 100644 --- a/README.md +++ b/README.md @@ -394,10 +394,61 @@ minor release. This is entirely opt-in — it adds no imports, code, or dependen `@okta/okta-react/client-js` provides [React Router](#) v6.4+ [loader](https://reactrouter.com/en/main/route/loader) factories for apps using [`@okta/okta-client-javascript`](https://github.com/okta/okta-client-javascript) instead of `@okta/okta-auth-js`. Unlike the rest of this SDK, this subpath does not provide a React Context, hooks, or -components — `@okta/okta-client-javascript` has no persistent global auth state to provide; authentication is -checked and refreshed per-request. Instead, you construct the SDK's client instances yourself (typically as -module-level singletons) and pass them into the loader factories below, wiring the returned loaders into your -own route definitions. +components — `@okta/okta-client-javascript` has no persistent global auth state to provide. Instead, you construct +the SDK's client instances yourself and pass them into the loader factories below, wiring the returned loaders +into your own route definitions. + +This is a different model from the rest of this README, not just a different API. Everywhere above, `oktaAuth` +determines authentication asynchronously and emits a cached `authState` object on the `Security` context; +components read `authState.isAuthenticated` / `authState.accessToken.accessToken` from that cached snapshot and +re-render when a new `authState` is emitted. `@okta/okta-client-javascript` has no equivalent object. There's +nothing to subscribe to, because token validity and refresh are evaluated at the moment a token is needed, on +every call — not on a change-detection loop your components have to stay in sync with. `TokenOrchestrator.getToken()` +(used by `createTokenLoader`) and `FetchClient.fetch()` (used by `createFetchLoader`) each check the stored +credential's expiry and refresh it as part of that call, then return. There's no snapshot handed to you ahead of +time — just whatever's in token storage right now, read fresh each time. + +Compare the [old pattern](#use-the-access-token-function-based) for attaching a bearer token to a request: + +```jsx +// old: authState is a cached snapshot, so components guard on it being ready and re-run +// effects when it changes. +const { authState } = useOktaAuth(); +const [messages, setMessages] = useState(null); + +useEffect(() => { + if (authState.isAuthenticated) { + fetch('/api/messages', { + headers: { Authorization: 'Bearer ' + authState.accessToken.accessToken }, + }).then(/* ... */); + } +}, [authState]); +``` + +against the loader-based equivalent shown below: + +```jsx +// new: the loader calls fetchClient.fetch(), which resolves a valid token (refreshing if +// necessary) at the moment of the request - no snapshot to guard on or re-subscribe to. +loader: createFetchLoader(fetchClient, () => '/api/messages'), +// ... +const messages = useLoaderData(); +``` + +Some concrete differences that follow from this: + +- `authState.accessToken.accessToken` is a string captured at render/effect time. If the token refreshes in the + background afterward, that captured string is stale, and a request built from it can 401 even though `authState` + itself is fine a moment later. `createFetchLoader`/`createTokenLoader` resolve the token at the moment of use, + so there's no window where a component holds an outdated value. +- The `useEffect(..., [authState])` dependency array exists to re-run the effect when `authState`'s identity + changes. Loaders re-run on every navigation to their route, so there's nothing to keep in sync — each run is + already fresh. +- Consumers of `authState` handle it being `null` during the initial async determination (the `Loading...` + checks above). `createTokenLoader` throwing a `401` `Response` on a bad/missing token is that check — the + loader blocks navigation until the answer is known, so there's no separate "is auth state ready" state. +- `authState` updates arrive via `oktaAuth.authStateManager.subscribe()`; getting an effect's subscription scope + wrong can mean missed updates or leaked listeners. The `client-js` loaders have no subscription to manage. > :warning: **Requires React Router v6.4+** :warning:
These loaders only work with a [data router](https://reactrouter.com/en/main/routers/picking-a-router) (`createBrowserRouter`, `createMemoryRouter`, etc.) and its `RouterProvider`. They are not compatible with @@ -410,7 +461,7 @@ npm install --save @okta/okta-react npm install --save @okta/auth-foundation @okta/oauth2-flows @okta/spa-platform ``` -### Constructing the SDK singletons +### Constructing the SDK client instances ```javascript // src/auth.js @@ -424,9 +475,9 @@ const config = { redirectUri: window.location.origin + '/login/callback', }; -export const fetchClient = new FetchClient(config); const signInFlow = new AuthorizationCodeFlow(config); export const tokenOrchestrator = new AuthorizationCodeFlowOrchestrator(signInFlow); +export const fetchClient = new FetchClient(tokenOrchestrator, config); ``` ### Wiring loaders into your router From 7c83a871dc5405c631ba111b4e0e19b31d8ae093 Mon Sep 17 00:00:00 2001 From: Benjamin Truong Date: Mon, 17 Aug 2026 21:31:30 -0700 Subject: [PATCH 3/4] refactor[client-js]: Binds loader factories to their client, defers call args createFetchLoader and createTokenLoader now take only the fetchClient/ orchestrator at creation time and return a function that takes the resource/params at call time, instead of baking those in upfront. The returned function no longer matches React Router's loader signature directly, so it must be called from within your own loader function. Adds createLoadersFromOrchestrator, which binds a single orchestrator instance to both createTokenLoader and createLoginCallbackLoader, since they need to share that instance to see each other's stored credential. Addresses review feedback on #321. --- README.md | 27 +++++++++----- src/client-js/createFetchLoader.ts | 29 +++++++-------- .../createLoadersFromOrchestrator.ts | 36 +++++++++++++++++++ src/client-js/createTokenLoader.ts | 22 +++++++----- src/client-js/index.ts | 2 +- test/jest/clientJs/createFetchLoader.test.tsx | 12 +++---- .../createLoadersFromOrchestrator.test.tsx | 34 ++++++++++++++++++ test/jest/clientJs/createTokenLoader.test.tsx | 9 +++-- 8 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 src/client-js/createLoadersFromOrchestrator.ts create mode 100644 test/jest/clientJs/createLoadersFromOrchestrator.test.tsx diff --git a/README.md b/README.md index 93b94b94..804ca998 100644 --- a/README.md +++ b/README.md @@ -430,7 +430,9 @@ against the loader-based equivalent shown below: ```jsx // new: the loader calls fetchClient.fetch(), which resolves a valid token (refreshing if // necessary) at the moment of the request - no snapshot to guard on or re-subscribe to. -loader: createFetchLoader(fetchClient, () => '/api/messages'), +const fetchResource = createFetchLoader(fetchClient); +// ... +loader: () => fetchResource('/api/messages'), // ... const messages = useLoaderData(); ``` @@ -485,27 +487,30 @@ export const fetchClient = new FetchClient(tokenOrchestrator, config); ```jsx // src/router.js import { createBrowserRouter } from 'react-router-dom'; -import { createFetchLoader, createTokenLoader, createLoginCallbackLoader } from '@okta/okta-react/client-js'; +import { createFetchLoader, createLoadersFromOrchestrator } from '@okta/okta-react/client-js'; import { fetchClient, tokenOrchestrator } from './auth'; import Home from './Home'; import Protected from './Protected'; import Messages from './Messages'; +const fetchResource = createFetchLoader(fetchClient); +const { tokenLoader, loginCallbackLoader } = createLoadersFromOrchestrator(tokenOrchestrator); + export const router = createBrowserRouter([ { path: '/', element: }, { path: '/protected', element: , - loader: createTokenLoader(tokenOrchestrator), + loader: () => tokenLoader(), }, { path: '/messages', element: , - loader: createFetchLoader(fetchClient, () => '/api/messages'), + loader: () => fetchResource('/api/messages'), }, { path: '/login/callback', - loader: createLoginCallbackLoader(tokenOrchestrator), + loader: loginCallbackLoader, }, ]); ``` @@ -520,10 +525,16 @@ export default function App() { } ``` -`createTokenLoader` throws a `401` `Response` if a valid token can't be obtained, which React Router will -surface to the nearest [`errorElement`](https://reactrouter.com/en/main/route/error-element). `createFetchLoader` +`createFetchLoader` and `createTokenLoader` (bundled into `createLoadersFromOrchestrator` alongside +`createLoginCallbackLoader`, since the token loader and login callback loader need to share one orchestrator +instance to see each other's stored credential) each return a function that takes the resource/params to use, +not React Router's `{ request, params }` loader args directly — call them from within your own loader function, +as above, rather than assigning them straight to `loader`. + +`tokenLoader` throws a `401` `Response` if a valid token can't be obtained, which React Router will +surface to the nearest [`errorElement`](https://reactrouter.com/en/main/route/error-element). `fetchResource` returns the `fetchClient.fetch()` response directly so it can be consumed with `useLoaderData()`. -`createLoginCallbackLoader` resumes the sign-in flow via `orchestrator.resumeFlow()` (which stores the resulting +`loginCallbackLoader` resumes the sign-in flow via `orchestrator.resumeFlow()` (which stores the resulting credential itself) and redirects back to the original URI — it replaces the `` component used with `@okta/okta-auth-js`. diff --git a/src/client-js/createFetchLoader.ts b/src/client-js/createFetchLoader.ts index b9f0a70d..454e71dc 100644 --- a/src/client-js/createFetchLoader.ts +++ b/src/client-js/createFetchLoader.ts @@ -15,26 +15,23 @@ // instances are structurally assignable here since they extend this base class. import type { FetchClient } from '@okta/auth-foundation'; -export interface LoaderArgs { - request: Request; - params: Record; -} - -export type GetResource = (args: LoaderArgs) => string | URL | Request; - /** - * Wraps a {@link FetchClient} in a React Router (v6.4+) loader-compatible function. + * Binds a {@link FetchClient} to a small helper for use inside a React Router (v6.4+) loader. * * `fetchClient.fetch()` already resolves a matching credential, refreshes it if needed, or performs a full - * re-authentication redirect if not - this loader does no auth logic of its own, it just fetches and returns + * re-authentication redirect if not - this helper does no auth logic of its own, it just fetches and returns * the raw `Response`, which React Router auto-parses when read via `useLoaderData()`. + * + * The returned function takes a resource and optional `RequestInit`, not React Router's `{ request, params }` + * loader args, so call it from within your own loader function rather than assigning it directly to `loader`: + * + * @example + * const fetchMessages = createFetchLoader(fetchClient); + * // ... + * loader: ({ params }) => fetchMessages(`/api/users/${params.userId}/messages`), */ -export function createFetchLoader( - fetchClient: FetchClient, - getResource: GetResource, - init?: RequestInit, -) { - return async (args: LoaderArgs): Promise => { - return fetchClient.fetch(getResource(args), init); +export function createFetchLoader(fetchClient: FetchClient) { + return async (resource: string | URL | Request, init?: RequestInit): Promise => { + return fetchClient.fetch(resource, init); }; } diff --git a/src/client-js/createLoadersFromOrchestrator.ts b/src/client-js/createLoadersFromOrchestrator.ts new file mode 100644 index 00000000..47a90bfd --- /dev/null +++ b/src/client-js/createLoadersFromOrchestrator.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import type { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; +import { createTokenLoader } from './createTokenLoader'; +import { createLoginCallbackLoader } from './createLoginCallbackLoader'; + +/** + * Binds a single {@link AuthorizationCodeFlowOrchestrator} instance to both {@link createTokenLoader} and + * {@link createLoginCallbackLoader}. + * + * The login callback route stores the credential that the token loader later reads back from the same + * orchestrator - passing two separately-constructed orchestrator instances to those loaders would silently + * break that handoff, so this constructs both from the one instance you provide. + * + * @example + * const { tokenLoader, loginCallbackLoader } = createLoadersFromOrchestrator(tokenOrchestrator); + */ +export function createLoadersFromOrchestrator(orchestrator: AuthorizationCodeFlowOrchestrator): { + tokenLoader: ReturnType; + loginCallbackLoader: ReturnType; +} { + return { + tokenLoader: createTokenLoader(orchestrator), + loginCallbackLoader: createLoginCallbackLoader(orchestrator), + }; +} diff --git a/src/client-js/createTokenLoader.ts b/src/client-js/createTokenLoader.ts index 2be6c5cc..6e1f0b63 100644 --- a/src/client-js/createTokenLoader.ts +++ b/src/client-js/createTokenLoader.ts @@ -14,20 +14,26 @@ import type { Token, TokenOrchestrator } from '@okta/auth-foundation'; import type { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; /** - * Wraps an {@link AuthorizationCodeFlowOrchestrator} in a React Router (v6.4+) loader-compatible function, - * for consumers who want a raw {@link Token} (e.g. to attach an `Authorization` header themselves) rather - * than a `fetchClient`-mediated `Response`. + * Binds an {@link AuthorizationCodeFlowOrchestrator} to a small helper for use inside a React Router (v6.4+) + * loader, for consumers who want a raw {@link Token} (e.g. to attach an `Authorization` header themselves) + * rather than a `fetchClient`-mediated `Response`. * * `orchestrator.getToken()` already resolves a matching credential, refreshes it if needed, or performs a * full re-authentication redirect if not - if a redirect occurs, the returned promise never resolves because * the page navigates away, same as {@link createFetchLoader}. The `401` throw below only covers the * (default-off, `avoidPrompting: true`) case where the orchestrator declines to redirect and returns `null`. + * + * The returned function takes optional {@link TokenOrchestrator.AuthorizeParams}, not React Router's + * `{ request, params }` loader args, so call it from within your own loader function rather than assigning + * it directly to `loader`: + * + * @example + * const getToken = createTokenLoader(orchestrator); + * // ... + * loader: () => getToken({ scopes: ['openid', 'admin'] }), */ -export function createTokenLoader( - orchestrator: AuthorizationCodeFlowOrchestrator, - params?: TokenOrchestrator.AuthorizeParams, -) { - return async (): Promise => { +export function createTokenLoader(orchestrator: AuthorizationCodeFlowOrchestrator) { + return async (params?: TokenOrchestrator.AuthorizeParams): Promise => { const token = await orchestrator.getToken(params); if (!token) { throw new Response('Unauthorized', { status: 401 }); diff --git a/src/client-js/index.ts b/src/client-js/index.ts index 784d2ae8..1ffefd99 100644 --- a/src/client-js/index.ts +++ b/src/client-js/index.ts @@ -11,6 +11,6 @@ */ export { createFetchLoader } from './createFetchLoader'; -export type { GetResource, LoaderArgs } from './createFetchLoader'; export { createTokenLoader } from './createTokenLoader'; export { createLoginCallbackLoader } from './createLoginCallbackLoader'; +export { createLoadersFromOrchestrator } from './createLoadersFromOrchestrator'; diff --git a/test/jest/clientJs/createFetchLoader.test.tsx b/test/jest/clientJs/createFetchLoader.test.tsx index 99d6c844..fca72795 100644 --- a/test/jest/clientJs/createFetchLoader.test.tsx +++ b/test/jest/clientJs/createFetchLoader.test.tsx @@ -25,6 +25,7 @@ describe('createFetchLoader', () => { headers: { 'Content-Type': 'application/json' }, })), }; + const fetchResource = createFetchLoader(fetchClient as any); function Page() { const data = useLoaderData() as { id: string }; @@ -35,7 +36,7 @@ describe('createFetchLoader', () => { [{ path: '/', element: , - loader: createFetchLoader(fetchClient as any, () => '/api/resource'), + loader: () => fetchResource('/api/resource'), }], { initialEntries: ['/'] } ); @@ -46,11 +47,11 @@ describe('createFetchLoader', () => { expect(fetchClient.fetch).toHaveBeenCalledWith('/api/resource', undefined); }); - it('derives the resource from request/params and forwards init through to fetchClient.fetch()', async () => { + it('derives the resource from params and forwards init through to fetchClient.fetch()', async () => { const fetchClient = { fetch: jest.fn().mockResolvedValue(new Response(null, { status: 204 })), }; - const getResource = jest.fn(({ params }: any) => `/api/users/${params.userId}`); + const fetchResource = createFetchLoader(fetchClient as any); const init = { headers: { 'X-Test': '1' } }; function Page() { @@ -62,7 +63,7 @@ describe('createFetchLoader', () => { [{ path: '/users/:userId', element: , - loader: createFetchLoader(fetchClient as any, getResource, init), + loader: ({ params }) => fetchResource(`/api/users/${params.userId}`, init), }], { initialEntries: ['/users/abc'] } ); @@ -70,9 +71,6 @@ describe('createFetchLoader', () => { render(); await waitFor(() => expect(screen.getByText('done')).toBeInTheDocument()); - expect(getResource).toHaveBeenCalledWith( - expect.objectContaining({ params: expect.objectContaining({ userId: 'abc' }) }) - ); expect(fetchClient.fetch).toHaveBeenCalledWith('/api/users/abc', init); }); }); diff --git a/test/jest/clientJs/createLoadersFromOrchestrator.test.tsx b/test/jest/clientJs/createLoadersFromOrchestrator.test.tsx new file mode 100644 index 00000000..629190a3 --- /dev/null +++ b/test/jest/clientJs/createLoadersFromOrchestrator.test.tsx @@ -0,0 +1,34 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createLoadersFromOrchestrator } from '../../../src/client-js/createLoadersFromOrchestrator'; + +describe('createLoadersFromOrchestrator', () => { + it('binds both loaders to the same orchestrator instance', async () => { + const token = { accessToken: 'abc123' }; + const orchestrator = { + getToken: jest.fn().mockResolvedValue(token), + resumeFlow: jest.fn().mockResolvedValue({ originalUri: '/protected' }), + }; + + const { tokenLoader, loginCallbackLoader } = createLoadersFromOrchestrator(orchestrator as any); + + await expect(tokenLoader()).resolves.toBe(token); + expect(orchestrator.getToken).toHaveBeenCalledWith(undefined); + + const response = await loginCallbackLoader({ request: new Request('https://example.com/login/callback') }); + expect(orchestrator.resumeFlow).toHaveBeenCalledWith('https://example.com/login/callback'); + expect(response.headers.get('Location')).toBe('/protected'); + }); +}); diff --git a/test/jest/clientJs/createTokenLoader.test.tsx b/test/jest/clientJs/createTokenLoader.test.tsx index 6e6a0391..5566cc92 100644 --- a/test/jest/clientJs/createTokenLoader.test.tsx +++ b/test/jest/clientJs/createTokenLoader.test.tsx @@ -21,6 +21,7 @@ describe('createTokenLoader', () => { it('resolves the token via orchestrator.getToken()', async () => { const token = { accessToken: 'abc123' }; const orchestrator = { getToken: jest.fn().mockResolvedValue(token) }; + const getToken = createTokenLoader(orchestrator as any); function Page() { const data = useLoaderData() as typeof token; @@ -28,7 +29,7 @@ describe('createTokenLoader', () => { } const router = createMemoryRouter( - [{ path: '/', element: , loader: createTokenLoader(orchestrator as any) }], + [{ path: '/', element: , loader: () => getToken() }], { initialEntries: ['/'] } ); @@ -40,6 +41,7 @@ describe('createTokenLoader', () => { it('passes the provided params through to orchestrator.getToken()', async () => { const orchestrator = { getToken: jest.fn().mockResolvedValue({ accessToken: 'xyz' }) }; + const getToken = createTokenLoader(orchestrator as any); const params = { scopes: ['openid'] }; function Page() { @@ -48,7 +50,7 @@ describe('createTokenLoader', () => { } const router = createMemoryRouter( - [{ path: '/', element: , loader: createTokenLoader(orchestrator as any, params) }], + [{ path: '/', element: , loader: () => getToken(params) }], { initialEntries: ['/'] } ); @@ -60,6 +62,7 @@ describe('createTokenLoader', () => { it('throws a 401 Response when getToken() resolves null', async () => { const orchestrator = { getToken: jest.fn().mockResolvedValue(null) }; + const getToken = createTokenLoader(orchestrator as any); function ErrorBoundary() { const error = useRouteError(); @@ -70,7 +73,7 @@ describe('createTokenLoader', () => { [{ path: '/', element:

never rendered

, - loader: createTokenLoader(orchestrator as any), + loader: () => getToken(), errorElement: , }], { initialEntries: ['/'] } From 13f27e9dc80db59f703a87501c78948725c472a1 Mon Sep 17 00:00:00 2001 From: Benjamin Truong Date: Tue, 18 Aug 2026 11:42:04 -0700 Subject: [PATCH 4/4] docs[client-js]: Adds framework-mode clientLoader example --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 804ca998..ebf19198 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,36 @@ returns the `fetchClient.fetch()` response directly so it can be consumed with ` credential itself) and redirects back to the original URI — it replaces the `` component used with `@okta/okta-auth-js`. +### Using these loaders in React Router framework mode + +`fetchClient`/`tokenOrchestrator` read from browser storage and can trigger a browser redirect, so they can only +run in the browser. In [framework mode](https://reactrouter.com/start/framework/data-loading), export them as +`clientLoader`, not `loader` (which runs on the server). Set `clientLoader.hydrate = true` and provide a +`HydrateFallback` so the check also runs on the very first page load, not just on later client-side navigations: + +```tsx +// app/routes/messages.tsx +import { createFetchLoader } from '@okta/okta-react/client-js'; +import { useLoaderData } from 'react-router'; +import { fetchClient } from '~/auth'; + +const fetchResource = createFetchLoader(fetchClient); + +export async function clientLoader() { + return fetchResource('/api/messages'); +} +clientLoader.hydrate = true; + +export function HydrateFallback() { + return

Checking authentication…

; +} + +export default function Messages() { + const messages = useLoaderData(); + // ... +} +``` + ## Reference ### `Security`