From 58a8dedb02d13ec25b7d7cde9b6fb9f66d8b935b Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 6 Dec 2024 07:28:27 -0400 Subject: [PATCH] bugfixes and presentation of credentials --- .../AuthorizationRequestHandler.tsx | 111 +++--- src/components/CredentialOfferHandler.tsx | 326 +++++++++++++----- src/components/Credentials/CredentialInfo.js | 108 +++++- .../Credentials/CredentialLayout.js | 2 +- .../Credentials/RenderCustomSvgTemplate.js | 27 +- src/components/Popups/PinPopup.tsx | 132 +++++++ src/context/ContainerContext.tsx | 97 ++++++ src/lib/http/proxy-client.ts | 45 +-- src/lib/services/credential-issuer.service.ts | 13 +- src/lib/services/credential-offer.service.ts | 5 +- .../pre-authorized-code-flow.service.ts | 13 +- src/lib/utils/getSdJwtVcMetadata.ts | 2 +- 12 files changed, 680 insertions(+), 201 deletions(-) create mode 100644 src/components/Popups/PinPopup.tsx diff --git a/src/components/AuthorizationRequestHandler.tsx b/src/components/AuthorizationRequestHandler.tsx index 7924cd89..7f4ada3a 100644 --- a/src/components/AuthorizationRequestHandler.tsx +++ b/src/components/AuthorizationRequestHandler.tsx @@ -28,7 +28,10 @@ export const AuthorizationRequestHandler = ({ const sendAuthorizationResponse = async () => { try { const { url: redirectUrl } = await container.openID4VPRelyingParty - .sendAuthorizationResponse(new Map(Object.entries(selectionMap))); + .sendAuthorizationResponse( + new Map(Object.entries(selectionMap)), + keystore, + ); if (redirectUrl) { window.location.href = redirectUrl; @@ -42,61 +45,71 @@ export const AuthorizationRequestHandler = ({ console.log('Selection map was mutated...'); sendAuthorizationResponse(); - }, [selectionMap, container]); + }, [selectionMap, container, keystore]); - if (!isLoggedIn || !container || !url || !keystore || !api || !t) { - return null; - } - - const userHandleB64u = keystore.getUserHandleB64u(); - - if (!userHandleB64u) { - return; - } - - const handleAuthorizationRequest = async () => { - try { - const result = await container.openID4VPRelyingParty.handleAuthorizationRequest(url); - - const { err } = result as unknown as { err: HandleAuthorizationRequestError }; + useEffect(() => { + if (!isLoggedIn || !container || !url || !keystore || !api || !t) { + return null; + } + + const userHandleB64u = keystore.getUserHandleB64u(); + + if (!userHandleB64u) { + return; + } - if (err !== undefined) { - switch(err) { - case HandleAuthorizationRequestError.INSUFFICIENT_CREDENTIALS: - setMessageTitle('messagePopup.insufficientCredentials.title'); - setMessageDescription('messagePopup.insufficientCredentials.description'); - break; - case HandleAuthorizationRequestError.NONTRUSTED_VERIFIER: - setMessageTitle('messagePopup.nonTrustedVerifier.title'); - setMessageDescription('messagePopup.nonTrustedVerifier.description'); - break; - default: + const handleAuthorizationRequest = async () => { + try { + const result = await container.openID4VPRelyingParty.handleAuthorizationRequest(url); + + const { err } = result as unknown as { err: HandleAuthorizationRequestError }; + + if (err !== undefined) { + switch(err) { + case HandleAuthorizationRequestError.INSUFFICIENT_CREDENTIALS: + setMessageTitle('messagePopup.insufficientCredentials.title'); + setMessageDescription('messagePopup.insufficientCredentials.description'); + break; + case HandleAuthorizationRequestError.NONTRUSTED_VERIFIER: + setMessageTitle('messagePopup.nonTrustedVerifier.title'); + setMessageDescription('messagePopup.nonTrustedVerifier.description'); + break; + default: + } + if (messageTitle) setShowMessage(true); + return; } - if (messageTitle) setShowMessage(true); - return; - } - const { - conformantCredentialsMap, - verifierDomainName, - } = result as unknown as { - conformantCredentialsMap: Map; - verifierDomainName: string; - }; + const { + conformantCredentialsMap, + verifierDomainName, + } = result as unknown as { + conformantCredentialsMap: Map; + verifierDomainName: string; + }; - const jsonedMap = Object.fromEntries(conformantCredentialsMap); - window.history.replaceState({}, '', `${window.location.pathname}`); - setVerifierDomainName(verifierDomainName); - setConformantCredentialsMap(jsonedMap); - setShowSelectCredentialsPopup(true); + const jsonedMap = Object.fromEntries(conformantCredentialsMap); + window.history.replaceState({}, '', `${window.location.pathname}`); + setVerifierDomainName(verifierDomainName); + setConformantCredentialsMap(jsonedMap); + setShowSelectCredentialsPopup(true); - } catch (error) { - console.log("Failed to handle authorization req"); - console.error(error) - } - }; + } catch (error) { + console.log("Failed to handle authorization req"); + console.error(error) + } + }; - handleAuthorizationRequest(); + handleAuthorizationRequest(); + }, [ + messageTitle, + url, + api, + container, + isLoggedIn, + keystore, + t, + ]); return ( <> diff --git a/src/components/CredentialOfferHandler.tsx b/src/components/CredentialOfferHandler.tsx index c4be5d77..e3390f94 100644 --- a/src/components/CredentialOfferHandler.tsx +++ b/src/components/CredentialOfferHandler.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import ContainerContext from '../context/ContainerContext'; import SessionContext from '../context/SessionContext'; import { useTranslation } from 'react-i18next'; @@ -7,9 +7,16 @@ import { credentialOfferFromUrl } from '../lib/services/credential-offer.service import { useApi } from '../hooks/useApi'; import { getDid, getIssuerConfiguration } from '../lib/services/credential-issuer.service'; import StatusContext from '../context/StatusContext'; -import { FIELD_PRE_AUTHORIZED_CODE, FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE, FIELD_USER_PIN_REQUIRED, generateNonceProof, getCredential, getToken } from '../lib/services/pre-authorized-code-flow.service'; -import { VerifiableCredentialFormat } from '../lib/schemas/vc'; +import { + FIELD_PRE_AUTHORIZED_CODE, + FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE, + FIELD_AUTHORIZATION_CODE_GRANT_TYPE, + generateNonceProof, + getCredential, + getToken, +} from '../lib/services/pre-authorized-code-flow.service'; import { generateRandomIdentifier } from '../lib/utils/generateRandomIdentifier'; +import PinPopup from '../components/Popups/PinPopup'; type CredentialOfferHandlerProps = { url: string; @@ -24,127 +31,254 @@ export const CredentialOfferHandler = ({ const { isOnline } = useContext(StatusContext); const api = useApi(); const [showMessage, setShowMessage] = useState(false); + const [isPinPopupOpen, setIsPinPopupOpen] = useState(false); + const [pinLength, setPinLength] = useState(4); + const [pin, setPin] = useState(''); + const [isPinRequired, setIsPinRequired] = useState(false); + const [credentialIssuer, setCredentialIssuer] = useState(''); + const [preAuthorizedCode, setPreAuthorizedCode] = useState(''); + const [accessToken, setAccessToken] = useState(''); + const [nonce, setNonce] = useState(''); + const [credentialEndpoint, setCredentialEndpoint] = useState(''); + const [credentialConfiguration, setCredentialConfiguration] = useState(); + const [failed, setFailed] = useState(false); - if (!isLoggedIn || !container || !url || !keystore || !api || !t) { - return null; - } + // Pre-authorized code flow: Get token after entering PIN + useEffect(() => { + if ( + !isPinRequired || + !pin || + pin.length !== pinLength || + !credentialIssuer || + !preAuthorizedCode + ) { + return; + } - const userHandleB64u = keystore.getUserHandleB64u(); + const fetchToken = async () => { + try { + const { accessToken, cNonce } = await getToken(credentialIssuer, preAuthorizedCode, pin); + setNonce(cNonce); + setAccessToken(accessToken); + } catch (error: unknown) { + if (error instanceof Error) { + console.log(error.message); + } + setShowMessage(true); + } + }; - if (!userHandleB64u) { - return; - } + fetchToken(); + }, [ + credentialIssuer, + preAuthorizedCode, + pin, + pinLength, + isPinRequired, + ]); - // @todo: split into separate functions to accommodate interruptions by prompts - // for PIN and confirmation of adding issuer to trusted issuers. - const handleCredentialOffer = async () => { - try { - // Get the credential offer - const offer = await credentialOfferFromUrl(url); + // Finish pre-authorized code flow after getting token + useEffect(() => { + if ( + !nonce || + !accessToken || + !credentialIssuer || + !credentialConfiguration || + failed + ) { + return; + } - if (!offer) { - throw new Error('Failed to load credential offer from URL.'); - } + const finishPreAuthorizedCodeFlow = async () => { + try { + // Generate proof + const { jws } = await generateNonceProof(keystore, nonce, credentialIssuer, 'wwWallet', credentialConfiguration.format); - const { - credential_issuer: credentialIssuer, - credential_configuration_ids: credentialOfferConfigurationIds, - grants, - } = offer; + // Get issuer did + const did = await getDid(credentialIssuer); - // Credential offer validation - if (!grants) throw new Error('Grant is missing in credential offer.'); - if (!credentialIssuer) throw new Error('Missing credential issuer in credential offer.'); - if (!credentialOfferConfigurationIds) throw new Error('Missing credential offer configuration IDs in credential offer.'); + // Get credential + const credential = await getCredential( + credentialEndpoint, + accessToken, + jws, + credentialConfiguration.format, + credentialConfiguration, + ); - // Get trusted issuers - const { data: trustedCredentialIssuers } = await api.getExternalEntity('/issuer/all', undefined, true); - const trustedCredentialIssuer = trustedCredentialIssuers[credentialIssuer]; + // Store credential + await api.post('/storage/vc', { + credentials: [{ + credentialConfigurationId: did.id, + credentialIssuerIdentifier: credentialIssuer, + credentialIdentifier: generateRandomIdentifier(32), + credential: credential.credential, + format: credentialConfiguration.format, + }], + }); - if (!trustedCredentialIssuer) { - // @todo: Prompt to add issuer to trusted issuers + window.location.href = '/'; + } catch (error: unknown) { + if (error instanceof Error) { + console.log(error.message); + } + setShowMessage(true); + setFailed(true); } + }; + + finishPreAuthorizedCodeFlow(); + }, [ + nonce, + accessToken, + credentialIssuer, + credentialEndpoint, + credentialConfiguration, + keystore, + api, + failed, + ]); - // Get issuer configuration - const { metadata } = await getIssuerConfiguration(credentialIssuer, isOnline, true); + useEffect(() => { + if ( + !isLoggedIn || + !container || + !url || + !keystore || + !api || + !t || + credentialIssuer + ) { + return; + } + + const userHandleB64u = keystore.getUserHandleB64u(); + + if (!userHandleB64u) { + return; + } + + // @todo: split into separate functions to accommodate interruptions by prompts + // for PIN and confirmation of adding issuer to trusted issuers. + const handleCredentialOffer = async () => { + try { + // Get the credential offer + const offer = await credentialOfferFromUrl(url); + + if (!offer) { + throw new Error('Failed to load credential offer from URL.'); + } - // @todo: What if there are multiple configuration IDs? - const selectedConfigurationId = credentialOfferConfigurationIds[0]; - const selectedConfiguration = metadata.credential_configurations_supported[selectedConfigurationId]; + const { + credential_issuer: issuer, + credential_configuration_ids: credentialOfferConfigurationIds, + grants, + } = offer; - if (!selectedConfiguration) { - throw new Error('Credential configuration not found'); - } + // Credential offer validation + if (!grants) throw new Error('Grant is missing in credential offer.'); + if (!issuer) throw new Error('Missing credential issuer in credential offer.'); + if (!credentialOfferConfigurationIds) throw new Error('Missing credential offer configuration IDs in credential offer.'); + + setCredentialIssuer(issuer); - /** Authorization code flow */ + // Get trusted issuers + const { data: trustedCredentialIssuers } = await api.getExternalEntity('/issuer/all', undefined, true); + const trustedCredentialIssuer = trustedCredentialIssuers[issuer]; - if ('authorization_code' in grants) { - // @todo: May not be needed if (!trustedCredentialIssuer) { - throw new Error('Issuing a credential with authorization code flow only works with trusted issuers.'); + // @todo: Prompt to add issuer to trusted issuers } - // Get issuer state - const issuer_state = grants.authorization_code?.issuer_state; + // Get issuer configuration + const issuerConfiguration = await getIssuerConfiguration( + issuer.endsWith('/') ? issuer.slice(0, -1) : issuer, + isOnline, + true + ); + + const metadata = 'metadata' in issuerConfiguration + ? issuerConfiguration.metadata + : issuerConfiguration; + + setCredentialEndpoint(metadata.credential_endpoint); - // Get redirect URL - const { url: redirectUrl } = await container.openID4VCIClients[credentialIssuer] - .generateAuthorizationRequest(selectedConfigurationId, userHandleB64u, issuer_state); + // @todo: What if there are multiple configuration IDs? + const selectedConfigurationId = credentialOfferConfigurationIds[0]; + const selectedConfiguration = metadata.credential_configurations_supported[selectedConfigurationId]; - // Possibly redirect - if (redirectUrl) { - window.location.href = redirectUrl; - return; + if (!selectedConfiguration) { + throw new Error('Credential configuration not found'); } - } - /** Pre-authorized code flow */ + setCredentialConfiguration(selectedConfiguration); + + /** Authorization code flow */ + + if (FIELD_AUTHORIZATION_CODE_GRANT_TYPE in grants) { + // @todo: May not be needed + if (!trustedCredentialIssuer) { + throw new Error('Issuing a credential with authorization code flow only works with trusted issuers.'); + } - if (FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE in grants) { - const preAuthorizedCodeGrant = grants[FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE]; - const preAuthorizedCode = preAuthorizedCodeGrant[FIELD_PRE_AUTHORIZED_CODE]; + // Get issuer state + const issuer_state = grants.authorization_code?.issuer_state; - if (preAuthorizedCodeGrant[FIELD_USER_PIN_REQUIRED]) { - // @todo: Prompt for PIN + // Get redirect URL + const { url: redirectUrl } = await container.openID4VCIClients[issuer] + .generateAuthorizationRequest(selectedConfigurationId, userHandleB64u, issuer_state); + + // Possibly redirect + if (redirectUrl) { + window.location.href = redirectUrl; + return; + } } - // Get token - // @todo: Pass PIN if applicable - const { accessToken, cNonce } = await getToken(credentialIssuer, preAuthorizedCode); + /** Pre-authorized code flow */ - // Generate proof - const { jws } = await generateNonceProof(keystore, cNonce, credentialIssuer, 'wwWallet'); + if (FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE in grants) { + const preAuthorizedCodeGrant = grants[FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE]; + const code = preAuthorizedCodeGrant[FIELD_PRE_AUTHORIZED_CODE]; - // Get issuer did - const did = await getDid(credentialIssuer); + setPreAuthorizedCode(code); - // Get credential - const credential = await getCredential( - metadata.credential_endpoint, - accessToken, - jws, - VerifiableCredentialFormat.JWT_VC_JSON, // @todo: retrieve dynamically - selectedConfiguration, - ); + if ('tx_code' in preAuthorizedCodeGrant && preAuthorizedCodeGrant.tx_code.description === 'PIN') { + setIsPinRequired(true); + setPinLength(preAuthorizedCodeGrant.tx_code.length); + setIsPinPopupOpen(true); + return; + } - // Store credential - await api.post('/storage/vc', { - credentialConfigurationId: did.id, - credentialIssuerIdentifier: credentialIssuer, - credentialIdentifier: generateRandomIdentifier(32), - credential: credential.credential, - format: VerifiableCredentialFormat.JWT_VC_JSON, // @todo: retrieve dynamically - }); - } - } catch (error: unknown) { - if (error instanceof Error) { - console.log(error.message); + const { accessToken, cNonce } = await getToken(issuer, code); + setAccessToken(accessToken); + setNonce(cNonce); + } + } catch (error: unknown) { + if (error instanceof Error) { + console.log(error.message); + } + setShowMessage(true); } - setShowMessage(true); - } - }; + }; + + handleCredentialOffer(); + }, [ + container, + isLoggedIn, + t, + api, + isOnline, + keystore, + url, + credentialIssuer + ]); - handleCredentialOffer(); + useEffect(() => { + if (pin.length !== pinLength) { + return; + } + }, [pinLength, pin]); return ( <> @@ -158,6 +292,14 @@ export const CredentialOfferHandler = ({ onClose={() => setShowMessage(false)} /> } + { + setPin(pin); + }} + /> ); }; diff --git a/src/components/Credentials/CredentialInfo.js b/src/components/Credentials/CredentialInfo.js index 98a62e67..665b7d6e 100644 --- a/src/components/Credentials/CredentialInfo.js +++ b/src/components/Credentials/CredentialInfo.js @@ -7,6 +7,11 @@ import { GiLevelEndFlag } from 'react-icons/gi'; import { formatDate } from '../../functions/DateFormat'; import ContainerContext from '../../context/ContainerContext'; import useScreenType from '../../hooks/useScreenType'; +import StatusContext from '../../context/StatusContext'; +import { VerifiableCredentialFormat } from '../../lib/schemas/vc'; +import { CredentialFormat } from '../../functions/parseSdJwtCredential'; + +const locale = 'en-US'; const getFieldIcon = (fieldName) => { switch (fieldName) { @@ -33,10 +38,9 @@ const getFieldIcon = (fieldName) => { }; const renderRow = (fieldName, label, fieldValue, screenType) => { - if (fieldValue) { return ( - +
{screenType !== 'mobile' && getFieldIcon(fieldName)} @@ -52,30 +56,93 @@ const renderRow = (fieldName, label, fieldValue, screenType) => { }; const CredentialInfo = ({ credential, mainClassName = "text-sm lg:text-base w-full" }) => { - + const { isOnline } = useContext(StatusContext); const [parsedCredential, setParsedCredential] = useState(null); + const [credentialFormat, setCredentialFormat] = useState(''); + const [credentialSubjectRows, setCredentialSubjectRows] = useState([]); const container = useContext(ContainerContext); const screenType = useScreenType(); useEffect(() => { - if (container) { - container.credentialParserRegistry.parse(credential).then((c) => { - if ('error' in c) { - return; - } - setParsedCredential(c.beautifiedForm); - }); + if (!container || !credential) { + return; + } + + const parseCredential = async () => { + const c = await container.credentialParserRegistry.parse(credential); + + if ('error' in c) { + return; + } + + setParsedCredential(c.beautifiedForm); + + let iss = c.beautifiedForm.iss; + + // @todo: make less specific for SURF agent + if (iss.startsWith('did:web:')) { + const issDomain = iss.split(':').pop(); + const domainParts = issDomain.split('.'); + const subDomain = domainParts.shift(); + const domain = domainParts.join('.'); + iss = `https://agent.${domain}/${subDomain}`; + } + + const metadataResponse = await container.openID4VCIHelper.getCredentialIssuerMetadata(isOnline, iss, true); + + if (!metadataResponse) { + return { error: 'No metadata response' }; + } + + const { metadata } = metadataResponse; + + if (!metadata) { + return; + } + + const supportedCredentialConfigurations = Object.values(metadata.credential_configurations_supported); + + if (! supportedCredentialConfigurations.every(configuration => configuration.format === VerifiableCredentialFormat.JWT_VC_JSON.toString())) { + return; + } + + setCredentialFormat(VerifiableCredentialFormat.JWT_VC_JSON.toString()); + + const rows = supportedCredentialConfigurations[0].credential_definition.credentialSubject + ? Object.entries(supportedCredentialConfigurations[0].credential_definition.credentialSubject) + .reduce( + (previous, [key, subject]) => { + const display = subject.display.find(d => d.locale === locale) || subject.display[0]; + return [ + ...previous, + { + name: key, + label: display.name, + value: c.beautifiedForm.credentialSubject[key] || '', + }, + ] + }, + [], + ) + : []; + + setCredentialSubjectRows(rows); } - }, [credential, container]); + parseCredential(); + }, [ + credential, + container, + isOnline + ]); return (
- {parsedCredential && ( + {!credentialFormat && parsedCredential && ( <> - {renderRow('expdate', 'Expiration', formatDate(new Date(parsedCredential?.exp * 1000).toISOString()), screenType)} + {renderRow('expdate', 'Expiration', parsedCredential?.exp ? formatDate(new Date(parsedCredential?.exp * 1000).toISOString()) : 'never', screenType)} {renderRow('familyName', 'Family Name', parsedCredential?.family_name, screenType)} {renderRow('firstName', 'Given Name', parsedCredential?.given_name, screenType)} {renderRow('id', 'Personal ID', parsedCredential?.personal_identifier, screenType)} @@ -88,6 +155,21 @@ const CredentialInfo = ({ credential, mainClassName = "text-sm lg:text-base w-fu {renderRow('id', 'Document Number', parsedCredential?.document_number, screenType)} )} + { + credentialFormat === CredentialFormat.JWT_VC_JSON.toString() && + parsedCredential && + ( + <> + {renderRow('expdate', 'Expiration', parsedCredential?.exp ? formatDate(new Date(parsedCredential?.exp * 1000).toISOString()) : 'never', screenType)} + {credentialSubjectRows.map(row => renderRow(row.name, row.label, row.value, screenType))} + {/* @todo: make dynamic using schema from credential context */} + {renderRow('name', 'Name', parsedCredential?.credentialSubject?.achievement?.name, screenType)} + {renderRow('description', 'Description', parsedCredential?.credentialSubject?.achievement?.description, screenType)} + {renderRow('id', 'ID', parsedCredential?.credentialSubject?.achievement?.id, screenType)} + {renderRow('criteria', 'Criteria', parsedCredential?.credentialSubject?.achievement?.criteria?.narrative, screenType)} + + ) + }
diff --git a/src/components/Credentials/CredentialLayout.js b/src/components/Credentials/CredentialLayout.js index 04f16683..e974313b 100644 --- a/src/components/Credentials/CredentialLayout.js +++ b/src/components/Credentials/CredentialLayout.js @@ -61,7 +61,7 @@ const CredentialLayout = ({ children, title = null }) => { if ('error' in c) { return; } - setIsExpired(CheckExpired(c.beautifiedForm.expiry_date)) + setIsExpired(c.beautifiedForm.expiry_date ? CheckExpired(c.beautifiedForm.expiry_date) : false) setCredentialFriendlyName(c.credentialFriendlyName); }); }, [vcEntity, container]); diff --git a/src/components/Credentials/RenderCustomSvgTemplate.js b/src/components/Credentials/RenderCustomSvgTemplate.js index 64dfa96d..e6377464 100644 --- a/src/components/Credentials/RenderCustomSvgTemplate.js +++ b/src/components/Credentials/RenderCustomSvgTemplate.js @@ -1,7 +1,21 @@ import axios from 'axios'; import jsonpointer from 'jsonpointer'; import { formatDate } from '../../functions/DateFormat'; -import customTemplate from '../../assets/images/custom_template.svg'; + +const svgContent = ` + + + + + {{backgroundImageBase64}} + {{logoBase64}} + + {{name}} + {{description}} + {{/expiry_date}} + +`; async function getBase64Image(url) { if (!url) return null; @@ -23,15 +37,10 @@ async function getBase64Image(url) { const renderCustomSvgTemplate = async ({ beautifiedForm, name, description, logoURL, logoAltText, backgroundColor, textColor, backgroundImageURL }) => { try { - const response = await fetch(customTemplate); - if (!response.ok) throw new Error("Failed to fetch SVG template"); - - let svgContent = await response.text(); - const backgroundImageBase64 = await getBase64Image(backgroundImageURL); const logoBase64 = await getBase64Image(logoURL); - svgContent = svgContent + let content = svgContent .replace(/{{backgroundColor}}/g, backgroundColor) .replace( /{{backgroundImageBase64}}/g, @@ -50,9 +59,9 @@ const renderCustomSvgTemplate = async ({ beautifiedForm, name, description, logo .replace(/{{description}}/g, description); const expiryDate = jsonpointer.get(beautifiedForm, "/expiry_date"); - svgContent = svgContent.replace(/{{\/expiry_date}}/g, expiryDate ? `Expiry Date: ${formatDate(expiryDate, 'date')}` : ''); + content = content.replace(/{{\/expiry_date}}/g, expiryDate ? `Expiry Date: ${formatDate(expiryDate, 'date')}` : ''); - return `data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}`; + return `data:image/svg+xml;utf8,${encodeURIComponent(content)}`; } catch (error) { console.error("Error rendering SVG template", error); return null; diff --git a/src/components/Popups/PinPopup.tsx b/src/components/Popups/PinPopup.tsx new file mode 100644 index 00000000..f1112ba1 --- /dev/null +++ b/src/components/Popups/PinPopup.tsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { FaLock } from "react-icons/fa"; +import { useTranslation } from 'react-i18next'; +import Button from '../Buttons/Button'; +import PopupLayout from './PopupLayout'; + +interface PinInputProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + inputsCount: number; + onCancel?: () => void; + onSubmit?: (pin: string) => void; +} + +const PinInput = ({ + isOpen, + setIsOpen, + inputsCount, + onCancel, + onSubmit, +}: PinInputProps) => { + const [errMessage, setErrMessage] = useState(''); + const [pin, setPin] = useState(Array(inputsCount).fill('')); + const { t } = useTranslation(); + + const inputRefs = useMemo(() => Array.from({ length: inputsCount }, () => React.createRef()), [inputsCount]); + + useEffect(() => { + if (inputRefs[0]?.current) { + inputRefs[0].current.focus(); + } + }, [inputRefs]); + + const handleCancel = () => { + setIsOpen(false); + onCancel?.(); + }; + + const handleSubmit = async () => { + const userPin = pin.join(''); + try { + setIsOpen(false); + onSubmit?.(userPin); + } catch (err) { + setErrMessage(`${t('PinInputPopup.errMessage')}`); + } + }; + + const handleInputChange = (index: number, value: string) => { + setErrMessage(''); + if (/^\d*$/.test(value) && value.length <= 1) { + const newPin = [...pin]; + newPin[index] = value; + + setPin(newPin); + + if (value !== '' && index < inputsCount - 1) { + const nextInput = inputRefs[index + 1].current; + nextInput?.focus(); + } + } + }; + + const handleInputKeyDown = (index: number, event: React.KeyboardEvent) => { + setErrMessage(''); + if (event.key === 'Backspace' && pin[index] === '' && index > 0) { + inputRefs[index - 1].current?.focus(); + const newPin = [...pin]; + newPin[index - 1] = ''; + setPin(newPin); + } + }; + + const handleInputPaste = (pastedValue: string) => { + setErrMessage(''); + if (/^\d+$/.test(pastedValue)) { + const newPin = pastedValue.split('').slice(0, inputsCount); + while (newPin.length < inputsCount) { + newPin.push(''); + } + setPin(newPin); + inputRefs[newPin.length - 1]?.current?.focus(); + } + }; + + if (!isOpen) { + return null; + } + + return ( + +

+ + {t('PinInputPopup.title')} +

+
+

+ {t('PinInputPopup.description')} +

+ + {errMessage && ( +

{errMessage}

+ )} +
+ {pin.map((digit, index) => ( + handleInputChange(index, e.target.value)} + onKeyDown={(e) => handleInputKeyDown(index, e)} + onPaste={(e) => handleInputPaste(e.clipboardData.getData('Text'))} + className="w-10 px-3 mx-1 my-2 py-2 dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-500 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500" + ref={inputRefs[index]} + autoFocus={index === 0} + /> + ))} +
+ +
+ + +
+
+ ); +}; + +export default PinInput; diff --git a/src/context/ContainerContext.tsx b/src/context/ContainerContext.tsx index ef8acf31..8f5e984d 100644 --- a/src/context/ContainerContext.tsx +++ b/src/context/ContainerContext.tsx @@ -25,6 +25,8 @@ import renderCustomSvgTemplate from "../components/Credentials/RenderCustomSvgTe import StatusContext from "./StatusContext"; import { getSdJwtVcMetadata } from "../lib/utils/getSdJwtVcMetadata"; import { CredentialBatchHelper } from "../lib/services/CredentialBatchHelper"; +import { parseJwtVcJsonCredential } from "../functions/parseJwtVcJsonCredential"; +import { VerifiableCredentialFormat } from "../lib/schemas/vc"; export type ContainerContextValue = { httpProxy: IHttpProxy, @@ -202,6 +204,101 @@ export const ContainerContextProvider = ({ children }) => { }, }); + + credentialParserRegistry.addParser({ + async parse(rawCredential) { + + if (typeof rawCredential != 'string') { + return { error: "rawCredential not of type 'string'" }; + + } + + const result = await parseJwtVcJsonCredential(rawCredential); + + if ('error' in result) { + return { error: "Failed to parse jwt_vc_json" }; + } + + let iss = result.beautifiedForm.iss; + + // @todo: make less specific for SURF agent + if (iss.startsWith('did:web:')) { + const issDomain = iss.split(':').pop(); + const domainParts = issDomain.split('.'); + const subDomain = domainParts.shift(); + const domain = domainParts.join('.'); + iss = `https://agent.${domain}/${subDomain}`; + } + + const metadataResponse = await cont.resolve('OpenID4VCIHelper').getCredentialIssuerMetadata(isOnline, iss, shouldUseCache); + + if (!metadataResponse) { + return { error: 'No metadata response' }; + } + + const { metadata } = metadataResponse; + + if (!metadata) { + return { error: 'No metadata' }; + } + + const supportedCredentialConfigurations = Object.values(metadata.credential_configurations_supported); + + if (! supportedCredentialConfigurations.every(configuration => configuration.format === VerifiableCredentialFormat.JWT_VC_JSON.toString())) { + return { error: 'Credential parser can only parse jwt_vc_json format' }; + } + + // @todo: make more dynamic using schema from credential context + const isOpenBadgeCredential = result.beautifiedForm.type.includes('OpenBadgeCredential'); + + const firstCredentialConfiguration = Object.values(metadata.credential_configurations_supported)[0]; + const credentialFriendlyName = isOpenBadgeCredential + ? result.beautifiedForm.credentialSubject.achievement.name + : firstCredentialConfiguration?.display?.[0]?.name || 'Credential'; + + if (firstCredentialConfiguration) { + const display = firstCredentialConfiguration?.display?.[0] || {}; + let description = isOpenBadgeCredential + ? result.beautifiedForm.credentialSubject.achievement.description + : display?.description || ''; + let logoURL = isOpenBadgeCredential + ? result.beautifiedForm.credentialSubject.achievement.image.id + : display?.logo?.uri || null; + let logoAltText = display?.logo?.alt_text || 'Credential logo'; + let backgroundColor = display?.background_color || '#808080'; + let textColor = display?.text_color || '#000000'; + let backgroundImageURL = display?.background_image?.uri || null; + + const svgCustomContent = await renderCustomSvgTemplate({ + beautifiedForm: result.beautifiedForm, + name: credentialFriendlyName, + description, + logoURL, + logoAltText, + backgroundColor, + textColor, + backgroundImageURL, + }); + + return { + beautifiedForm: result.beautifiedForm, + credentialImage: { + credentialImageURL: svgCustomContent || defaultCredentialImage, + }, + credentialFriendlyName, + } + } + + return { + beautifiedForm: result.beautifiedForm, + credentialImage: { + credentialImageURL: defaultCredentialImage, + }, + credentialFriendlyName, + } + + }, + }); cont.register('OpenID4VPRelyingParty', OpenID4VPRelyingParty, cont.resolve('OpenID4VPRelyingPartyStateRepository'), diff --git a/src/lib/http/proxy-client.ts b/src/lib/http/proxy-client.ts index a95a53b8..9b98f63c 100644 --- a/src/lib/http/proxy-client.ts +++ b/src/lib/http/proxy-client.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { BACKEND_URL } from '../../config'; import { appTokenAuthorizationHeader } from './authorization'; @@ -17,21 +18,17 @@ interface ProxyResponseError { export const get = async (url: string, headers: any): Promise => { try { - const response = await fetch(API_BASE_URL, { - body: JSON.stringify({ - headers: headers, - url: url, - method: 'get', - }), + const response = await axios.post(API_BASE_URL, { + headers: headers, + url: url, + method: 'get', + }, { + timeout: 2500, headers: { - Authorization: appTokenAuthorizationHeader(), - }, - signal: AbortSignal.timeout(2500), + Authorization: appTokenAuthorizationHeader() + } }); - - const { data } = await response.json(); - - return data; + return response.data; } catch(err) { return null; @@ -40,22 +37,18 @@ export const get = async (url: string, headers: any): Promise export const post = async (url: string, body: any, headers: any): Promise => { try { - const response = await fetch(API_BASE_URL, { - body: JSON.stringify({ - headers: headers, - url: url, - method: 'post', - data: body, - }), + const response = await axios.post(API_BASE_URL, { + headers: headers, + url: url, + method: 'post', + data: body, + }, { + timeout: 2500, headers: { Authorization: appTokenAuthorizationHeader(), - }, - signal: AbortSignal.timeout(2500), + } }); - - const { data } = await response.json(); - - return data; + return response.data; } catch(err) { return { diff --git a/src/lib/services/credential-issuer.service.ts b/src/lib/services/credential-issuer.service.ts index 6c9fbd89..b6bb457e 100644 --- a/src/lib/services/credential-issuer.service.ts +++ b/src/lib/services/credential-issuer.service.ts @@ -1,7 +1,10 @@ import ProxyClient from '../http/proxy-client'; import LocalApiClient from '../http/local-api-client'; import { OpenidAuthorizationServerMetadata, OpenidAuthorizationServerMetadataSchema } from '../schemas/OpenidAuthorizationServerMetadataSchema'; -import { OpenidCredentialIssuerMetadata, OpenidCredentialIssuerMetadataSchema } from '../schemas/OpenidCredentialIssuerMetadataSchema'; +import { + OpenidCredentialIssuerMetadata, + OpenidCredentialIssuerMetadataSchema, +} from '../schemas/OpenidCredentialIssuerMetadataSchema'; const PATH_OPEN_ID_CREDENTIAL_ISSUER = '/.well-known/openid-credential-issuer'; const PATH_OAUTH_AUTHORIZATION_SERVER = '/.well-known/oauth-authorization-server'; @@ -16,7 +19,7 @@ export const getIssuerConfiguration = async ( credentialIssuer: string, isOnline: boolean = true, useCache: boolean = false, -): Promise<{ metadata: OpenidCredentialIssuerMetadata }> => { +): Promise<{ metadata: OpenidCredentialIssuerMetadata } | OpenidCredentialIssuerMetadata> => { const path = `${credentialIssuer}${PATH_OPEN_ID_CREDENTIAL_ISSUER}`; if (!isOnline || useCache) { @@ -25,11 +28,13 @@ export const getIssuerConfiguration = async ( } try { - const { data } = await ProxyClient.get(path, { + const response = await ProxyClient.get(path, { 'Cache-Control': 'no-cache', }); - const parsedData = OpenidCredentialIssuerMetadataSchema.parse(data); + const data = response.data || response; + // const parsedData = OpenidCredentialIssuerMetadataSchema.parse(data); + const parsedData = data; await LocalApiClient.post(path, path, parsedData); return { metadata: parsedData }; diff --git a/src/lib/services/credential-offer.service.ts b/src/lib/services/credential-offer.service.ts index 841fe69b..ddeb1a43 100644 --- a/src/lib/services/credential-offer.service.ts +++ b/src/lib/services/credential-offer.service.ts @@ -1,5 +1,4 @@ import { CredentialOfferSchema } from '../schemas/CredentialOfferSchema'; -import ProxyClient from '../http/proxy-client'; const PARAM_CREDENTIAL_OFFER = 'credential_offer'; const PARAM_CREDENTIAL_OFFER_URI = 'credential_offer_uri'; @@ -11,8 +10,8 @@ export const credentialOfferFromUrl = async (url: string) => { return CredentialOfferSchema.parse(JSON.parse(parsedUrl.searchParams.get(PARAM_CREDENTIAL_OFFER))); } try { - const { data } = await ProxyClient.get(parsedUrl.searchParams.get(PARAM_CREDENTIAL_OFFER_URI), {}); - return data; + const response = await fetch(parsedUrl.searchParams.get(PARAM_CREDENTIAL_OFFER_URI), {}); + return await response.json(); } catch (err) { console.error(err); return; diff --git a/src/lib/services/pre-authorized-code-flow.service.ts b/src/lib/services/pre-authorized-code-flow.service.ts index cfcf7998..3ab34409 100644 --- a/src/lib/services/pre-authorized-code-flow.service.ts +++ b/src/lib/services/pre-authorized-code-flow.service.ts @@ -1,10 +1,11 @@ import { LocalStorageKeystore } from '../../services/LocalStorageKeystore'; import type { ProxyResponseData } from '../http/proxy-client'; import ProxyClient from '../http/proxy-client'; +import { VerifiableCredentialFormat } from '../schemas/vc'; export const FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:pre-authorized_code'; +export const FIELD_AUTHORIZATION_CODE_GRANT_TYPE = 'authorization_code'; export const FIELD_PRE_AUTHORIZED_CODE = 'pre-authorized_code'; -export const FIELD_USER_PIN_REQUIRED = 'user_pin_required'; const PATH_TOKEN = '/token'; interface TokenResponse { @@ -13,12 +14,17 @@ interface TokenResponse { } // @todo: Add PIN support -export const getToken = async (credentialIssuer: string, preAuthorizedCode: string): Promise => { +export const getToken = async ( + credentialIssuer: string, + preAuthorizedCode: string, + pin: string = '', +): Promise => { const response = await ProxyClient.post( `${credentialIssuer}${PATH_TOKEN}`, { grant_type: FIELD_PRE_AUTHORIZED_CODE_GRANT_TYPE, 'pre-authorized_code': preAuthorizedCode, + user_pin: pin || undefined, }, { 'Content-Type': 'application/json', @@ -43,8 +49,9 @@ export const generateNonceProof = async ( nonce: string, audience: string, issuer: string, + format?: VerifiableCredentialFormat ): Promise<{ jws: string }> => { - const [{ proof_jwts }] = await keystore.generateOpenid4vciProofs([{ nonce, audience, issuer }]); + const [{ proof_jwts }] = await keystore.generateOpenid4vciProofs([{ nonce, audience, issuer, format }]); return { jws: proof_jwts[0] }; }; diff --git a/src/lib/utils/getSdJwtVcMetadata.ts b/src/lib/utils/getSdJwtVcMetadata.ts index 9ca29f95..6dd24d79 100644 --- a/src/lib/utils/getSdJwtVcMetadata.ts +++ b/src/lib/utils/getSdJwtVcMetadata.ts @@ -7,7 +7,7 @@ export async function getSdJwtVcMetadata(credential: string): Promise<{ credenti const credentialHeader = JSON.parse(new TextDecoder().decode(fromBase64(credential.split('.')[0] as string))); const credentialPayload = JSON.parse(new TextDecoder().decode(fromBase64(credential.split('.')[1] as string))); - if (credentialHeader.vctm) { + if (Array.isArray(credentialHeader.vctm)) { const sdjwtvcMetadataDocument = credentialHeader.vctm.map((encodedMetadataDocument: string) => JSON.parse(new TextDecoder().decode(fromBase64(encodedMetadataDocument))) ).filter(((metadataDocument) => metadataDocument.vct === credentialPayload.vct))[0];