diff --git a/README.md b/README.md index 83cf3ea4..ebf19198 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,190 @@ 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. 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. +const fetchResource = createFetchLoader(fetchClient); +// ... +loader: () => fetchResource('/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 +`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 client instances + +```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', +}; + +const signInFlow = new AuthorizationCodeFlow(config); +export const tokenOrchestrator = new AuthorizationCodeFlowOrchestrator(signInFlow); +export const fetchClient = new FetchClient(tokenOrchestrator, config); +``` + +### Wiring loaders into your router + +```jsx +// src/router.js +import { createBrowserRouter } from 'react-router-dom'; +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: () => tokenLoader(), + }, + { + path: '/messages', + element: , + loader: () => fetchResource('/api/messages'), + }, + { + path: '/login/callback', + loader: loginCallbackLoader, + }, +]); +``` + +```jsx +// src/App.js +import { RouterProvider } from 'react-router-dom'; +import { router } from './router'; + +export default function App() { + return ; +} +``` + +`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()`. +`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`. + +### 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` 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..454e71dc --- /dev/null +++ b/src/client-js/createFetchLoader.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. + */ + +// 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'; + +/** + * 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 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) { + 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/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..6e1f0b63 --- /dev/null +++ b/src/client-js/createTokenLoader.ts @@ -0,0 +1,43 @@ +/* + * 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'; + +/** + * 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) { + return async (params?: TokenOrchestrator.AuthorizeParams): 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..1ffefd99 --- /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 { 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 new file mode 100644 index 00000000..fca72795 --- /dev/null +++ b/test/jest/clientJs/createFetchLoader.test.tsx @@ -0,0 +1,76 @@ +/*! + * 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' }, + })), + }; + const fetchResource = createFetchLoader(fetchClient as any); + + function Page() { + const data = useLoaderData() as { id: string }; + return

{data.id}

; + } + + const router = createMemoryRouter( + [{ + path: '/', + element: , + loader: () => fetchResource('/api/resource'), + }], + { initialEntries: ['/'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('123')).toBeInTheDocument()); + expect(fetchClient.fetch).toHaveBeenCalledWith('/api/resource', undefined); + }); + + 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 fetchResource = createFetchLoader(fetchClient as any); + const init = { headers: { 'X-Test': '1' } }; + + function Page() { + useLoaderData(); + return

done

; + } + + const router = createMemoryRouter( + [{ + path: '/users/:userId', + element: , + loader: ({ params }) => fetchResource(`/api/users/${params.userId}`, init), + }], + { initialEntries: ['/users/abc'] } + ); + + render(); + + await waitFor(() => expect(screen.getByText('done')).toBeInTheDocument()); + 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/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..5566cc92 --- /dev/null +++ b/test/jest/clientJs/createTokenLoader.test.tsx @@ -0,0 +1,86 @@ +/*! + * 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) }; + const getToken = createTokenLoader(orchestrator as any); + + function Page() { + const data = useLoaderData() as typeof token; + return

{data.accessToken}

; + } + + const router = createMemoryRouter( + [{ path: '/', element: , loader: () => getToken() }], + { 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 getToken = createTokenLoader(orchestrator as any); + const params = { scopes: ['openid'] }; + + function Page() { + useLoaderData(); + return

done

; + } + + const router = createMemoryRouter( + [{ path: '/', element: , loader: () => getToken(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) }; + const getToken = createTokenLoader(orchestrator as any); + + function ErrorBoundary() { + const error = useRouteError(); + return

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

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

never rendered

, + loader: () => getToken(), + 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==