Skip to content

Commit

Permalink
feat: EBSI headless attestation credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
nklomp committed Jun 25, 2024
1 parent b3550de commit 6b6ad14
Show file tree
Hide file tree
Showing 20 changed files with 168 additions and 92 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@
"@veramo/url-handler": "4.2.0",
"@sphereon/ssi-types": "workspace:*",
"@sphereon/ssi-sdk.core": "workspace:*",
"@sphereon/oid4vci-common": "0.12.1-next.5",
"@sphereon/oid4vci-client": "0.12.1-next.5",
"@sphereon/oid4vci-issuer": "0.12.1-next.5",
"@sphereon/oid4vci-common": "0.12.1-next.29",
"@sphereon/oid4vci-client": "0.12.1-next.29",
"@sphereon/oid4vci-issuer": "0.12.1-next.29",
"@noble/hashes": "1.2.0",
"did-jwt": "6.11.6",
"did-jwt-vc": "3.1.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/data-store/src/utils/contact/MappingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,4 +492,4 @@ export const isOpenIdConfig = (config: NonPersistedConnectionConfig | BaseConfig
'clientSecret' in config && 'issuer' in config && 'redirectUrl' in config

export const isDidAuthConfig = (config: NonPersistedConnectionConfig | BaseConfigEntity): config is DidAuthConfig | DidAuthConfigEntity =>
('identifier' in config || ('idOpts' in config && 'identifier' in config.idOpts)) && 'redirectUrl' in config && 'sessionId' in config
('identifier' in config || ('idOpts' in config && 'identifier' in config.idOpts)) && 'redirectUrl' in config && 'sessionId' in config
25 changes: 22 additions & 3 deletions packages/ebsi-authorization-client/__tests__/attestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const tearDown = async (): Promise<boolean> => {
return true
}

describe.skip('attestation client should', () => {
describe('attestation client should', () => {
let identifier: IIdentifier

beforeAll(async (): Promise<void> => {
Expand All @@ -143,8 +143,8 @@ describe.skip('attestation client should', () => {
redirectUri: `${MOCK_BASE_URL}`,
clientId,
idOpts: { identifier },
credentialIssuer: 'http://192.168.2.90:3000/conformance/v3/issuer-mock',
//credentialIssuer: 'https://conformance-test.ebsi.eu/conformance/v3/issuer-mock',
// credentialIssuer: 'http://192.168.2.90:3000/conformance/v3/issuer-mock',
credentialIssuer: 'https://api-conformance.ebsi.eu/conformance/v3/issuer-mock',
requestObjectOpts: {
iss: clientId,
requestObjectMode: CreateRequestObjectMode.REQUEST_OBJECT,
Expand Down Expand Up @@ -283,12 +283,15 @@ describe.skip('attestation client should', () => {
OID4VCIMachineStates,
(oid4vciMachine: OID4VCIMachineInterpreter, state: OID4VCIMachineState) => Promise<void>
>()
vciStateCallbacks.set(OID4VCIMachineStates.handleError, handleError)
vciStateCallbacks.set(OID4VCIMachineStates.addContact, addContact)
vciStateCallbacks.set(OID4VCIMachineStates.selectCredentials, selectCredentials)
vciStateCallbacks.set(OID4VCIMachineStates.initiateAuthorizationRequest, authorizationCodeUrl)
vciStateCallbacks.set(OID4VCIMachineStates.reviewCredentials, reviewCredentials)

const vpStateCallbacks = new Map<Siopv2MachineStates, (oid4vpMachine: Siopv2MachineInterpreter, state: Siopv2MachineState) => Promise<void>>()
vpStateCallbacks.set(Siopv2MachineStates.done, siopDone)
vpStateCallbacks.set(Siopv2MachineStates.handleError, handleError)
vpLinkHandler = new Siopv2OID4VPLinkHandler({
protocols: ['openid:'],
// @ts-ignore
Expand All @@ -302,6 +305,12 @@ describe.skip('attestation client should', () => {
issuanceOpt: {
identifier,
didMethod: SupportedDidMethodEnum.DID_EBSI,
kid: authReqResult.authKey.meta?.jwkThumbprint ?? authReqResult.authKey.kid,
},
clientOpts: {
clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
kid: authReqResult.authKey.meta?.jwkThumbprint ?? authReqResult.authKey.kid,
clientId,
},
didMethodPreferences: [SupportedDidMethodEnum.DID_EBSI, SupportedDidMethodEnum.DID_KEY],
stateNavigationListener: OID4VCICallbackStateListener(vciStateCallbacks),
Expand All @@ -320,6 +329,10 @@ describe.skip('attestation client should', () => {
})
})

const handleError = async (oid4vciMachine: OID4VCIMachineInterpreter | Siopv2MachineInterpreter, state: OID4VCIMachineState | Siopv2MachineState) => {
console.error(state.event)
}

const selectCredentials = async (oid4vciMachine: OID4VCIMachineInterpreter, state: OID4VCIMachineState) => {
const { contact, credentialToSelectFrom, selectedCredentials } = state.context

Expand Down Expand Up @@ -376,6 +389,12 @@ const authorizationCodeUrl = async (oid4vciMachine: OID4VCIMachineInterpreter, s
await onOpenAuthorizationUrl(url)
}

const reviewCredentials = async (oid4vciMachine: OID4VCIMachineInterpreter, state: OID4VCIMachineState) => {
console.log(`# REVIEW CREDENTIALS:`)
console.log(JSON.stringify(state.context.credentialsToAccept, null, 2))

}

const siopDone = async (oid4vpMachine: Siopv2MachineInterpreter, state: Siopv2MachineState) => {
// console.log('SIOP result:')
// console.log(JSON.stringify(state.context, null , 2))
Expand Down
4 changes: 2 additions & 2 deletions packages/ebsi-authorization-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"uuidv4": "^6.2.13"
},
"devDependencies": {
"@sphereon/oid4vci-client": "0.12.1-next.5",
"@sphereon/oid4vci-common": "0.12.1-next.5",
"@sphereon/oid4vci-client": "0.12.1-next.29",
"@sphereon/oid4vci-common": "0.12.1-next.29",
"@sphereon/ssi-sdk.agent-config": "workspace:*",
"@sphereon/ssi-sdk-ext.key-manager": "0.21.1-next.8",
"@sphereon/ssi-sdk-ext.kms-local": "0.21.1-next.8",
Expand Down
22 changes: 20 additions & 2 deletions packages/ebsi-authorization-client/src/functions/Attestation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { OpenID4VCIClient } from '@sphereon/oid4vci-client'
import {
Alg,
AuthorizationDetails,
AuthorizationRequestOpts,
AuthzFlowType,
CredentialConfigurationSupported,
getJson,
Expand All @@ -11,7 +13,14 @@ import {
} from '@sphereon/oid4vci-common'
import { getIdentifier, IIdentifierOpts } from '@sphereon/ssi-sdk-ext.did-utils'
import { calculateJwkThumbprintForKey } from '@sphereon/ssi-sdk-ext.key-utils'
import { getAuthenticationKey, IssuanceOpts, PrepareStartArgs, signCallback, SupportedDidMethodEnum } from '@sphereon/ssi-sdk.oid4vci-holder'
import {
getAuthenticationKey,
IssuanceOpts,
PrepareStartArgs,
signatureAlgorithmFromKey,
signCallback,
SupportedDidMethodEnum,
} from '@sphereon/ssi-sdk.oid4vci-holder'
import { IIdentifier } from '@veramo/core'
import { _ExtendedIKey } from '@veramo/utils'
import { IRequiredContext } from '../types/IEBSIAuthorizationClient'
Expand Down Expand Up @@ -106,7 +115,7 @@ export const ebsiCreateAttestationAuthRequestURL = async (
signCallbacks,
kid: requestObjectOpts.kid ?? kid,
},
}
} satisfies AuthorizationRequestOpts
// todo: Do we really need to do this, or can we just set the create option to true at this point? We are passing in the authzReq opts
const authorizationCodeURL = await vciClient.createAuthorizationRequestUrl({
authorizationRequest: authorizationRequestOpts,
Expand All @@ -119,6 +128,15 @@ export const ebsiCreateAttestationAuthRequestURL = async (
uri: credentialIssuer,
existingClientState: JSON.parse(await vciClient.exportState()),
},
accessTokenOpts: {
clientOpts: {
alg: Alg[await signatureAlgorithmFromKey({ key: authKey })],
clientId,
kid,
signCallbacks,
clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
},
},
authorizationRequestOpts,
authorizationCodeURL,
identifier,
Expand Down
4 changes: 2 additions & 2 deletions packages/oid4vci-holder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"build:clean": "tsc --build --clean && tsc --build"
},
"dependencies": {
"@sphereon/oid4vci-client": "0.12.1-next.5",
"@sphereon/oid4vci-common": "0.12.1-next.5",
"@sphereon/oid4vci-client": "0.12.1-next.29",
"@sphereon/oid4vci-common": "0.12.1-next.29",
"@sphereon/ssi-sdk-ext.did-resolver-jwk": "0.21.1-next.8",
"@sphereon/ssi-sdk-ext.did-utils": "0.21.1-next.8",
"@sphereon/ssi-sdk.contact-manager": "workspace:*",
Expand Down
39 changes: 32 additions & 7 deletions packages/oid4vci-holder/src/agent/OID4VCIHolder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CredentialOfferClient, MetadataClient, OpenID4VCIClient } from '@sphereon/oid4vci-client'
import {
AuthorizationRequestOpts,
AuthorizationServerClientOpts,
AuthorizationServerOpts,
CredentialOfferRequestWithBaseUrl,
DefaultURISchemes,
EndpointMetadataResult,
Expand Down Expand Up @@ -30,7 +32,7 @@ import {
parseDid,
SdJwtDecodedVerifiableCredentialPayload,
} from '@sphereon/ssi-types'
import { CredentialPayload, DIDDocument, IAgentPlugin, ProofFormat, VerifiableCredential, W3CVerifiableCredential } from '@veramo/core'
import { CredentialPayload, IAgentPlugin, ProofFormat, VerifiableCredential, W3CVerifiableCredential } from '@veramo/core'
import { asArray, computeEntryHash } from '@veramo/utils'
import { decodeJWT, JWTHeader } from 'did-jwt'
import { v4 as uuidv4 } from 'uuid'
Expand Down Expand Up @@ -121,7 +123,7 @@ export function signCallback(client: OpenID4VCIClient, idOpts: IIdentifierOpts,
kid = key.meta.jwkThumbprint
}

const httpsClientId = jwt.payload.client_id?.startsWith('http')
const httpsClientId = jwt.payload.iss?.startsWith('http') ?? jwt.payload.client_id?.startsWith('http') === true
if (!httpsClientId && client.isEBSI()) {
iss = identifier.did /*kid?.split('#')[0]*/
} else if (!iss) {
Expand Down Expand Up @@ -253,7 +255,8 @@ export class OID4VCIHolder implements IAgentPlugin {
),
createCredentialsToSelectFrom: (args: createCredentialsToSelectFromArgs) => this.oid4vciHoldercreateCredentialsToSelectFrom(args, context),
getContact: (args: GetContactArgs) => this.oid4vciHolderGetContact(args, context),
getCredentials: (args: GetCredentialsArgs) => this.oid4vciHolderGetCredentials(args, context),
getCredentials: (args: GetCredentialsArgs) =>
this.oid4vciHolderGetCredentials({ accessTokenOpts: args.accessTokenOpts ?? opts.accessTokenOpts, ...args }, context),
addContactIdentity: (args: AddContactIdentityArgs) => this.oid4vciHolderAddContactIdentity(args, context),
assertValidCredentials: (args: AssertValidCredentialsArgs) => this.oid4vciHolderAssertValidCredentials(args, context),
storeCredentialBranding: (args: StoreCredentialBrandingArgs) => this.oid4vciHolderStoreCredentialBranding(args, context),
Expand Down Expand Up @@ -393,9 +396,9 @@ export class OID4VCIHolder implements IAgentPlugin {

// const client = await OpenID4VCIClient.fromState({ state: openID4VCIClientState! }) // TODO see if we need the check openID4VCIClientState defined
/*const credentialsSupported = await getCredentialConfigsSupportedBySingleTypeOrId({
client,
vcFormatPreferences: this.vcFormatPreferences,
})*/
client,
vcFormatPreferences: this.vcFormatPreferences,
})*/
logger.info(`Credentials supported ${Object.keys(credentialsSupported).join(', ')}`)

const credentialSelection: Array<CredentialToSelectFromResult> = await Promise.all(
Expand Down Expand Up @@ -518,11 +521,12 @@ export class OID4VCIHolder implements IAgentPlugin {
if (!issuanceOpt) {
return Promise.reject(Error(`Cannot get credential issuance options`))
}

const idOpts = await getIdentifierOpts({ issuanceOpt, context })
const { key, kid } = idOpts
const alg: SignatureAlgorithmEnum = await signatureAlgorithmFromKey({ key })

const callbacks: ProofOfPossessionCallbacks<DIDDocument> = {
const callbacks: ProofOfPossessionCallbacks<never> = {
signCallback: await signCallback(client, idOpts, context),
}

Expand All @@ -531,11 +535,32 @@ export class OID4VCIHolder implements IAgentPlugin {
if (!client.clientId) {
client.clientId = issuanceOpt.identifier.did
}
let asOpts: AuthorizationServerOpts | undefined = undefined
if (accessTokenOpts?.clientOpts) {
let clientOptsKid = accessTokenOpts.clientOpts.kid ?? kid
const clientId = accessTokenOpts.clientOpts.clientId ?? client.clientId
if (client.isEBSI() && clientId?.startsWith('http') && clientOptsKid.includes('#')) {
clientOptsKid = clientOptsKid.split('#')[1]
}
const clientOpts: AuthorizationServerClientOpts = {
...accessTokenOpts.clientOpts,
clientId,
kid: clientOptsKid,
// @ts-ignore
alg: accessTokenOpts.clientOpts.alg ?? alg,
signCallbacks: accessTokenOpts.clientOpts.signCallbacks ?? callbacks,
}
asOpts = {
clientOpts,
}
}

await client.acquireAccessToken({
clientId: client.clientId,
pin,
authorizationResponse: JSON.parse(await client.exportState()).authorizationCodeResponse,
additionalRequestParams: accessTokenOpts?.additionalRequestParams,
...(asOpts && { asOpts }),
})

// FIXME: This type mapping is wrong. It should use credential_identifier in case the access token response has authorization details
Expand Down
9 changes: 7 additions & 2 deletions packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,13 @@ export const getIdentifierOpts = async (args: GetIdentifierArgs): Promise<Identi
},
},
}))
const key: _ExtendedIKey = await getAuthenticationKey({ identifier, context })
const kid: string = key.meta.verificationMethod.id
const key: _ExtendedIKey = await getAuthenticationKey({
identifier,
context,
offlineWhenNoDIDRegistered: identifier.did.startsWith('did:ebsi'),
noVerificationMethodFallback: true,
})
const kid: string = key.meta?.jwkThumbprint ?? key.meta.verificationMethod?.id ?? key.kid

return { identifier, key, kid }
}
Expand Down
10 changes: 7 additions & 3 deletions packages/oid4vci-holder/src/link-handler/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CredentialOfferClient } from '@sphereon/oid4vci-client'
import { AuthorizationRequestOpts, AuthzFlowType, convertURIToJsonObject } from '@sphereon/oid4vci-common'
import { AuthorizationRequestOpts, AuthorizationServerClientOpts, AuthzFlowType, convertURIToJsonObject } from '@sphereon/oid4vci-common'
import { DefaultLinkPriorities, LinkHandlerAdapter } from '@sphereon/ssi-sdk.core'
import { IMachineStatePersistence, interpreterStartOrResume, SerializableState } from '@sphereon/ssi-sdk.xstate-machine-persistence'
import { IAgentContext } from '@veramo/core'
Expand All @@ -15,9 +15,10 @@ export class OID4VCIHolderLinkHandler extends LinkHandlerAdapter {
| undefined
private readonly noStateMachinePersistence: boolean
private readonly authorizationRequestOpts?: AuthorizationRequestOpts
private readonly clientOpts?: AuthorizationServerClientOpts

constructor(
args: Pick<GetMachineArgs, 'stateNavigationListener' | 'authorizationRequestOpts'> & {
args: Pick<GetMachineArgs, 'stateNavigationListener' | 'authorizationRequestOpts' | 'clientOpts'> & {
priority?: number | DefaultLinkPriorities
protocols?: Array<string | RegExp>
noStateMachinePersistence?: boolean
Expand All @@ -26,6 +27,7 @@ export class OID4VCIHolderLinkHandler extends LinkHandlerAdapter {
) {
super({ ...args, id: 'OID4VCIHolder' })
this.authorizationRequestOpts = args.authorizationRequestOpts
this.clientOpts = args.clientOpts
this.context = args.context
this.noStateMachinePersistence = args.noStateMachinePersistence === true
this.stateNavigationListener = args.stateNavigationListener
Expand All @@ -37,14 +39,15 @@ export class OID4VCIHolderLinkHandler extends LinkHandlerAdapter {
machineState?: SerializableState
authorizationRequestOpts?: AuthorizationRequestOpts
createAuthorizationRequestURL?: boolean
clientOpts?: AuthorizationServerClientOpts
flowType?: AuthzFlowType
},
): Promise<void> {
const uri = new URL(url).toString()
const offerData = convertURIToJsonObject(uri) as Record<string, unknown>
const hasCode = 'code' in offerData && !!offerData.code && !('issuer' in offerData)
const code = hasCode ? (offerData.code as string) : undefined

const clientOpts = { ...this.clientOpts, ...opts?.clientOpts }
const oid4vciMachine = await this.context.agent.oid4vciHolderGetMachineInterpreter({
requestData: {
// We know this can only be invoked with a credential offer, so we convert the URI to offer
Expand All @@ -55,6 +58,7 @@ export class OID4VCIHolderLinkHandler extends LinkHandlerAdapter {
uri,
},
authorizationRequestOpts: { ...this.authorizationRequestOpts, ...opts?.authorizationRequestOpts },
...((clientOpts.clientId || clientOpts.clientAssertionType) && { clientOpts: clientOpts as AuthorizationServerClientOpts }),
stateNavigationListener: this.stateNavigationListener,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export const OID4VCICallbackStateListener = (
return
}

callbacks.forEach((callback, key: OID4VCIMachineStates) => {
if (state.matches(key)) {
for (const [stateKey, callback] of callbacks) {
if (state.matches(stateKey)) {
logger.log(`state callback found for state: ${JSON.stringify(state.value)}, will execute callback`)
callback(oid4vciMachine, state)
await callback(oid4vciMachine, state)
.then(() => logger.log(`state callback executed for state: ${JSON.stringify(state.value)}`))
.catch((error) =>
logger.log(`state callback failed for state: ${JSON.stringify(state.value)}, error: ${JSON.stringify(error?.message)}, ${state.event}`),
)
}
})
}
}
}
7 changes: 2 additions & 5 deletions packages/oid4vci-holder/src/machine/oid4vciMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const oid4vciHasAuthorizationResponse = (ctx: OID4VCIMachineContext, _event: OID
const createOID4VCIMachine = (opts?: CreateOID4VCIMachineOpts): OID4VCIStateMachine => {
const initialContext: OID4VCIMachineContext = {
// TODO WAL-671 we need to store the data from OpenIdProvider here in the context and make sure we can restart the machine with it and init the OpenIdProvider
accessTokenOpts: opts?.accessTokenOpts,
requestData: opts?.requestData,
issuanceOpt: opts?.issuanceOpt,
didMethodPreferences: opts?.didMethodPreferences,
Expand Down Expand Up @@ -363,14 +364,10 @@ const createOID4VCIMachine = (opts?: CreateOID4VCIMachineOpts): OID4VCIStateMach
[OID4VCIMachineEvents.PROVIDE_AUTHORIZATION_CODE_RESPONSE]: {
actions: assign({
openID4VCIClientState: (_ctx: OID4VCIMachineContext, _event: AuthorizationResponseEvent) => {
console.log(`=> Assigning authorizationCodeResponse using event data ${JSON.stringify(_event.data)}`)
const authorizationCodeResponse = toAuthorizationResponsePayload(_event.data)
console.log(`=> Assigned authorizationCodeResponse value ${JSON.stringify(authorizationCodeResponse)}`)
return { ..._ctx.openID4VCIClientState!, authorizationCodeResponse }
},
}), // TODO can we not call toAuthorizationResponsePayload before
// target: OID4VCIMachineStates.waitForAuthorizationResponse,
// target: OID4VCIMachineStates.transitionFromSelectingCredentials,
}),
},
},
always: [
Expand Down
Loading

0 comments on commit 6b6ad14

Please sign in to comment.