From 66a9ffa859a56cb6b5eb6d0a1b5e1d7d0c5e47bb Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Sat, 15 Aug 2026 21:49:55 +0200 Subject: [PATCH] oidc --- .env.development | 2 + .env.production | 2 + .env.test | 2 + package.json | 1 + pnpm-lock.yaml | 17 +++ src/app.tsx | 33 +++- src/client/client.ts | 44 +++++- src/client/error.ts | 2 + src/client/pet.ts | 5 +- src/hook/use-oidc.tsx | 149 +++++++++++++++++++ src/index.tsx | 14 +- src/oidc.ts | 27 ++++ src/vite-env.d.ts | 6 + tests/app.test.tsx | 281 ++++++++++++++++++++++++++++++++++- tests/client/client.test.ts | 257 +++++++++++++++++++++++++++++++- tests/hook/use-oidc.test.tsx | 250 +++++++++++++++++++++++++++++++ tests/oidc.test.ts | 53 +++++++ 17 files changed, 1131 insertions(+), 14 deletions(-) create mode 100644 src/hook/use-oidc.tsx create mode 100644 src/oidc.ts create mode 100644 tests/hook/use-oidc.test.tsx create mode 100644 tests/oidc.test.ts diff --git a/.env.development b/.env.development index dbd559c..43e3f10 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,3 @@ VITE_PETSTORE_URL=https://localhost +VITE_OIDC_AUTHORITY=http://keycloak:8080/realms/petstore +VITE_OIDC_CLIENT_ID=petstore-frontend diff --git a/.env.production b/.env.production index 994acdd..d58062c 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,3 @@ VITE_PETSTORE_URL=https://petstore.production +VITE_OIDC_AUTHORITY=https://keycloak.production/realms/petstore +VITE_OIDC_CLIENT_ID=petstore-frontend diff --git a/.env.test b/.env.test index 9104501..2e29316 100644 --- a/.env.test +++ b/.env.test @@ -1 +1,3 @@ VITE_PETSTORE_URL=https://petstore.test +VITE_OIDC_AUTHORITY=https://keycloak.test/realms/petstore +VITE_OIDC_CLIENT_ID=petstore-frontend diff --git a/package.json b/package.json index f1d7be5..212905a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@tanstack/react-query": "^5.101.4", "cross-fetch": "^4.1.0", "date-fns": "^4.4.0", + "oidc-client-ts": "^3.5.0", "qs": "^6.15.3", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e906b..a409b46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: date-fns: specifier: ^4.4.0 version: 4.4.0 + oidc-client-ts: + specifier: ^3.5.0 + version: 3.5.0 qs: specifier: ^6.15.3 version: 6.15.3 @@ -1115,6 +1118,10 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1317,6 +1324,10 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -2482,6 +2493,8 @@ snapshots: json-stringify-safe@5.0.1: {} + jwt-decode@4.0.0: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2620,6 +2633,10 @@ snapshots: obug@2.1.4: {} + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + outvariant@1.4.3: {} oxc-resolver@11.24.2: diff --git a/src/app.tsx b/src/app.tsx index 4dd752f..9cde4cf 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,9 +2,15 @@ import type { FC } from 'react'; import { useState } from 'react'; import { NavLink } from 'react-router-dom'; import Routes from './routes'; +import { useOidc } from './hook/use-oidc'; +import { HttpError as HttpErrorPartial } from './component/partial/http-error'; +import { HttpError } from './client/error'; +import { H1 } from './component/heading'; +import { Button } from './component/button'; const App: FC = () => { const [displayMenu, setDisplayMenu] = useState(false); + const oidc = useOidc(); const toggleMenu = () => { setDisplayMenu(!displayMenu); @@ -15,7 +21,7 @@ const App: FC = () => {
- + {oidc.error ? ( + + ) : null} + {oidc.isLoading ? null : oidc.isAuthenticated ? ( + + ) : ( +
+

Login

+

You need to login to use the petstore.

+ +
+ )}
); diff --git a/src/client/client.ts b/src/client/client.ts index 22d1335..5cf48db 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -2,10 +2,32 @@ import { throwableToError } from '@chubbyts/chubbyts-throwable-to-error/dist/thr import qs from 'qs'; import type { z } from 'zod'; import type { HttpError } from './error'; -import { BadRequest, InternalServerError, NetworkError, NotFound, UnprocessableEntity } from './error'; +import { BadRequest, InternalServerError, NetworkError, NotFound, Unauthorized, UnprocessableEntity } from './error'; export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; +export type GetAccessToken = () => Promise; + +export const createAuthenticatedFetch = (fetch: Fetch, getAccessToken: GetAccessToken): Fetch => { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const accessToken = await getAccessToken(); + + if (!accessToken) { + return fetch(input, init); + } + + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + + return fetch(input, { ...init, headers: Object.fromEntries(headers.entries()) }); + }; +}; + +// the api responds without a body, but with a www-authenticate header +const createUnauthorized = (): Unauthorized => { + return new Unauthorized({ title: 'Unauthorized', detail: 'The access token is missing, invalid or expired' }); +}; + export type ListClient = ( modelListRequest: ModelListRequest, ) => Promise; @@ -30,6 +52,10 @@ export const createListClient = < }, }); + if (401 === response.status) { + return createUnauthorized(); + } + const json = await response.json(); if (200 === response.status) { @@ -72,6 +98,10 @@ export const createCreateClient = ( }, }); + if (401 === response.status) { + return createUnauthorized(); + } + const json = await response.json(); if (200 === response.status) { @@ -159,6 +193,10 @@ export const createUpdateClient = { return; } + if (401 === response.status) { + return createUnauthorized(); + } + const json = await response.json(); if (404 === response.status) { diff --git a/src/client/error.ts b/src/client/error.ts index 7013bda..f94e604 100644 --- a/src/client/error.ts +++ b/src/client/error.ts @@ -43,6 +43,8 @@ export class NetworkError extends HttpError {} export class NotFound extends HttpError {} +export class Unauthorized extends HttpError {} + export class UnprocessableEntity extends BadRequestOrUnprocessableEntity {} export const createInvalidParametersByName = ( diff --git a/src/client/pet.ts b/src/client/pet.ts index 1d71296..d8eface 100644 --- a/src/client/pet.ts +++ b/src/client/pet.ts @@ -1,6 +1,8 @@ -import { fetch } from 'cross-fetch'; +import { fetch as crossFetch } from 'cross-fetch'; import { petListRequestSchema, petListResponseSchema, petRequestSchema, petResponseSchema } from '../model/pet'; +import { getAccessToken } from '../oidc'; import { + createAuthenticatedFetch, createCreateClient, createDeleteClient, createListClient, @@ -8,6 +10,7 @@ import { createUpdateClient, } from './client'; +const fetch = createAuthenticatedFetch(crossFetch, getAccessToken); const url = `${import.meta.env.VITE_PETSTORE_URL}/api/pets`; export const listPetsClient = createListClient(fetch, url, petListRequestSchema, petListResponseSchema); diff --git a/src/hook/use-oidc.tsx b/src/hook/use-oidc.tsx new file mode 100644 index 0000000..7a81b48 --- /dev/null +++ b/src/hook/use-oidc.tsx @@ -0,0 +1,149 @@ +import type { FC, PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { User, UserManager } from 'oidc-client-ts'; +import { throwableToError } from '@chubbyts/chubbyts-throwable-to-error/dist/throwable-to-error'; + +export type OidcProviderProps = { + userManager: UserManager; + onSigninCallback?: (user: User | undefined) => Promise | void; +}; + +export type Oidc = { + isLoading: boolean; + isAuthenticated: boolean; + error?: Error; + login: () => Promise; + logout: () => Promise; +}; + +type OidcState = { + isLoading: boolean; + isAuthenticated: boolean; + error: Error | undefined; +}; + +const OidcContext = createContext(undefined); + +// check if returning back from authority server (response_mode: query) +const hasAuthParams = (): boolean => { + const searchParams = new URLSearchParams(window.location.search); + + return Boolean((searchParams.get('code') || searchParams.get('error')) && searchParams.get('state')); +}; + +export const OidcProvider: FC> = ({ + userManager, + onSigninCallback, + children, +}: PropsWithChildren) => { + const [state, setState] = useState({ isLoading: true, isAuthenticated: false, error: undefined }); + const didInitialize = useRef(false); + + const patchState = useCallback((patch: Partial): void => { + setState((currentState) => ({ ...currentState, ...patch })); + }, []); + + useEffect(() => { + // event UserLoaded (e.g. initial load, silent renew success) + const handleUserLoaded = (user: User): void => { + patchState({ isLoading: false, isAuthenticated: !user.expired, error: undefined }); + }; + + // event UserUnloaded (e.g. userManager.removeUser) / UserSignedOut (e.g. user was signed out in background) + const handleUserUnloaded = (): void => { + patchState({ isAuthenticated: false }); + }; + + // event SilentRenewError (silent renew error) + const handleSilentRenewError = (error: Error): void => { + patchState({ isLoading: false, error }); + }; + + userManager.events.addUserLoaded(handleUserLoaded); + userManager.events.addUserUnloaded(handleUserUnloaded); + userManager.events.addUserSignedOut(handleUserUnloaded); + userManager.events.addSilentRenewError(handleSilentRenewError); + + return () => { + userManager.events.removeUserLoaded(handleUserLoaded); + userManager.events.removeUserUnloaded(handleUserUnloaded); + userManager.events.removeUserSignedOut(handleUserUnloaded); + userManager.events.removeSilentRenewError(handleSilentRenewError); + }; + }, [userManager, patchState]); + + useEffect(() => { + // the signin callback must only run once, even if the effect runs twice (react strict mode) + if (didInitialize.current) { + return; + } + + // oxlint-disable-next-line functional/immutable-data + didInitialize.current = true; + + const signinCallback = async (): Promise => { + const user = await userManager.signinCallback(); + + if (onSigninCallback) { + await onSigninCallback(user); + } + + return user; + }; + + const initialize = async (): Promise => { + try { + const signedInUser = hasAuthParams() ? await signinCallback() : undefined; + const user = signedInUser ?? (await userManager.getUser()); + + patchState({ isLoading: false, isAuthenticated: user ? !user.expired : false, error: undefined }); + } catch (error) { + patchState({ isLoading: false, error: throwableToError(error) }); + } + }; + + void initialize(); + }, [userManager, onSigninCallback, patchState]); + + const navigate = useCallback( + async (callback: () => Promise): Promise => { + patchState({ isLoading: true }); + + try { + await callback(); + } catch (error) { + patchState({ error: throwableToError(error) }); + } finally { + patchState({ isLoading: false }); + } + }, + [patchState], + ); + + const oidc = useMemo( + () => ({ + isLoading: state.isLoading, + isAuthenticated: state.isAuthenticated, + error: state.error, + // return to the current page after the login + login: () => + navigate(() => + userManager.signinRedirect({ redirect_uri: `${window.location.origin}${window.location.pathname}` }), + ), + logout: () => navigate(() => userManager.signoutRedirect()), + }), + [state, userManager, navigate], + ); + + return {children}; +}; + +export const useOidc = (): Oidc => { + const oidc = useContext(OidcContext); + + if (!oidc) { + throw new Error('useOidc must be used within an OidcProvider'); + } + + return oidc; +}; diff --git a/src/index.tsx b/src/index.tsx index 88982b0..c7905cc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,8 @@ import ReactDOM from 'react-dom/client'; import { StrictMode } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { OidcProvider } from './hook/use-oidc'; +import { oidcConfig } from './oidc'; import App from './app'; import './index.css'; @@ -11,10 +13,12 @@ const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) root.render( - - - - - + + + + + + + , ); diff --git a/src/oidc.ts b/src/oidc.ts new file mode 100644 index 0000000..5fa3611 --- /dev/null +++ b/src/oidc.ts @@ -0,0 +1,27 @@ +import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; +import type { OidcProviderProps } from './hook/use-oidc'; + +export const userManager = new UserManager({ + authority: import.meta.env.VITE_OIDC_AUTHORITY, + client_id: import.meta.env.VITE_OIDC_CLIENT_ID, + redirect_uri: `${window.location.origin}/`, + post_logout_redirect_uri: `${window.location.origin}/`, + scope: 'openid profile email', + userStore: new WebStorageStateStore({ store: window.sessionStorage }), +}); + +// remove the code and state parameters from the url after a successful signin +export const onSigninCallback = (): void => { + window.history.replaceState({}, document.title, window.location.pathname); +}; + +export const oidcConfig: OidcProviderProps = { + userManager, + onSigninCallback, +}; + +export const getAccessToken = async (): Promise => { + const user = await userManager.getUser(); + + return user?.access_token; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..6e0100f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,7 @@ /// + +interface ImportMetaEnv { + readonly VITE_PETSTORE_URL: string; + readonly VITE_OIDC_AUTHORITY: string; + readonly VITE_OIDC_CLIENT_ID: string; +} diff --git a/tests/app.test.tsx b/tests/app.test.tsx index b22212d..8d2d230 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -1,8 +1,9 @@ -import { test, expect, vi, describe } from 'vitest'; +import { test, expect, vi, describe, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { userEvent } from '@testing-library/user-event'; import App from '../src/app'; +import type { Oidc } from '../src/hook/use-oidc'; import { formatHtml } from './formatter'; vi.mock('../src/routes', () => { @@ -11,7 +12,35 @@ vi.mock('../src/routes', () => { }; }); +const { oidc } = vi.hoisted(() => { + return { + oidc: { + isLoading: false, + isAuthenticated: true, + error: undefined, + login: vi.fn(), + logout: vi.fn(), + } as Oidc, + }; +}); + +vi.mock('../src/hook/use-oidc', () => { + return { + useOidc: () => oidc, + }; +}); + describe('app', () => { + beforeEach(() => { + // oxlint-disable functional/immutable-data + oidc.isLoading = false; + oidc.isAuthenticated = true; + oidc.error = undefined; + oidc.login = vi.fn(); + oidc.logout = vi.fn(); + // oxlint-enable functional/immutable-data + }); + test('close navigation', async () => { const { container } = render( @@ -29,12 +58,18 @@ describe('app', () => { > { > { > Petstore