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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -383,6 +384,190 @@ export default MessageList = () => {
};
```

## Using `@okta/okta-react/client-js` (opt-in, beta)

> :warning: **Beta** :warning:<br> 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:<br> 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 (`<BrowserRouter>` + `<Routes>`).

### 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: <Home /> },
{
path: '/protected',
element: <Protected />,
loader: () => tokenLoader(),
},
{
path: '/messages',
element: <Messages />,
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 <RouterProvider router={router} />;
}
```

`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 `<LoginCallback>` 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 <p>Checking authentication…</p>;
}

export default function Messages() {
const messages = useLoaderData<typeof clientLoader>();
// ...
}
```

## Reference

### `Security`
Expand Down
18 changes: 17 additions & 1 deletion build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
34 changes: 33 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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/",
Expand Down
32 changes: 32 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
];
26 changes: 26 additions & 0 deletions src/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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'
}
}
]
}
Loading