From 275e5381690c04395d297ed269b292bfdf330dd3 Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Sun, 16 Aug 2026 13:16:32 +0200 Subject: [PATCH] oidc --- .env.development | 2 + .env.production | 2 + .env.test | 2 + package.json | 1 + pnpm-lock.yaml | 17 +++ src/app.tsx | 44 +++++- src/client/client.ts | 44 +++++- src/client/error.ts | 2 + src/client/pet.ts | 5 +- src/hook/use-oidc.tsx | 131 ++++++++++++++++++ src/index.tsx | 14 +- src/oidc.ts | 27 ++++ src/vite-env.d.ts | 6 + tests/app.test.tsx | 174 +++++++++++++++++++++++- tests/client/client.test.ts | 257 ++++++++++++++++++++++++++++++++++- tests/hook/use-oidc.test.tsx | 205 ++++++++++++++++++++++++++++ tests/oidc.test.ts | 53 ++++++++ 17 files changed, 972 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 1b09d37..cdc9088 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "cross-fetch": "^4.1.0", "date-fns": "^4.4.0", "nock": "^14.0.16", + "oidc-client-ts": "^3.5.0", "qs": "^6.15.3", "solid-js": "^1.9.14", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e97dfa..bdd9268 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: nock: specifier: ^14.0.16 version: 14.0.16 + oidc-client-ts: + specifier: ^3.5.0 + version: 3.5.0 qs: specifier: ^6.15.3 version: 6.15.3 @@ -1272,6 +1275,10 @@ packages: engines: {node: '>=6'} hasBin: true + 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'} @@ -1501,6 +1508,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==} @@ -2884,6 +2895,8 @@ snapshots: json5@2.2.3: {} + jwt-decode@4.0.0: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -3044,6 +3057,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 e87483d..9ccb25a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,10 +1,16 @@ import type { Component } from 'solid-js'; -import { createSignal } from 'solid-js'; +import { createSignal, Show } from 'solid-js'; import type { RouteSectionProps } from '@solidjs/router'; import { A } from '@solidjs/router'; +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: Component = (props: RouteSectionProps) => { const [getDisplayMenu, setDisplayMenu] = createSignal(false); + const oidc = useOidc(); const toggleMenu = () => { setDisplayMenu(!getDisplayMenu()); @@ -15,7 +21,7 @@ const App: Component = (props: RouteSectionProps) => {
- {props.children} + + {(getError) => ( + + )} + + + +

Login

+

You need to login to use the petstore.

+ +
+ } + > + {props.children} + + ); 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..be1a69a --- /dev/null +++ b/src/hook/use-oidc.tsx @@ -0,0 +1,131 @@ +import type { ParentComponent } from 'solid-js'; +import { createContext, onCleanup, onMount, useContext } from 'solid-js'; +import { createStore } from 'solid-js/store'; +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(); + +// 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: ParentComponent = (props) => { + const [state, setState] = createStore({ isLoading: true, isAuthenticated: false, error: undefined }); + + const signinCallback = async (): Promise => { + const user = await props.userManager.signinCallback(); + + if (props.onSigninCallback) { + await props.onSigninCallback(user); + } + + return user; + }; + + const initialize = async (): Promise => { + try { + const signedInUser = hasAuthParams() ? await signinCallback() : undefined; + const user = signedInUser ?? (await props.userManager.getUser()); + + setState({ isLoading: false, isAuthenticated: user ? !user.expired : false, error: undefined }); + } catch (error) { + setState({ isLoading: false, error: throwableToError(error) }); + } + }; + + const navigate = async (callback: () => Promise): Promise => { + setState({ isLoading: true }); + + try { + await callback(); + } catch (error) { + setState({ error: throwableToError(error) }); + } finally { + setState({ isLoading: false }); + } + }; + + // event UserLoaded (e.g. initial load, silent renew success) + const handleUserLoaded = (user: User): void => { + setState({ 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 => { + setState({ isAuthenticated: false }); + }; + + // event SilentRenewError (silent renew error) + const handleSilentRenewError = (error: Error): void => { + setState({ isLoading: false, error }); + }; + + onMount(() => { + props.userManager.events.addUserLoaded(handleUserLoaded); + props.userManager.events.addUserUnloaded(handleUserUnloaded); + props.userManager.events.addUserSignedOut(handleUserUnloaded); + props.userManager.events.addSilentRenewError(handleSilentRenewError); + + void initialize(); + }); + + onCleanup(() => { + props.userManager.events.removeUserLoaded(handleUserLoaded); + props.userManager.events.removeUserUnloaded(handleUserUnloaded); + props.userManager.events.removeUserSignedOut(handleUserUnloaded); + props.userManager.events.removeSilentRenewError(handleSilentRenewError); + }); + + const oidc: Oidc = { + get isLoading() { + return state.isLoading; + }, + get isAuthenticated() { + return state.isAuthenticated; + }, + get error() { + return state.error; + }, + // return to the current page after the login + login: () => + navigate(() => + props.userManager.signinRedirect({ redirect_uri: `${window.location.origin}${window.location.pathname}` }), + ), + logout: () => navigate(() => props.userManager.signoutRedirect()), + }; + + return {props.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 f66ea55..d39e216 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,8 @@ import { render } from 'solid-js/web'; import { Router } from '@solidjs/router'; import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; +import { OidcProvider } from './hook/use-oidc'; +import { oidcConfig } from './oidc'; import Routes from './routes'; import App from './app'; import './index.css'; @@ -10,11 +12,13 @@ const queryClient = new QueryClient(); render( () => ( - - - - - + + + + + + + ), // oxlint-disable-next-line typescript/no-non-null-assertion document.getElementById('root')!, 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 3c8ed6e..2a5fed5 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -1,13 +1,42 @@ /** @jsxImportSource solid-js */ -import { test, expect, describe } from 'vitest'; +import { test, expect, vi, describe, beforeEach } from 'vitest'; import { render, screen } from '@solidjs/testing-library'; import { Router } from '@solidjs/router'; import { userEvent } from '@testing-library/user-event'; import App from '../src/app'; +import type { Oidc } from '../src/hook/use-oidc'; import { formatHtml } from './formatter'; +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', () => { const { container } = render(() => ); @@ -16,11 +45,13 @@ describe('app', () => {