diff --git a/apps/easypid/src/app/(app)/_layout.tsx b/apps/easypid/src/app/(app)/_layout.tsx index d5084138..ac4fd2d5 100644 --- a/apps/easypid/src/app/(app)/_layout.tsx +++ b/apps/easypid/src/app/(app)/_layout.tsx @@ -10,7 +10,6 @@ import { type CredentialDataHandlerOptions, DeeplinkHandler, useHaptics } from ' import { HeroIcons, IconContainer } from '@package/ui' import { useEffect, useState } from 'react' import { useTheme } from 'tamagui' -import { WithBackgroundPidRefresh } from '../../features/pid/WithBackPidRefresh' const jsonRecordIds = [activityStorage.recordId] @@ -99,48 +98,46 @@ export default function AppLayout() { return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ) diff --git a/apps/easypid/src/features/pid/WithBackPidRefresh.tsx b/apps/easypid/src/features/pid/WithBackPidRefresh.tsx deleted file mode 100644 index ff00fcaa..00000000 --- a/apps/easypid/src/features/pid/WithBackPidRefresh.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { PropsWithChildren } from 'react' -import { useBackgroundPidRefresh } from '../../hooks/useBackgroundPidRefresh' - -export function WithBackgroundPidRefresh({ children }: PropsWithChildren) { - // Refresh PID once it reaches 1 - // useBackgroundPidRefresh(1) - - return children -} diff --git a/apps/easypid/src/hooks/useBackgroundPidRefresh.ts b/apps/easypid/src/hooks/useBackgroundPidRefresh.ts deleted file mode 100644 index 5b8725d6..00000000 --- a/apps/easypid/src/hooks/useBackgroundPidRefresh.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { MdocRecord, SdJwtVcRecord } from '@credo-ts/core' -import { getBatchCredentialMetadata } from '@package/agent/src/openid4vc/batchMetadata' -import { useHasInternetConnection } from '@package/app' -import { useEffect, useMemo, useState } from 'react' -import { type AppAgent, useAppAgent } from '../agent' -import { useShouldUseCloudHsm } from '../features/onboarding/useShouldUseCloudHsm' -import { RefreshPidUseCase } from '../use-cases/RefreshPidUseCase.ts' -import { usePidCredential } from './usePidCredential' - -async function refreshPid({ agent, sdJwt, mdoc }: { agent: AppAgent; sdJwt?: SdJwtVcRecord; mdoc?: MdocRecord }) { - console.log('refreshing PID') - const useCase = await RefreshPidUseCase.initialize({ - agent, - }) - - await useCase.retrieveCredentialsUsingExistingRecords({ - sdJwt, - mdoc, - }) -} - -export function useBackgroundPidRefresh(batchThreshold: number) { - const { sdJwt, mdoc } = usePidCredential() - const [isRefreshing, setIsRefreshing] = useState(false) - const hasInternet = useHasInternetConnection() - const shouldUseCloudHsm = useShouldUseCloudHsm() - const { agent } = useAppAgent() - - const { shouldRefreshMdoc, shouldRefreshSdJwt } = useMemo(() => { - if (!shouldUseCloudHsm) return {} - - let shouldRefreshSdJwt = false - if (sdJwt) { - const sdJwtBatch = getBatchCredentialMetadata(sdJwt.record) - if (sdJwtBatch) { - shouldRefreshSdJwt = sdJwtBatch.additionalCredentials.length <= batchThreshold - } - } - - let shouldRefreshMdoc = false - if (mdoc) { - const mdocBatch = getBatchCredentialMetadata(mdoc.record) - if (mdocBatch) { - shouldRefreshMdoc = mdocBatch.additionalCredentials.length <= batchThreshold - } - } - - return { - shouldRefreshSdJwt, - shouldRefreshMdoc, - } - }, [sdJwt, mdoc, batchThreshold, shouldUseCloudHsm]) - - useEffect(() => { - if (isRefreshing || !hasInternet || !shouldUseCloudHsm) return - - if (shouldRefreshMdoc || shouldRefreshSdJwt) { - setIsRefreshing(true) - - refreshPid({ - agent, - sdJwt: shouldRefreshSdJwt ? sdJwt?.record : undefined, - mdoc: shouldRefreshMdoc ? mdoc?.record : undefined, - }).finally(() => setIsRefreshing(false)) - } - }, [shouldRefreshMdoc, shouldRefreshSdJwt, hasInternet, agent, isRefreshing, mdoc, sdJwt, shouldUseCloudHsm]) -} diff --git a/apps/easypid/src/use-cases/ReceivePidUseCaseFlow.ts b/apps/easypid/src/use-cases/ReceivePidUseCaseFlow.ts index d6974bc4..4e4022b2 100644 --- a/apps/easypid/src/use-cases/ReceivePidUseCaseFlow.ts +++ b/apps/easypid/src/use-cases/ReceivePidUseCaseFlow.ts @@ -1,13 +1,13 @@ import { AusweisAuthFlow, type AusweisAuthFlowOptions, sendCommand } from '@animo-id/expo-ausweis-sdk' import type { MdocRecord } from '@credo-ts/core' import type { AppAgent } from '@easypid/agent' -import { - type OpenId4VciRequestTokenResponse, - type OpenId4VciResolvedCredentialOffer, - type OpenId4VciResolvedOauth2RedirectAuthorizationRequest, - type SdJwtVcRecord, - acquireAuthorizationCodeAccessToken, +import type { + OpenId4VciRequestTokenResponse, + OpenId4VciResolvedCredentialOffer, + OpenId4VciResolvedOauth2RedirectAuthorizationRequest, + SdJwtVcRecord, } from '@package/agent' +import { acquireAuthorizationCodeAccessToken } from '@package/agent/src/invitation/handler' export interface ReceivePidUseCaseFlowOptions extends Pick { diff --git a/apps/easypid/src/use-cases/RefreshPidUseCase.ts.ts b/apps/easypid/src/use-cases/RefreshPidUseCase.ts similarity index 92% rename from apps/easypid/src/use-cases/RefreshPidUseCase.ts.ts rename to apps/easypid/src/use-cases/RefreshPidUseCase.ts index 46f545f0..8ac91059 100644 --- a/apps/easypid/src/use-cases/RefreshPidUseCase.ts.ts +++ b/apps/easypid/src/use-cases/RefreshPidUseCase.ts @@ -1,17 +1,18 @@ import { ClaimFormat, MdocRecord, getJwkFromJson } from '@credo-ts/core' +import { SdJwtVcRecord } from '@credo-ts/core' import type { AppAgent } from '@easypid/agent' +import type { OpenId4VciRequestTokenResponse, OpenId4VciResolvedCredentialOffer } from '@package/agent' import { - type OpenId4VciRequestTokenResponse, - type OpenId4VciResolvedCredentialOffer, - SdJwtVcRecord, acquireRefreshTokenAccessToken, - getRefreshCredentialMetadata, receiveCredentialFromOpenId4VciOffer, resolveOpenId4VciOffer, - setRefreshCredentialMetadata, - updateCredential, -} from '@package/agent' +} from '@package/agent/src/invitation/handler' import { getBatchCredentialMetadata, setBatchCredentialMetadata } from '@package/agent/src/openid4vc/batchMetadata' +import { + getRefreshCredentialMetadata, + setRefreshCredentialMetadata, +} from '@package/agent/src/openid4vc/refreshMetadata' +import { updateCredential } from '@package/agent/src/storage/credential' import { pidSchemes } from '../constants' import { ReceivePidUseCaseFlow } from './ReceivePidUseCaseFlow' import { C_PRIME_SD_JWT_MDOC_OFFER } from './bdrPidIssuerOffers' @@ -55,9 +56,11 @@ export class RefreshPidUseCase { public async retrieveCredentialsUsingExistingRecords({ sdJwt, mdoc, + batchSize = 2, }: { sdJwt?: SdJwtVcRecord mdoc?: MdocRecord + batchSize?: number }) { const existingRefreshMetadata = (sdJwt ? getRefreshCredentialMetadata(sdJwt) : undefined) ?? @@ -94,7 +97,7 @@ export class RefreshPidUseCase { resolvedCredentialOffer: this.resolvedCredentialOffer, credentialConfigurationIdsToRequest, clientId: RefreshPidUseCase.CLIENT_ID, - requestBatch: 2, + requestBatch: batchSize, pidSchemes, }) diff --git a/packages/agent/src/batch.ts b/packages/agent/src/batch.ts index cf921916..cec7c4cf 100644 --- a/packages/agent/src/batch.ts +++ b/packages/agent/src/batch.ts @@ -1,9 +1,31 @@ import { Mdoc, MdocRecord, SdJwtVcRecord, W3cCredentialRecord } from '@credo-ts/core' -import type { EitherAgent } from './agent' +import type { AppAgent } from '../../../apps/easypid/src/agent' +import { RefreshPidUseCase } from '../../../apps/easypid/src/use-cases/RefreshPidUseCase' +import type { EasyPIDAppAgent, EitherAgent } from './agent' +import { getCredentialCategoryMetadata } from './credentialCategoryMetadata' import { decodeW3cCredential } from './format/credentialEncoding' import { getBatchCredentialMetadata } from './openid4vc/batchMetadata' +import { getRefreshCredentialMetadata } from './openid4vc/refreshMetadata' import { updateCredential } from './storage' +export async function refreshPid({ + agent, + sdJwt, + mdoc, + batchSize, +}: { agent: AppAgent; sdJwt?: SdJwtVcRecord; mdoc?: MdocRecord; batchSize?: number }) { + console.log('refreshing PID') + const useCase = await RefreshPidUseCase.initialize({ + agent, + }) + + await useCase.retrieveCredentialsUsingExistingRecords({ + sdJwt, + mdoc, + batchSize, + }) +} + /** * If available, takes a credential from the batch. * @@ -14,31 +36,59 @@ export async function handleBatchCredential { const batchMetadata = getBatchCredentialMetadata(credentialRecord) + if (!batchMetadata) return credentialRecord + + // TODO: maybe we should also store the main credential in the additional credentials (and rename it) + // As right now the main one is mostly for display + const batchCredential = batchMetadata.additionalCredentials.pop() + + // Store the record with the used credential removed. Even if the presentation fails we remove it, as we want to be careful + // if the presentation was shared + if (batchCredential) await updateCredential(agent, credentialRecord) - if (batchMetadata) { - const batchCredential = batchMetadata.additionalCredentials.pop() - - if (batchCredential) { - // Store the record with the used credential removed. Even if the presentation fails we remove it, as we want to be careful - // if the presentation was shared - await updateCredential(agent, credentialRecord) - - if (credentialRecord instanceof MdocRecord) { - return new MdocRecord({ - mdoc: Mdoc.fromBase64Url(batchCredential as string), - }) as CredentialRecord - } - if (credentialRecord instanceof SdJwtVcRecord) { - return new SdJwtVcRecord({ - compactSdJwtVc: batchCredential as string, - }) as CredentialRecord - } - if (credentialRecord instanceof W3cCredentialRecord) { - return new W3cCredentialRecord({ - tags: { expandedTypes: [] }, - credential: decodeW3cCredential(batchCredential), - }) as CredentialRecord - } + // Try to refresh the pid when we run out + // TODO: we should probably move this somewhere else at some point + const categoryMetadata = getCredentialCategoryMetadata(credentialRecord) + const refreshMetadata = getRefreshCredentialMetadata(credentialRecord) + if ( + categoryMetadata?.credentialCategory === 'DE-PID' && + refreshMetadata && + batchMetadata.additionalCredentials.length === 0 + ) { + refreshPid({ + agent: agent as EasyPIDAppAgent, + sdJwt: credentialRecord.type === 'SdJwtVcRecord' ? credentialRecord : undefined, + mdoc: credentialRecord.type === 'MdocRecord' ? credentialRecord : undefined, + // Get a batch of 5 for a single record type + batchSize: 5, + }) + .catch((error) => { + // TODO: we should handle the case where the refresh token is expired + agent.config.logger.error('Error refreshing pid', { + error, + }) + }) + .then(() => { + agent.config.logger.debug('Successfully refreshed PID') + }) + } + + if (batchCredential) { + if (credentialRecord instanceof MdocRecord) { + return new MdocRecord({ + mdoc: Mdoc.fromBase64Url(batchCredential as string), + }) as CredentialRecord + } + if (credentialRecord instanceof SdJwtVcRecord) { + return new SdJwtVcRecord({ + compactSdJwtVc: batchCredential as string, + }) as CredentialRecord + } + if (credentialRecord instanceof W3cCredentialRecord) { + return new W3cCredentialRecord({ + tags: { expandedTypes: [] }, + credential: decodeW3cCredential(batchCredential), + }) as CredentialRecord } } diff --git a/packages/agent/src/invitation/handler.ts b/packages/agent/src/invitation/handler.ts index 98978eac..584f882c 100644 --- a/packages/agent/src/invitation/handler.ts +++ b/packages/agent/src/invitation/handler.ts @@ -44,7 +44,6 @@ import { filter, first, firstValueFrom, merge, timeout } from 'rxjs' import { Oauth2Client, getAuthorizationServerMetadataFromList } from '@animo-id/oauth2' import q from 'query-string' -import { handleBatchCredential } from '../batch' import { credentialRecordFromCredential, encodeCredential } from '../format/credentialEncoding' import { type FormattedSubmission, @@ -536,96 +535,6 @@ export const getCredentialsForProofRequest = async ({ } as const } -export const shareProof = async ({ - agent, - resolvedRequest, - selectedCredentials, -}: { - agent: EitherAgent - resolvedRequest: CredentialsForProofRequest - selectedCredentials: { [inputDescriptorId: string]: string } -}) => { - const { authorizationRequest } = resolvedRequest - if ( - !resolvedRequest.credentialsForRequest?.areRequirementsSatisfied && - !resolvedRequest.queryResult?.canBeSatisfied - ) { - throw new Error('Requirements from proof request are not satisfied') - } - - // Map all requirements and entries to a credential record. If a credential record for an - // input descriptor has been provided in `selectedCredentials` we will use that. Otherwise - // it will pick the first available credential. - const presentationExchangeCredentials = resolvedRequest.credentialsForRequest - ? Object.fromEntries( - await Promise.all( - resolvedRequest.credentialsForRequest.requirements.flatMap((requirement) => - requirement.submissionEntry.slice(0, requirement.needsCount).map(async (entry) => { - const credentialId = selectedCredentials[entry.inputDescriptorId] - const credential = - entry.verifiableCredentials.find((vc) => vc.credentialRecord.id === credentialId) ?? - entry.verifiableCredentials[0] - - // Optionally use a batch credential - const credentialRecord = await handleBatchCredential(agent, credential.credentialRecord) - - return [entry.inputDescriptorId, [credentialRecord]] as [string, (typeof credentialRecord)[]] - }) - ) - ) - ) - : undefined - - // TODO: support credential selection for DCQL - const dcqlCredentials = resolvedRequest.queryResult - ? Object.fromEntries( - await Promise.all( - Object.entries( - agent.modules.openId4VcHolder.selectCredentialsForDcqlRequest(resolvedRequest.queryResult) - ).map(async ([queryCredentialId, credential]) => { - // Optionally use a batch credential - const credentialRecord = await handleBatchCredential(agent, credential.credentialRecord) - - return [queryCredentialId, { ...credential, credentialRecord }] - }) - ) - ) - : undefined - - try { - const result = await agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ - authorizationRequest, - presentationExchange: presentationExchangeCredentials - ? { - credentials: presentationExchangeCredentials, - } - : undefined, - dcql: dcqlCredentials - ? { - credentials: dcqlCredentials, - } - : undefined, - }) - - // if redirect_uri is provided, open it in the browser - // Even if the response returned an error, we must open this uri - if (result.redirectUri) { - await Linking.openURL(result.redirectUri) - } - - if (result.serverResponse.status < 200 || result.serverResponse.status > 299) { - throw new Error( - `Error while accepting authorization request. ${JSON.stringify(result.serverResponse.body, null, 2)}` - ) - } - - return result - } catch (error) { - // Handle biometric authentication errors - throw BiometricAuthenticationError.tryParseFromError(error) ?? error - } -} - /** * @todo we probably need a way to cancel this method, if the qr scanner is .e.g dismissed. */ diff --git a/packages/agent/src/invitation/index.ts b/packages/agent/src/invitation/index.ts index 4908fe99..35c52ff0 100644 --- a/packages/agent/src/invitation/index.ts +++ b/packages/agent/src/invitation/index.ts @@ -35,8 +35,8 @@ export { acquirePreAuthorizedAccessToken, resolveOpenId4VciOffer, getCredentialsForProofRequest, - shareProof, withTrustedCertificate, acquireAuthorizationCodeUsingPresentation, } from './handler' +export { shareProof } from './shareProof' export * from './error' diff --git a/packages/agent/src/invitation/shareProof.ts b/packages/agent/src/invitation/shareProof.ts new file mode 100644 index 00000000..c1cc91d5 --- /dev/null +++ b/packages/agent/src/invitation/shareProof.ts @@ -0,0 +1,95 @@ +import { Linking } from 'react-native' +import type { EitherAgent } from '../agent' +import { handleBatchCredential } from '../batch' +import { BiometricAuthenticationError } from './error' +import type { CredentialsForProofRequest } from './handler' + +export const shareProof = async ({ + agent, + resolvedRequest, + selectedCredentials, +}: { + agent: EitherAgent + resolvedRequest: CredentialsForProofRequest + selectedCredentials: { [inputDescriptorId: string]: string } +}) => { + const { authorizationRequest } = resolvedRequest + if ( + !resolvedRequest.credentialsForRequest?.areRequirementsSatisfied && + !resolvedRequest.queryResult?.canBeSatisfied + ) { + throw new Error('Requirements from proof request are not satisfied') + } + + // Map all requirements and entries to a credential record. If a credential record for an + // input descriptor has been provided in `selectedCredentials` we will use that. Otherwise + // it will pick the first available credential. + const presentationExchangeCredentials = resolvedRequest.credentialsForRequest + ? Object.fromEntries( + await Promise.all( + resolvedRequest.credentialsForRequest.requirements.flatMap((requirement) => + requirement.submissionEntry.slice(0, requirement.needsCount).map(async (entry) => { + const credentialId = selectedCredentials[entry.inputDescriptorId] + const credential = + entry.verifiableCredentials.find((vc) => vc.credentialRecord.id === credentialId) ?? + entry.verifiableCredentials[0] + + // Optionally use a batch credential + const credentialRecord = await handleBatchCredential(agent, credential.credentialRecord) + + return [entry.inputDescriptorId, [credentialRecord]] as [string, (typeof credentialRecord)[]] + }) + ) + ) + ) + : undefined + + // TODO: support credential selection for DCQL + const dcqlCredentials = resolvedRequest.queryResult + ? Object.fromEntries( + await Promise.all( + Object.entries( + agent.modules.openId4VcHolder.selectCredentialsForDcqlRequest(resolvedRequest.queryResult) + ).map(async ([queryCredentialId, credential]) => { + // Optionally use a batch credential + const credentialRecord = await handleBatchCredential(agent, credential.credentialRecord) + + return [queryCredentialId, { ...credential, credentialRecord }] + }) + ) + ) + : undefined + + try { + const result = await agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ + authorizationRequest, + presentationExchange: presentationExchangeCredentials + ? { + credentials: presentationExchangeCredentials, + } + : undefined, + dcql: dcqlCredentials + ? { + credentials: dcqlCredentials, + } + : undefined, + }) + + // if redirect_uri is provided, open it in the browser + // Even if the response returned an error, we must open this uri + if (result.redirectUri) { + await Linking.openURL(result.redirectUri) + } + + if (result.serverResponse.status < 200 || result.serverResponse.status > 299) { + throw new Error( + `Error while accepting authorization request. ${JSON.stringify(result.serverResponse.body, null, 2)}` + ) + } + + return result + } catch (error) { + // Handle biometric authentication errors + throw BiometricAuthenticationError.tryParseFromError(error) ?? error + } +}