=> {
+ 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==