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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@
"**/react-dom/**",
"**/react-router-dom",
"**/react-router-dom/**",
"**/react-router",
"**/react-router/**",
"**/@types/react/**",
"**/@types/react-dom",
"**/@types/react-dom/**",
Expand Down
9 changes: 9 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
build
.react-router
*.local

.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
31 changes: 31 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# client-js-router-v7-ssr-app

Sample app demonstrating `@okta/okta-react/client-js` loaders in a [React Router v7 framework-mode](https://reactrouter.com/start/framework/installation) app with SSR enabled (`ssr: true` in `react-router.config.ts`).

The app is server-rendered, but authentication state is entirely browser-held (via `@okta/spa-platform`'s `Credential`/IndexedDB) - there's no server-side session. Every route that needs auth state or a token uses a [`clientLoader`](https://reactrouter.com/how-to/client-data#clientloader) instead of a server `loader`, with `clientLoader.hydrate = true` and a `HydrateFallback` shown while it resolves on first load.

## Routes

- `/` (`app/routes/home.tsx`) - shows a sign in/out button based on whether a `Credential` exists.
- `/protected` (`app/routes/protected.tsx`) - loads and renders ID token claims via `createTokenLoader`.
- `/resource` (`app/routes/resource.tsx`) - fetches `/resource.json` via `createFetchLoader`.
- `/login/callback` (`app/routes/login-callback.tsx`) - resumes the OAuth flow via `createLoginCallbackLoader` and redirects back to the original page. Exports `HydrateFallback` as its `default`, so React Router treats it as a page route rather than a resource route.

`app/auth.ts` exports a single `getAuth()` - a lazy, memoized async singleton. `@okta/spa-platform`'s main entry is a single barrel file: importing any export from it (e.g. `OAuth2Client`) loads the whole module graph, including `Credential`'s own module, which touches the browser-only `location` global on import. `getAuth()` dynamically imports it on first call and is only called from code that runs in the browser (`clientLoader` bodies, event handlers), never from module scope.

## Setup

Requires a `testenv` file at the repo root providing `ISSUER` and `CLIENT_ID` for a test Okta org:

```
CLIENT_ID=<YOUR CLIENT ID>
ISSUER=<YOUR ISSUER URL>
```

The app's redirect URI is `{origin}/login/callback` and its logout redirect is `{origin}/`, so the test org's app configuration needs to allow those for whatever origin you run this on.

```bash
yarn dev # start the dev server (defaults to http://localhost:8080)
yarn build # production build
yarn start # build, then serve via @react-router/serve
```
67 changes: 67 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*!
* 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, FetchClient, SessionLogoutFlow } from '@okta/spa-platform';

type Auth = {
orchestrator: AuthorizationCodeFlowOrchestrator;
signOutFlow: SessionLogoutFlow;
fetchClient: FetchClient;
};

let authPromise: Promise<Auth> | undefined;

// `@okta/spa-platform`'s entry point is a single barrel file: importing any
// export from it (e.g. `OAuth2Client`) loads the whole module graph,
// including `Credential`, whose module touches the browser-only `location`
// global on import. This file is imported on the server as well as in the
// browser, so the import is dynamic here. `createAuth()` runs only in the
// browser (clientLoader bodies, event handlers), never from module scope.
async function createAuth(): Promise<Auth> {
const {
OAuth2Client,
AuthorizationCodeFlow,
SessionLogoutFlow,
AuthorizationCodeFlowOrchestrator,
FetchClient,
} = await import('@okta/spa-platform');

const { ISSUER, CLIENT_ID } = process.env;
const appOrigin = window.location.origin;

const client = new OAuth2Client({
issuer: ISSUER!,
clientId: CLIENT_ID!,
scopes: ['openid', 'profile', 'email'],
});

const signInFlow = new AuthorizationCodeFlow(client, {
redirectUri: `${appOrigin}/login/callback`,
});

const signOutFlow = new SessionLogoutFlow(client, {
logoutRedirectUri: `${appOrigin}/`,
});

const orchestrator = new AuthorizationCodeFlowOrchestrator(signInFlow, {
emitBeforeRedirect: false,
});

const fetchClient = new FetchClient(orchestrator);

return { orchestrator, signOutFlow, fetchClient };
}

export function getAuth(): Promise<Auth> {
authPromise ??= createAuth();
return authPromise;
}
24 changes: 24 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*!
* 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 { HydratedRouter } from 'react-router/dom';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
57 changes: 57 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/

import { PassThrough } from 'node:stream';
import type { EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { createReadableStreamFromReadable } from '@react-router/node';
import { renderToPipeableStream } from 'react-dom/server';

// Routes with `clientLoader.hydrate = true` render inside a Suspense boundary
// for their `HydrateFallback`. `renderToPipeableStream` supports streaming a
// pending boundary; `renderToString` does not.
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
return new Promise<Response>((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(new Response(stream, {
status: responseStatusCode,
headers: responseHeaders,
}));
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
},
);
setTimeout(abort, 5000);
});
}
55 changes: 55 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*!
* 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 { ReactNode } from 'react';
import { Link, Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from 'react-router';

export function Layout({ children }: { children?: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>okta-react client-js + React Router v7 SSR sample</title>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export function ErrorBoundary() {
const error = useRouteError();

const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: 'Unknown error';

return (
<div>
<h1>Error</h1>
<p id="error-message">{message}</p>
<Link to="/">Home</Link>
</div>
);
}
21 changes: 21 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*!
* 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 { RouteConfig } from '@react-router/dev/routes';
import { index, route } from '@react-router/dev/routes';

export default [
index('routes/home.tsx'),
route('protected', 'routes/protected.tsx'),
route('resource', 'routes/resource.tsx'),
route('login/callback', 'routes/login-callback.tsx'),
] satisfies RouteConfig;
69 changes: 69 additions & 0 deletions test/apps/client-js-router-v7-ssr-app/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*!
* 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 { Link, useLoaderData } from 'react-router';
import { getAuth } from '../auth';

// `Credential` is imported dynamically because `@okta/spa-platform`'s entry
// point is a single barrel file: importing any export from it loads the
// whole module graph, including `Credential`'s own module, which touches the
// browser-only `location` global on import. This route module is also
// imported on the server, for its `HydrateFallback`/`Home` exports;
// `clientLoader` and `signOut` below only run in the browser.
export async function clientLoader() {
// `getAuth()` constructs the `OAuth2Client` that `Credential.getDefault()` reads from.
const [{ Credential }] = await Promise.all([import('@okta/spa-platform'), getAuth()]);
const credential = await Credential.getDefault();
return { hasCredential: credential !== null };
}
clientLoader.hydrate = true;

export function HydrateFallback() {
return <p>Checking authentication…</p>;
}

export default function Home() {
const { hasCredential } = useLoaderData<typeof clientLoader>();

const signIn = async () => {
const { orchestrator } = await getAuth();
await orchestrator.getToken();
};

const signOut = async () => {
const [{ Credential }, { signOutFlow }] = await Promise.all([import('@okta/spa-platform'), getAuth()]);
const credential = await Credential.getDefault();
const idToken = credential?.token.idToken?.toString();
if (!credential || !idToken) {
return;
}
await credential.remove();
const url = await signOutFlow.start(idToken);
window.location.assign(url);
};

return (
<div>
<h1>okta-react client-js + React Router v7 SSR sample</h1>
{hasCredential ? (
<button id="logout-button" onClick={signOut}>Sign out</button>
) : (
<button id="login-button" onClick={signIn}>Sign in</button>
)}
<nav>
<Link to="/protected">Protected</Link>
{' | '}
<Link to="/resource">Resource</Link>
</nav>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*!
* 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 { createLoginCallbackLoader } from '@okta/okta-react/client-js';
import { getAuth } from '../auth';

export async function clientLoader({ request }: { request: Request }) {
const { orchestrator } = await getAuth();
return createLoginCallbackLoader(orchestrator)({ request });
}
clientLoader.hydrate = true;

export default function HydrateFallback() {
return <p>Checking authentication…</p>;
}

Loading