From 04cd2686a6fb317615981ed4a4d07b0d2e3bb0a0 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Mon, 10 Nov 2025 16:04:50 +0100 Subject: [PATCH 1/5] Generate original attestation proofs integration in core credential request handler --- src/hocs/UriHandler/handlers/CredentialRequestHandler.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hocs/UriHandler/handlers/CredentialRequestHandler.tsx b/src/hocs/UriHandler/handlers/CredentialRequestHandler.tsx index acf3cc182..ad5daeec2 100644 --- a/src/hocs/UriHandler/handlers/CredentialRequestHandler.tsx +++ b/src/hocs/UriHandler/handlers/CredentialRequestHandler.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair, JWK, SignJWT } from "jose"; import { OauthError } from "@wwwallet/client-core"; import { OPENID4VCI_PROOF_TYPE_PRECEDENCE } from "@/config"; @@ -47,8 +47,8 @@ export const CredentialRequestHandler = ({ goToStep, data }) => { logger.debug(err); return null; } - }, [api]); - + }, [api] + ); useEffect(() => { const { From 5f11a5811d66ddd3fe6a781367d2135d15922a1e Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Wed, 12 Nov 2025 10:37:10 +0100 Subject: [PATCH 2/5] Setup protocol error handler tests --- src/api/index.ts | 5 ++ src/config.ts | 2 +- src/context/SessionContextProvider.tsx | 7 ++ src/hocs/UriHandler/UriHandler.tsx | 17 ++-- .../handlers/ProtocolErrorHandler.tsx | 27 +++--- src/store/index.ts | 1 + src/store/sessionsSlice.ts | 6 ++ test/hoc/UriHandler.test.tsx | 82 +++++++++++++++++++ vitest.config.ts | 16 ++++ 9 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 test/hoc/UriHandler.test.tsx diff --git a/src/api/index.ts b/src/api/index.ts index 5b29643f0..305dfdb92 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,8 +1,10 @@ import axios, { AxiosResponse } from 'axios'; import { Err, Ok, Result } from 'ts-results'; +import { useDispatch } from 'react-redux'; import * as config from '../config'; import { logger } from '@/logger'; +import { setLoggedIn } from '@/store'; import { fromBase64Url, jsonParseTaggedBinary, jsonStringifyTaggedBinary, toBase64Url } from '../util'; import { EncryptedContainer, makeAssertionPrfExtensionInputs, parsePrivateData, serializePrivateData } from '../services/keystore'; import { CachedUser, LocalStorageKeystore } from '../services/LocalStorageKeystore'; @@ -98,6 +100,7 @@ export interface BackendApi { } export function useApi(isOnlineProp: boolean = true): BackendApi { + const dispatch = useDispatch(); const isOnline = useMemo(() => isOnlineProp === null ? true : isOnlineProp, [isOnlineProp]); const [appToken, setAppToken, clearAppToken] = useSessionStorage("appToken", null); const [userHandle,] = useSessionStorage("userHandle", null); @@ -326,6 +329,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi { const clearSession = useCallback((): void => { clearSessionStorage(); + dispatch(setLoggedIn(false)); events.dispatchEvent(new CustomEvent(CLEAR_SESSION_EVENT)); }, [clearSessionStorage]); @@ -343,6 +347,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi { authenticationType, showWelcome: authenticationType === 'signup', }); + dispatch(setLoggedIn(true)); await addItem('users', response.data.uuid, response.data); if (isOnline) { diff --git a/src/config.ts b/src/config.ts index b175e4601..4a93e150e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ export const LOG_LEVEL = import.meta.env.VITE_LOG_LEVEL; export const BACKEND_URL = import.meta.env.VITE_WALLET_BACKEND_URL; export const DID_KEY_VERSION: DidKeyVersion = import.meta.env.VITE_DID_KEY_VERSION as DidKeyVersion; export const DISPLAY_CONSOLE = import.meta.env.VITE_DISPLAY_CONSOLE; -export const CORE_CONFIGURATION = import.meta.env.VITE_CORE_CONFIGURATION; +export const CORE_CONFIGURATION = typeof import.meta.env.VITE_CORE_CONFIGURATION === 'object' ? import.meta.env.VITE_CORE_CONFIGURATION : JSON.parse(import.meta.env.VITE_CORE_CONFIGURATION); export const MULTI_LANGUAGE_DISPLAY: boolean = import.meta.env.VITE_MULTI_LANGUAGE_DISPLAY ? JSON.parse(import.meta.env.VITE_MULTI_LANGUAGE_DISPLAY) : false; export const I18N_WALLET_NAME_OVERRIDE: string | undefined = import.meta.env.VITE_I18N_WALLET_NAME_OVERRIDE; export const INACTIVE_LOGOUT_MILLIS = (import.meta.env.VITE_INACTIVE_LOGOUT_SECONDS ? parseInt(import.meta.env.VITE_INACTIVE_LOGOUT_SECONDS, 10) : 60 * 15) * 1000 diff --git a/src/context/SessionContextProvider.tsx b/src/context/SessionContextProvider.tsx index 05c031337..2667f47c5 100644 --- a/src/context/SessionContextProvider.tsx +++ b/src/context/SessionContextProvider.tsx @@ -1,8 +1,10 @@ import React, { useContext, useEffect, useCallback, useRef, useState, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import StatusContext from './StatusContext'; import { useApi } from '../api'; import { logger } from '@/logger'; +import { setLoggedIn } from '@/store'; import { KeystoreEvent, useLocalStorageKeystore } from '../services/LocalStorageKeystore'; import keystoreEvents from '../services/keystoreEvents'; import SessionContext, { SessionContextValue } from './SessionContext'; @@ -13,6 +15,7 @@ import { fetchKeyConfig, HpkeConfig } from '@/lib/utils/ohttpHelpers'; import { OHTTP_KEY_CONFIG } from '@/config'; export const SessionContextProvider = ({ children }) => { + const dispatch = useDispatch(); const { isOnline } = useContext(StatusContext); const api = useApi(isOnline); const keystore = useLocalStorageKeystore(keystoreEvents); @@ -44,6 +47,10 @@ export const SessionContextProvider = ({ children }) => { clearSessionRef.current = clearSession; }, [clearSession]); + useEffect(() => { + dispatch(setLoggedIn(isLoggedIn)); + }, [isLoggedIn]); + // The close() will dispatch Event CloseSessionTabLocal in order to call the clearSession const logout = useCallback(async () => { logger.debug('[Session Context] Close Keystore'); diff --git a/src/hocs/UriHandler/UriHandler.tsx b/src/hocs/UriHandler/UriHandler.tsx index ba0b80ba4..50c15a3e1 100644 --- a/src/hocs/UriHandler/UriHandler.tsx +++ b/src/hocs/UriHandler/UriHandler.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState, useContext, useMemo, useCallback } from "react"; +import { useSelector } from "react-redux"; import { OauthError } from "@wwwallet/client-core"; import { jsonToLog, logger } from "@/logger"; +import { AppState } from "@/store"; import { ProtocolData, ProtocolStep } from "./resources"; -import SessionContext from "../../context/SessionContext"; - import { useTranslation } from "react-i18next"; import useClientCore from "@/hooks/useClientCore"; import useErrorDialog from "@/hooks/useErrorDialog"; @@ -17,7 +17,6 @@ import { PresentationSuccessHandler, ProtocolErrorHandler } from "./handlers"; -import StatusContext from "@/context/StatusContext"; type UriHandlerProps = { children: React.ReactNode; @@ -30,8 +29,12 @@ export const UriHandler = (props: UriHandlerProps) => { const [ protocolData, setProtocolData ] = useState(null); const core = useClientCore(); - const { isOnline } = useContext(StatusContext); - const { isLoggedIn } = useContext(SessionContext); + const isOnline = useSelector((state: AppState) => { + return state.status.isOnline + }) + const isLoggedIn = useSelector((state: AppState) => { + return state.sessions.isLoggedIn + }) const { displayError } = useErrorDialog(); const { t } = useTranslation(); @@ -59,10 +62,10 @@ export const UriHandler = (props: UriHandlerProps) => { return currentStep === "protocol_error" }, [currentStep]) - const goToStep = useCallback((step: ProtocolStep, data?: ProtocolData) => { + const goToStep = (step: ProtocolStep, data?: ProtocolData) => { setStep(step) setProtocolData(data) - }, []) + } useEffect(() => { if (currentStep || !isOnline || !isLoggedIn) return diff --git a/src/hocs/UriHandler/handlers/ProtocolErrorHandler.tsx b/src/hocs/UriHandler/handlers/ProtocolErrorHandler.tsx index f71336f01..d15f97ebc 100644 --- a/src/hocs/UriHandler/handlers/ProtocolErrorHandler.tsx +++ b/src/hocs/UriHandler/handlers/ProtocolErrorHandler.tsx @@ -1,9 +1,7 @@ -import React, { useContext } from "react"; +import React, { useEffect } from "react"; import { ProtocolData, ProtocolStep } from "../resources"; -import SessionContext from "@/context/SessionContext"; - -import useErrorDialog from "@/hooks/useErrorDialog"; +import MessagePopup from "@/components/Popups/MessagePopup"; export type ProtocolErrorHandlerProps = { goToStep: (step: ProtocolStep, data: ProtocolData) => void; @@ -11,20 +9,21 @@ export type ProtocolErrorHandlerProps = { } export const ProtocolErrorHandler = ({ goToStep, data }) => { - const { isLoggedIn } = useContext(SessionContext); - const { displayError } = useErrorDialog(); + const { error, error_description } = data - const urlParams = new URLSearchParams(window.location.search); - const state = urlParams.get('state'); - const error = urlParams.get('error'); + // TODO verify authorization response state + // const state = urlParams.get('state'); - if (isLoggedIn && state && error) { + useEffect(() => { window.history.replaceState({}, '', `${window.location.pathname}`); - const errorDescription = urlParams.get('error_description'); - displayError({ title: error, description: errorDescription }) - } + }, []) return ( - <> + <> + {}} message={{ + title: error, + description: error_description, + }} /> + ) } diff --git a/src/store/index.ts b/src/store/index.ts index 04e0ed5d3..536fc4829 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -72,6 +72,7 @@ export { setCalculatedWalletState, setStorageValue, setApi, + setLoggedIn, setVcEntityList, } from "./sessionsSlice"; diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index c09278e82..86535a57c 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -10,6 +10,7 @@ type State = { privateData: EncryptedContainer | null; calculatedWalletState: WalletState | null; api: BackendApi | null; + isLoggedIn: boolean | null; storage: { "Local storage": { currentValue: Record; @@ -36,6 +37,7 @@ export const sessionsSlice = createSlice({ }, }, api: null, + isLoggedIn: null, vcEntityList: null, }, reducers: { @@ -57,6 +59,9 @@ export const sessionsSlice = createSlice({ setApi: (state: State, { payload }: { payload: BackendApi }) => { state.api = payload }, + setLoggedIn: (state: State, { payload }: { payload: boolean }) => { + state.isLoggedIn = payload + }, setVcEntityList: (state: State, { payload }: { payload: ExtendedVcEntity[] }) => { const current = state.vcEntityList || [] const newList = payload.filter(vcEntity => { @@ -77,6 +82,7 @@ export const { setCalculatedWalletState, setStorageValue, setApi, + setLoggedIn, setVcEntityList, } = sessionsSlice.actions; export default sessionsSlice.reducer; diff --git a/test/hoc/UriHandler.test.tsx b/test/hoc/UriHandler.test.tsx new file mode 100644 index 000000000..c8bc3a1c7 --- /dev/null +++ b/test/hoc/UriHandler.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Provider as StateProvider } from 'react-redux'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setLoggedIn, setOnline, store } from '../../src/store'; + +import { ClientCoreContextProvider } from '../../src/context/ClientCoreContextProvider'; +import { ErrorDialogContextProvider } from '../../src/context/ErrorDialogContextProvider'; +import { StatusContextProvider } from '../../src/context/StatusContextProvider'; +import UriHandler from '../../src/hocs/UriHandler/UriHandler'; + +const subject = ( + + + + + content} /> + + + + +) + +describe("UriHandler", () => { + beforeEach(() => { + vi.stubGlobal("navigator", { + serviceWorker: { + addEventListener: () => {} + } + }) + }) + + it('renders without location parameters', () => { + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + + expect(content.innerHTML).to.eq("content") + }) + }) + + describe("wallet is online", () => { + beforeEach(() => { + store.dispatch(setOnline()) + }) + + it('renders without location parameters', () => { + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + + expect(content.innerHTML).to.eq("content") + }) + }) + + describe("user is logged in", () => { + beforeEach(() => { + store.dispatch(setLoggedIn(true)) + }) + + it("renders protocol errors", () => { + const error = "oauth_error" + const error_description = "oauth_error_description" + vi.stubGlobal("location", { + search: `?error=${error}&error_description=${error_description}` + }) + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + expect(content.innerHTML).to.eq("content") + + screen.getByText(error) as HTMLElement + screen.getByText(error_description) as HTMLElement + }) + }) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index b3fcf3f4d..6a0d7f06a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,23 @@ +import react from "@vitejs/plugin-react"; +import { parse } from "yaml"; +import fs from "node:fs"; +import path from "node:path"; import { defineConfig } from 'vitest/config'; import { resolve } from 'path'; +const yamlConfig = parse(fs.readFileSync(path.join(__dirname, 'config.test.yml')).toString()).wallet + +const walletConfig = {} + +Object.keys(yamlConfig).forEach((key: string) => { + // FIXME vitest does not manage objects as definitions + walletConfig[`import.meta.env.VITE_${key.toUpperCase()}`] = JSON.stringify(JSON.stringify(yamlConfig[key])) +}) + export default defineConfig({ + define: { + ...walletConfig, + }, resolve: { alias: { '@': resolve(__dirname, './src'), From 1b42da369b82208b1cc297a881459728b372a25a Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Wed, 12 Nov 2025 18:19:25 +0100 Subject: [PATCH 3/5] Credential offer integration testing --- package.json | 1 + src/config.ts | 2 +- src/hocs/UriHandler/UriHandler.tsx | 2 +- .../handlers/AuthorizationRequestHandler.tsx | 9 +- .../UriHandler/handlers/AuthorizeHandler.tsx | 3 +- src/hocs/UriHandler/resources.ts | 1 + src/lib/services/HttpProxy/HttpProxy.ts | 3 +- test/hoc/UriHandler.test.tsx | 87 ++++++++++++++++++- yarn.lock | 64 ++++++++++++++ 9 files changed, 166 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 72227b38c..b38e92085 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "eslint-config-react-app": "^7.0.1", "jsdom": "^26.0.0", "sharp": "^0.34.5", + "nock": "^14.0.10", "vite": "^6.1.0", "vite-plugin-checker": "^0.11.0", "vite-plugin-svgr": "^4.3.0", diff --git a/src/config.ts b/src/config.ts index 4a93e150e..ca09eee0c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ export type DidKeyVersion = "p256-pub" | "jwk_jcs-pub"; export const APP_VERSION = import.meta.env.VITE_APP_VERSION; export const LOG_LEVEL = import.meta.env.VITE_LOG_LEVEL; -export const BACKEND_URL = import.meta.env.VITE_WALLET_BACKEND_URL; +export const BACKEND_URL = import.meta.env.VITE_WALLET_BACKEND_URL.startsWith('"') ? JSON.parse(import.meta.env.VITE_WALLET_BACKEND_URL) : import.meta.env.VITE_WALLET_BACKEND_URL; export const DID_KEY_VERSION: DidKeyVersion = import.meta.env.VITE_DID_KEY_VERSION as DidKeyVersion; export const DISPLAY_CONSOLE = import.meta.env.VITE_DISPLAY_CONSOLE; export const CORE_CONFIGURATION = typeof import.meta.env.VITE_CORE_CONFIGURATION === 'object' ? import.meta.env.VITE_CORE_CONFIGURATION : JSON.parse(import.meta.env.VITE_CORE_CONFIGURATION); diff --git a/src/hocs/UriHandler/UriHandler.tsx b/src/hocs/UriHandler/UriHandler.tsx index 50c15a3e1..fb9503b04 100644 --- a/src/hocs/UriHandler/UriHandler.tsx +++ b/src/hocs/UriHandler/UriHandler.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useContext, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { useSelector } from "react-redux"; import { OauthError } from "@wwwallet/client-core"; import { jsonToLog, logger } from "@/logger"; diff --git a/src/hocs/UriHandler/handlers/AuthorizationRequestHandler.tsx b/src/hocs/UriHandler/handlers/AuthorizationRequestHandler.tsx index 431becf5c..3adb690f7 100644 --- a/src/hocs/UriHandler/handlers/AuthorizationRequestHandler.tsx +++ b/src/hocs/UriHandler/handlers/AuthorizationRequestHandler.tsx @@ -1,6 +1,8 @@ import React, { useEffect } from "react"; +import { useSelector } from "react-redux"; import { OauthError } from "@wwwallet/client-core"; import { jsonToLog, logger } from "@/logger"; +import { AppState } from "@/store"; import { ProtocolData, ProtocolStep } from "../resources"; import { useTranslation } from "react-i18next"; @@ -18,15 +20,20 @@ export const AuthorizationRequestHandler = ({ goToStep, data }: AuthorizationReq issuer, issuer_state } = data + const isOnline = useSelector((state: AppState) => state.status.isOnline) + const core = useClientCore(); const { displayError } = useErrorDialog(); const { t } = useTranslation(); useEffect(() => { + if (!isOnline) return + core.authorization({ issuer: issuer, issuer_state: issuer_state ?? 'issuer_state', }).then(({ nextStep, data }) => { + // @ts-expect-error return goToStep(nextStep, data) }).catch((err) => { if (err instanceof OauthError) { @@ -41,7 +48,7 @@ export const AuthorizationRequestHandler = ({ goToStep, data }: AuthorizationReq throw err; }) - }, [core, goToStep, displayError, t, issuer, issuer_state]) + }, [isOnline, core, goToStep, displayError, t, issuer, issuer_state]) return ( <> diff --git a/src/hocs/UriHandler/handlers/AuthorizeHandler.tsx b/src/hocs/UriHandler/handlers/AuthorizeHandler.tsx index ab54e5fd3..c55a63cb9 100644 --- a/src/hocs/UriHandler/handlers/AuthorizeHandler.tsx +++ b/src/hocs/UriHandler/handlers/AuthorizeHandler.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { ProtocolData, ProtocolStep } from "../resources"; type AuthorizeHandlerProps = { @@ -11,6 +12,6 @@ export const AuthorizeHandler = ({ data }: AuthorizeHandlerProps) => { window.location.href = authorize_url return ( - <> + <>redirected ) } diff --git a/src/hocs/UriHandler/resources.ts b/src/hocs/UriHandler/resources.ts index a8260d01d..03ad4874d 100644 --- a/src/hocs/UriHandler/resources.ts +++ b/src/hocs/UriHandler/resources.ts @@ -7,6 +7,7 @@ export type ProtocolData = ProtocolResponse['data'] export type ProtocolStep = | "authorization_request" | "authorize" + | "authorization_challenge" | "generate_presentation" | "send_presentation" | "presentation_success" diff --git a/src/lib/services/HttpProxy/HttpProxy.ts b/src/lib/services/HttpProxy/HttpProxy.ts index b7be86f3a..b230aa45d 100644 --- a/src/lib/services/HttpProxy/HttpProxy.ts +++ b/src/lib/services/HttpProxy/HttpProxy.ts @@ -2,6 +2,7 @@ import { useContext, useMemo } from 'react'; import { useSelector } from 'react-redux'; import axios from 'axios'; import { IHttpProxy, RequestHeaders, ResponseHeaders } from '../../interfaces/IHttpProxy'; +import { BACKEND_URL } from '@/config'; import { AppState } from '@/store'; import { addItem, getItem, removeItem } from '@/indexedDB'; import { encryptedHttpRequest, toArrayBuffer } from '@/lib/utils/ohttpHelpers'; @@ -10,7 +11,7 @@ import SessionContext from '@/context/SessionContext'; import { toU8 } from '@/util'; // @ts-ignore -const walletBackendServerUrl = import.meta.env.VITE_WALLET_BACKEND_URL; +const walletBackendServerUrl = BACKEND_URL; const inFlightRequests = new Map>(); const TIMEOUT = 100 * 1000; diff --git a/test/hoc/UriHandler.test.tsx b/test/hoc/UriHandler.test.tsx index c8bc3a1c7..b2c3d891a 100644 --- a/test/hoc/UriHandler.test.tsx +++ b/test/hoc/UriHandler.test.tsx @@ -1,7 +1,8 @@ +import nock from 'nock'; import React from 'react'; import { Provider as StateProvider } from 'react-redux'; import { render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setLoggedIn, setOnline, store } from '../../src/store'; @@ -30,6 +31,9 @@ describe("UriHandler", () => { } }) }) + afterEach(() => { + nock.cleanAll() + }) it('renders without location parameters', () => { render(subject) @@ -43,6 +47,10 @@ describe("UriHandler", () => { describe("wallet is online", () => { beforeEach(() => { + nock("http://backend.test") + .persist() + .get("/status") + .reply(200, {}) store.dispatch(setOnline()) }) @@ -61,6 +69,16 @@ describe("UriHandler", () => { store.dispatch(setLoggedIn(true)) }) + it('renders without location parameters', () => { + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + + expect(content.innerHTML).to.eq("content") + }) + }) + it("renders protocol errors", () => { const error = "oauth_error" const error_description = "oauth_error_description" @@ -77,6 +95,73 @@ describe("UriHandler", () => { screen.getByText(error_description) as HTMLElement }) }) + + it("renders credential offer errors", () => { + const credential_offer = {} + vi.stubGlobal("location", { + search: `?credential_offer=${JSON.stringify(credential_offer)}` + }) + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + expect(content.innerHTML).to.eq("content") + + screen.getByText("errors.oid4vci.parse_location.description.authorization_request") as HTMLElement + screen.getByText("errors.oid4vci.parse_location.invalid_location") as HTMLElement + }) + }) + + it("redirects to authorization url with a valid credential offer", () => { + nock("http://backend.test") + .persist() + .post("/proxy", () => true) + .reply(200, (_url, body: { url: string }) => { + const pushed_authorization_request_endpoint = "http://issuer.test/par" + if (body.url === pushed_authorization_request_endpoint) { + return { + data: { + request_uri: "request_uri", + }, + } + } + + if (body.url.match(/well-known/)) { + return { + data: { + authorization_endpoint: "http://issuer.test/authorize", + pushed_authorization_request_endpoint, + credential_configurations_supported: {}, + request_uri: "request_uri", + }, + } + } + }) + + const credential_issuer = "http://issuer.test" + const credential_configuration_ids = ["credential_configuration_ids"] + const grants = { authorization_code: {} } + const credential_offer = { + credential_issuer, + credential_configuration_ids, + grants, + } + vi.stubGlobal("location", { + search: `?credential_offer=${JSON.stringify(credential_offer)}`, + set href(value) { + expect(value).to.eq("http://issuer.test/authorize?client_id=client_id&request_uri=request_uri") + } + }) + + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + expect(content.innerHTML).to.eq("content") + + screen.getByText("redirected") as HTMLElement + }) + }) }) }) }) diff --git a/yarn.lock b/yarn.lock index ef3258410..1375e43db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1688,6 +1688,18 @@ resolved "https://registry.yarnpkg.com/@jsep-plugin/regex/-/regex-1.0.4.tgz#cb2fc423220fa71c609323b9ba7f7d344a755fcc" integrity sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg== +"@mswjs/interceptors@^0.39.5": + version "0.39.8" + resolved "https://npm.internal.siros.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz#0a2cf4cf26a731214ca4156273121f67dff7ebf8" + integrity sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -1728,6 +1740,24 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://npm.internal.siros.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://npm.internal.siros.org/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://npm.internal.siros.org/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + "@panva/hkdf@^1.1.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.2.1.tgz#cb0d111ef700136f4580349ff0226bf25c853f23" @@ -4985,6 +5015,11 @@ is-module@^1.0.0: resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://npm.internal.siros.org/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + is-number-object@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" @@ -5262,6 +5297,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stringify-safe@^5.0.1: + version "5.0.1" + resolved "https://npm.internal.siros.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -5631,6 +5671,15 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +nock@^14.0.10: + version "14.0.10" + resolved "https://npm.internal.siros.org/nock/-/nock-14.0.10.tgz#d6f4e73e1c6b4b7aa19d852176e68940e15cd19d" + integrity sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw== + dependencies: + "@mswjs/interceptors" "^0.39.5" + json-stringify-safe "^5.0.1" + propagate "^2.0.0" + node-gyp-build-optional-packages@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz#52b143b9dd77b7669073cbfe39e3f4118bfc603c" @@ -5769,6 +5818,11 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://npm.internal.siros.org/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -6072,6 +6126,11 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://npm.internal.siros.org/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -6799,6 +6858,11 @@ std-env@^3.5.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://npm.internal.siros.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + string-natural-compare@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" From fdad280bb430a1aec67ef4ec1b2ce32103aa5e48 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 13 Nov 2025 14:22:46 +0100 Subject: [PATCH 4/5] Add test configuration file --- config.test.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 config.test.yml diff --git a/config.test.yml b/config.test.yml new file mode 100644 index 000000000..df65a8c25 --- /dev/null +++ b/config.test.yml @@ -0,0 +1,34 @@ +--- +urls: &urls + backend_url: &backend_url "http://backend.test" + wallet_url: &wallet_url "http://wallet.test" + wallet_callback_url: &wallet_callback_url "http://wallet.test/cb" + ws_url: &ws_url "ws://ws.wallet.test" + +wallet: + environment: development + log_level: info + ws_url: *ws_url + wallet_backend_url: *backend_url + login_with_password: false + did_key_version: jwk_jcs-pub + app_version: $npm_package_version + generate_sourcemap: false + display_console: true + webauthn_rpid: localhost + openid4vci_redirect_uri: *wallet_callback_url + openid4vci_proof_type_precedence: jwt,attestation + openid4vp_san_dns_check: false + openid4vp_san_dns_check_ssl_certs: false + validate_credentials_with_trust_anchors: true + multi_language_display: true + static_public_url: *wallet_url + static_name: wwWallet + core_configuration: + wallet_url: *wallet_callback_url + wallet_callback_url: *wallet_callback_url + dpop_ttl_seconds: 60 + static_clients: + - issuer: "http://issuer.test" + client_id: "client_id" + client_secret: "client_secret" From 171a9f4928962291dc2d5237d1b767b3cce2a68c Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 27 Nov 2025 11:02:27 +0100 Subject: [PATCH 5/5] UriHandler passing cases integration testing --- .../handlers/PresentationSuccessHandler.tsx | 17 +- .../services/CoreWrappers/ClientStateStore.ts | 8 - test/hoc/UriHandler.test.tsx | 195 +++++++++++++++++- yarn.lock | 15 +- 4 files changed, 203 insertions(+), 32 deletions(-) diff --git a/src/hocs/UriHandler/handlers/PresentationSuccessHandler.tsx b/src/hocs/UriHandler/handlers/PresentationSuccessHandler.tsx index 796cdf384..5afbd8173 100644 --- a/src/hocs/UriHandler/handlers/PresentationSuccessHandler.tsx +++ b/src/hocs/UriHandler/handlers/PresentationSuccessHandler.tsx @@ -1,24 +1,21 @@ import React, { useContext } from "react"; -import { logger } from "@/logger"; import { ProtocolData, ProtocolStep } from "../resources"; -import OpenID4VCIContext from "@/context/OpenID4VCIContext"; - export type PresentationSuccessProps = { goToStep: (step: ProtocolStep, data: ProtocolData) => void; data: any; } export const PresentationSuccessHandler = ({ goToStep: _goToStep, data: _data }) => { - const { openID4VCI } = useContext(OpenID4VCIContext); + // const { openID4VCI } = useContext(OpenID4VCIContext); - const u = new URL(window.location.href); + // const u = new URL(window.location.href); - logger.debug("Handling authorization response..."); - openID4VCI.handleAuthorizationResponse(u.toString()).catch(err => { - logger.error("Error during the handling of authorization response", err); - window.history.replaceState({}, '', `${window.location.pathname}`); - }) + // logger.debug("Handling authorization response..."); + // openID4VCI.handleAuthorizationResponse(u.toString()).catch(err => { + // logger.error("Error during the handling of authorization response", err); + // window.history.replaceState({}, '', `${window.location.pathname}`); + // }) return ( <> diff --git a/src/lib/services/CoreWrappers/ClientStateStore.ts b/src/lib/services/CoreWrappers/ClientStateStore.ts index 01762773a..12e0a7029 100644 --- a/src/lib/services/CoreWrappers/ClientStateStore.ts +++ b/src/lib/services/CoreWrappers/ClientStateStore.ts @@ -82,10 +82,6 @@ export function useCoreClientStateStore(): ClientStateStore { } ) - if (!clientState) { - throw new Error("could not find client state") - } - return clientState; }, [] @@ -97,10 +93,6 @@ export function useCoreClientStateStore(): ClientStateStore { const clientState = JSON.parse(rawClientStates).find(({ state: e }) => e === state) - if (!clientState) { - throw new Error("could not find client state") - } - return clientState; }, [] diff --git a/test/hoc/UriHandler.test.tsx b/test/hoc/UriHandler.test.tsx index b2c3d891a..92c8288a7 100644 --- a/test/hoc/UriHandler.test.tsx +++ b/test/hoc/UriHandler.test.tsx @@ -1,3 +1,4 @@ +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import nock from 'nock'; import React from 'react'; import { Provider as StateProvider } from 'react-redux'; @@ -96,7 +97,7 @@ describe("UriHandler", () => { }) }) - it("renders credential offer errors", () => { + it("renders an error to invalid credential offer requests", () => { const credential_offer = {} vi.stubGlobal("location", { search: `?credential_offer=${JSON.stringify(credential_offer)}` @@ -148,7 +149,7 @@ describe("UriHandler", () => { } vi.stubGlobal("location", { search: `?credential_offer=${JSON.stringify(credential_offer)}`, - set href(value) { + set href(value: string) { expect(value).to.eq("http://issuer.test/authorize?client_id=client_id&request_uri=request_uri") } }) @@ -162,6 +163,196 @@ describe("UriHandler", () => { screen.getByText("redirected") as HTMLElement }) }) + + it("renders an error to authorization code requests with an unknown client state", () => { + nock("http://backend.test") + .persist() + .post("/proxy", () => true) + .reply(401, (_url, _body: { url: string }) => { + return { + data: { + error: "error", + error_description: "error_description", + }, + } + }) + + const code = "invalid_code" + const state = "state" + vi.stubGlobal("location", { + search: `?code=${code}&state=${state}`, + }) + + render(subject) + + return waitFor(() => { + screen.getByText("errors.oid4vci.parse_location.description.credential_request") as HTMLElement + screen.getByText("errors.oid4vci.parse_location.invalid_client") as HTMLElement + }) + }) + + it("renders an error to authorization code requests with an unknown issuer", async () => { + nock("http://backend.test") + .persist() + .post("/proxy", () => true) + .reply(401, (_url, _body: { url: string }) => { + return { + data: { + error: "error", + error_description: "error_description", + }, + } + }) + + const { publicKey, privateKey } = await generateKeyPair("ES256", { + extractable: true, + }); + const publicKeyJwk = await exportJWK(publicKey) + const privateKeyJwk = await exportJWK(privateKey) + const issuer = "unknown_issuer" + const issuer_state = "issuer_state" + const code_verifier = "code_verifier" + const code = "invalid_code" + const state = "state" + vi.stubGlobal("localStorage", { + getItem(key: string) { + if (key === "clientStates") { + return JSON.stringify([{ + issuer, + issuer_state, + state, + code_verifier, + dpopKeyPair: { + publicKey: publicKeyJwk, + privateKey: { + alg: "ES256", + ...(privateKeyJwk), + }, + }, + }]); + + } + }, + }) + vi.stubGlobal("location", { + search: `?code=${code}&state=${state}`, + }) + + render(subject) + + return waitFor(() => { + screen.getByText("errors.oid4vci.parse_location.description.credential_request") as HTMLElement + screen.getByText("errors.oid4vci.parse_location.invalid_client") as HTMLElement + }) + }) + + it.skip("stores a credential with authorization code", async () => { + nock("http://backend.test") + .persist() + .post("/proxy", () => true) + .reply(200, (_url, body: { url: string }) => { + console.log("proxy", body) + if (body.url.match(/well-known/)) { + return { + data: { + authorization_endpoint: "http://issuer.test/authorize", + token_endpoint: "http://issuer.test/token", + credential_configurations_supported: {}, + request_uri: "request_uri", + }, + } + } + }) + + const { publicKey, privateKey } = await generateKeyPair("ES256", { + extractable: true, + }); + const publicKeyJwk = await exportJWK(publicKey) + const privateKeyJwk = await exportJWK(privateKey) + const issuer = "http://issuer.test" + const issuer_state = "issuer_state" + const code_verifier = "code_verifier" + const code = "invalid_code" + const state = "state" + vi.stubGlobal("localStorage", { + getItem(key: string) { + if (key === "clientStates") { + return JSON.stringify([{ + issuer, + issuer_state, + state, + code_verifier, + dpopKeyPair: { + publicKey: publicKeyJwk, + privateKey: { + alg: "ES256", + ...(privateKeyJwk), + }, + }, + }]); + + } + }, + }) + vi.stubGlobal("location", { + search: `?code=${code}&state=${state}`, + }) + + render(subject) + + return waitFor(() => { + screen.getByText("errors.oid4vci.parse_location.description.credential_request") as HTMLElement + screen.getByText("errors.oid4vci.parse_location.invalid_client") as HTMLElement + }) + }) + + it("does nothing with presentation success", async () => { + const code = "invalid_code" + vi.stubGlobal("location", { + search: `?code=${code}`, + get href() { + return `http://vallet.test?code=${code}` + } + }) + + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + expect(content.innerHTML).to.eq("content") + }) + }) + + it.skip("does nothing with presentation success", async () => { + const client_id = "client_id"; + const response_uri = "response_uri"; + const response_type = "response_type"; + const response_mode = "response_mode"; + const nonce = "nonce"; + const state = "state"; + + const request = await new SignJWT({ + client_id, + response_uri, + response_type, + response_mode, + nonce, + state, + }) + .setProtectedHeader({ alg: "HS256" }) + .sign(new TextEncoder().encode("secret")); + + vi.stubGlobal("location", { + search: `?client_id=${client_id}&request=${request}`, + }) + + render(subject) + + return waitFor(() => { + const content = screen.getByTestId("content") as HTMLElement + expect(content.innerHTML).to.eq("content") + }) + }) }) }) }) diff --git a/yarn.lock b/yarn.lock index 1375e43db..36b250e46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2788,18 +2788,9 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== -"@wwwallet/client-core@0.1.0-alpha.9": - version "0.1.0-alpha.9" - resolved "https://registry.yarnpkg.com/@wwwallet/client-core/-/client-core-0.1.0-alpha.9.tgz#c6cb3eb9d3518bdcea0ff3160173d1e50ad8b844" - integrity sha512-8CGBIE7A1bB5SPBTHY7aDLb+aFZyzZMDFO3z1KLMKI7KX8XxptUst5eI0AqWLF8Nr58JDnizKi5LAJaaGg/HDg== - dependencies: - "@types/web" "^0.0.265" - ajv "^8.17.1" - axios "^1.11.0" - dcql "^1.0.1" - jose "^6.1.0" - ts-deepmerge "^7.0.3" - uuid "^13.0.0" +"@wwwallet/client-core@link:../../wwwallet-core/packages/client-core": + version "0.0.0" + uid "" "@zxing/text-encoding@0.9.0": version "0.9.0"