Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dpop support #1966

Merged
merged 15 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/modules/x509/X509ModuleConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export interface X509ModuleConfigOptions {
/**
*
* Array of trusted base64-encoded certificate strings in the DER-format.
*/
trustedCertificates?: [string, ...string[]]
}

Expand Down
9 changes: 5 additions & 4 deletions packages/openid4vc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@
},
"dependencies": {
"@credo-ts/core": "workspace:*",
"@sphereon/did-auth-siop": "0.15.1-next.4",
"@sphereon/oid4vci-client": "0.15.1-next.4",
"@sphereon/oid4vci-common": "0.15.1-next.4",
"@sphereon/oid4vci-issuer": "0.15.1-next.4",
"@sphereon/did-auth-siop": "link:../../../../Documents/OID4VCI/packages/siop-oid4vp",
"@sphereon/oid4vc-common": "link:../../../../Documents/OID4VCI/packages/common",
"@sphereon/oid4vci-client": "link:../../../../Documents/OID4VCI/packages/client",
"@sphereon/oid4vci-common": "link:../../../../Documents/OID4VCI/packages/oid4vci-common",
"@sphereon/oid4vci-issuer": "link:../../../../Documents/OID4VCI/packages/issuer",
"@sphereon/ssi-types": "0.26.1-next.132",
"class-transformer": "^0.5.1",
"rxjs": "^7.8.0"
Expand Down
14 changes: 8 additions & 6 deletions packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@ export class OpenId4VcHolderApi {
* @param options.code The authorization code obtained via the authorization request URI
*/
public async requestToken(options: OpenId4VciRequestTokenOptions): Promise<OpenId4VciRequestTokenResponse> {
const { access_token: accessToken, c_nonce: cNonce } = await this.openId4VciHolderService.requestAccessToken(
this.agentContext,
options
)
return { accessToken, cNonce }
const {
access_token: accessToken,
c_nonce: cNonce,
dpop,
} = await this.openId4VciHolderService.requestAccessToken(this.agentContext, options)
Comment on lines +149 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change the service interface just fine, so let's change the service return value to:

{
  accessToken,
  cNonce,
  dpop,
  response // should be cloned response (using .clone)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? what should the response contain? the whole response?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it would be a start at solving #1965, which was requested. Returning the response means better extensibility as you can extract the headers, etc..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the PR where I implemented the tokenRequestfunctionality, I had that behaviour, and you made me change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay then i misunderstood the previous implementation. What i don't want is requiring the whole access token response as input to the request credentials method.

I want to have separation between the required fields for credo to work, and additional params you may need for extension. And as I said in the previous PR, i'm not a fan of mixing the successBody with extra params we extracted from e.g. the header.

return { accessToken, cNonce, dpop }
}

/**
Expand All @@ -160,13 +161,14 @@ export class OpenId4VcHolderApi {
* @param options.tokenResponse Obtained through @see requestAccessToken
*/
public async requestCredentials(options: OpenId4VciRequestCredentialOptions) {
const { resolvedCredentialOffer, cNonce, accessToken, ...credentialRequestOptions } = options
const { resolvedCredentialOffer, cNonce, accessToken, dpop, ...credentialRequestOptions } = options

return this.openId4VciHolderService.acceptCredentialOffer(this.agentContext, {
resolvedCredentialOffer,
acceptCredentialOfferOptions: credentialRequestOptions,
accessToken,
cNonce,
dpop,
})
}

Expand Down
135 changes: 108 additions & 27 deletions packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,89 @@
import type {
OpenId4VciAcceptCredentialOfferOptions,
OpenId4VciAuthCodeFlowOptions,
OpenId4VciProofOfPossessionRequirements,
OpenId4VciCredentialBindingResolver,
OpenId4VciResolvedCredentialOffer,
OpenId4VciCredentialResponse,
OpenId4VciNotificationEvent,
OpenId4VciProofOfPossessionRequirements,
OpenId4VciResolvedAuthorizationRequest,
OpenId4VciResolvedAuthorizationRequestWithCode,
OpenId4VciResolvedCredentialOffer,
OpenId4VciSupportedCredentialFormats,
OpenId4VciCredentialResponse,
OpenId4VciNotificationEvent,
OpenId4VciAcceptCredentialOfferOptions,
OpenId4VciTokenRequestOptions,
} from './OpenId4VciHolderServiceOptions'
import type {
OpenId4VciCredentialConfigurationsSupported,
OpenId4VciCredentialConfigurationSupported,
OpenId4VciCredentialConfigurationsSupported,
OpenId4VciCredentialSupported,
OpenId4VciIssuerMetadata,
} from '../shared'
import type { AgentContext, JwaSignatureAlgorithm, Key, JwkJson } from '@credo-ts/core'
import type { AgentContext, JwaSignatureAlgorithm, JwkJson, Key } from '@credo-ts/core'
import type {
AccessTokenResponse,
CredentialResponse,
Jwt,
OpenIDResponse,
AuthorizationDetails,
AuthorizationDetailsJwtVcJson,
CredentialIssuerMetadataV1_0_11,
CredentialIssuerMetadataV1_0_13,
AuthorizationDetailsJwtVcJsonLdAndLdpVc,
AuthorizationDetailsSdJwtVc,
CredentialIssuerMetadataV1_0_11,
CredentialIssuerMetadataV1_0_13,
CredentialResponse,
Jwt,
OpenIDResponse,
} from '@sphereon/oid4vci-common'

import {
SdJwtVcApi,
getJwkFromJson,
DidsApi,
CredoError,
DidsApi,
Hasher,
InjectionSymbols,
JsonEncoder,
Jwk,
JwsService,
Logger,
SdJwtVcApi,
SignatureSuiteRegistry,
TypedArrayEncoder,
W3cCredentialService,
W3cJsonLdVerifiableCredential,
W3cJwtVerifiableCredential,
getJwkClassFromJwaSignatureAlgorithm,
getJwkFromJson,
getJwkFromKey,
getKeyFromVerificationMethod,
getSupportedVerificationMethodTypesFromKeyType,
inject,
injectable,
parseDid,
} from '@credo-ts/core'
import { CreateDPoPClientOpts, SigningAlgo } from '@sphereon/oid4vc-common'

Check failure on line 58 in packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts

View workflow job for this annotation

GitHub Actions / Validate

Unable to resolve path to module '@sphereon/oid4vc-common'
import {
AccessTokenClient,
CredentialRequestClientBuilder,
ProofOfPossessionBuilder,
OpenID4VCIClient,
OpenID4VCIClientStateV1_0_13,
OpenID4VCIClientV1_0_11,
OpenID4VCIClientV1_0_13,
OpenID4VCIClientStateV1_0_13,
ProofOfPossessionBuilder,
} from '@sphereon/oid4vci-client'

Check failure on line 67 in packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts

View workflow job for this annotation

GitHub Actions / Validate

Unable to resolve path to module '@sphereon/oid4vci-client'
import { CodeChallengeMethod, OpenId4VCIVersion, PARMode, post } from '@sphereon/oid4vci-common'
import {
CodeChallengeMethod,
DPoPResponseParams,
EndpointMetadataResult,
OpenId4VCIVersion,
PARMode,
post,
} from '@sphereon/oid4vci-common'

Check failure on line 75 in packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts

View workflow job for this annotation

GitHub Actions / Validate

Unable to resolve path to module '@sphereon/oid4vci-common'

import { OpenId4VciCredentialFormatProfile } from '../shared'
import {
getTypesFromCredentialSupported,
getOfferedCredentials,
credentialsSupportedV11ToV13,
getOfferedCredentials,
getTypesFromCredentialSupported,
} from '../shared/issuerMetadataUtils'
import { OpenId4VciCredentialSupportedWithId } from '../shared/models/index'
import { getSupportedJwaSignatureAlgorithms, isCredentialOfferV1Draft13 } from '../shared/utils'
import { getCreateJwtCallback, getSupportedJwaSignatureAlgorithms, isCredentialOfferV1Draft13 } from '../shared/utils'

import { openId4VciSupportedCredentialFormats, OpenId4VciNotificationMetadata } from './OpenId4VciHolderServiceOptions'
import { OpenId4VciNotificationMetadata, openId4VciSupportedCredentialFormats } from './OpenId4VciHolderServiceOptions'

@injectable()
export class OpenId4VciHolderService {
Expand Down Expand Up @@ -266,14 +275,60 @@
}
}

private async getCreateDPoPOptions(
agentContext: AgentContext,
metadata: Pick<EndpointMetadataResult, 'authorizationServerMetadata'> & {
credentialIssuerMetadata: OpenId4VciIssuerMetadata
}
) {
const dpopSigningAlgValuesSupported =
metadata.authorizationServerMetadata?.dpop_signing_alg_values_supported ??
metadata.credentialIssuerMetadata.dpop_signing_alg_values_supported

if (!dpopSigningAlgValuesSupported) return undefined

const alg = dpopSigningAlgValuesSupported.find((alg) => getJwkClassFromJwaSignatureAlgorithm(alg))

const JwkClass = alg ? getJwkClassFromJwaSignatureAlgorithm(alg) : undefined

if (!JwkClass) {
throw new CredoError(
`No supported dpop signature algorithms found in dpop_signing_alg_values_supported '${dpopSigningAlgValuesSupported.join(
', '
)}'`
)
}

const key = await agentContext.wallet.createKey({ keyType: JwkClass.keyType })
const jwk = getJwkFromKey(key)

const createDPoPOpts: CreateDPoPClientOpts = {
jwtIssuer: { alg: alg as unknown as SigningAlgo, jwk: jwk.toJson() },
dPoPSigningAlgValuesSupported: dpopSigningAlgValuesSupported,
jwtPayloadProps: {},
createJwtCallback: getCreateJwtCallback(agentContext),
}
return createDPoPOpts
}

public async requestAccessToken(agentContext: AgentContext, options: OpenId4VciTokenRequestOptions) {
const { resolvedCredentialOffer, txCode, resolvedAuthorizationRequest, code } = options
const { metadata, credentialOfferRequestWithBaseUrl } = resolvedCredentialOffer

// acquire the access token
let accessTokenResponse: OpenIDResponse<AccessTokenResponse>
let accessTokenResponse: OpenIDResponse<AccessTokenResponse, DPoPResponseParams>

const accessTokenClient = new AccessTokenClient()

const createDPoPOpts = await this.getCreateDPoPOptions(agentContext, metadata)

let dpopJwk: Jwk | undefined
if (createDPoPOpts) {
if (!createDPoPOpts.jwtIssuer.jwk.kty) {
throw new CredoError('Missing required key type (kty) in the jwk.')
}
Comment on lines +330 to +332
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed? In theory the jwk should have this property, so I'm not sure why we check for this specific property

dpopJwk = getJwkFromJson(createDPoPOpts.jwtIssuer.jwk as JwkJson)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why first transform from JWK class to json and then back to jwk, shouldn't the createDpopOptions just return the instance?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm. I don't think so because we create the options which are passed to sphereons library and there we don't have the jwk class

}
if (resolvedAuthorizationRequest) {
const { codeVerifier, redirectUri } = resolvedAuthorizationRequest
accessTokenResponse = await accessTokenClient.acquireAccessToken({
Expand All @@ -283,12 +338,14 @@
code,
codeVerifier,
redirectUri,
createDPoPOpts,
})
} else {
accessTokenResponse = await accessTokenClient.acquireAccessToken({
metadata: metadata,
credentialOffer: { credential_offer: credentialOfferRequestWithBaseUrl.credential_offer },
pin: txCode,
createDPoPOpts,
})
}

Expand All @@ -300,7 +357,10 @@

this.logger.debug('Requested OpenId4VCI Access Token.')

return accessTokenResponse.successBody
return {
...accessTokenResponse.successBody,
...(dpopJwk && { dpop: { dpopJwk: dpopJwk, dpopNonce: accessTokenResponse.params?.dpop?.dpopNonce } }),
}
}

public async acceptCredentialOffer(
Expand All @@ -311,6 +371,7 @@
resolvedAuthorizationRequestWithCode?: OpenId4VciResolvedAuthorizationRequestWithCode
accessToken?: string
cNonce?: string
dpop?: { dpopJwk: Jwk; dpopNonce?: string }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just thinking: is it needed to prefix dpop if we already are in dpop object?

clientId?: string
}
) {
Expand All @@ -324,7 +385,9 @@
return []
}

this.logger.info(`Accepting the following credential offers '${credentialsToRequest}'`)
this.logger.info(
`Accepting the following credential offers '${credentialsToRequest ? credentialsToRequest.join(', ') : 'all'}`
)

const supportedJwaSignatureAlgorithms = getSupportedJwaSignatureAlgorithms(agentContext)

Expand All @@ -351,7 +414,11 @@
} as OpenId4VciTokenRequestOptions

const tokenResponse = options.accessToken
? { access_token: options.accessToken, c_nonce: options.cNonce }
? {
access_token: options.accessToken,
c_nonce: options.cNonce,
...(options.dpop && { dpop: { dpopJwk: options.dpop.dpopJwk, dpopNonce: options.dpop?.dpopNonce } }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't work?

Suggested change
...(options.dpop && { dpop: { dpopJwk: options.dpop.dpopJwk, dpopNonce: options.dpop?.dpopNonce } }),
...(options.dpop && { dpop: options.dpop }),

}
: await this.requestAccessToken(agentContext, tokenRequestOptions)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the service we can already change it (only api we need to worry about breaking changes)


const receivedCredentials: Array<OpenId4VciCredentialResponse> = []
Expand Down Expand Up @@ -425,10 +492,24 @@
.withToken(tokenResponse.access_token)

const credentialRequestClient = credentialRequestBuilder.build()

let createDpopOpts: CreateDPoPClientOpts | undefined
if (tokenResponse.dpop) {
const jwk = tokenResponse.dpop.dpopJwk
const alg = jwk.supportedSignatureAlgorithms[0]

createDpopOpts = {
jwtIssuer: { alg: alg as unknown as SigningAlgo, jwk: jwk.toJson() },
jwtPayloadProps: { accessToken: tokenResponse.access_token, nonce: tokenResponse.dpop?.dpopNonce },
createJwtCallback: getCreateJwtCallback(agentContext),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse this.getCreateDPoPOptions here? Also this could give an error when multiple alg values are supported for a jwk type, but the server only supports a specific one. So reusing the dpop alg values supported logic from existing method makes sense?

Also, accessing index [0] is unsafe again

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also should we check if dpop is required? Can we see that using the access token? Or how do we know it is required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the tokenresponse contains dpop it is required

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But token response is passed by the user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, IMO, the user should not modify the object unless he knows what he is doing.
Also in theory it is valid to remove it since an authorization server can protect multiple credential issuers,
for some of them use of dpop may be optional even if the authorization server requires dpop.


const credentialResponse = await credentialRequestClient.acquireCredentialsUsingProof({
proofInput: proofOfPossession,
credentialTypes: getTypesFromCredentialSupported(offeredCredentialConfiguration),
format: offeredCredentialConfiguration.format,
createDPoPOpts: createDpopOpts,
})

newCNonce = credentialResponse.successBody?.c_nonce
Expand Down Expand Up @@ -625,7 +706,7 @@

private async handleCredentialResponse(
agentContext: AgentContext,
credentialResponse: OpenIDResponse<CredentialResponse>,
credentialResponse: OpenIDResponse<CredentialResponse, DPoPResponseParams>,
options: {
verifyCredentialStatus: boolean
credentialIssuerMetadata: OpenId4VciIssuerMetadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
OpenId4VciIssuerMetadata,
OpenId4VciCredentialOfferPayload,
} from '../shared'
import type { JwaSignatureAlgorithm, KeyType } from '@credo-ts/core'
import type { JwaSignatureAlgorithm, Jwk, KeyType } from '@credo-ts/core'
import type { VerifiableCredential } from '@credo-ts/core/src/modules/dif-presentation-exchange/models/index'
import type {
AccessTokenResponse,
Expand Down Expand Up @@ -42,7 +42,11 @@ export type OpenId4VciNotificationEvent = 'credential_accepted' | 'credential_fa

export type OpenId4VciTokenResponse = Pick<AccessTokenResponse, 'access_token' | 'c_nonce'>

export type OpenId4VciRequestTokenResponse = { accessToken: string; cNonce?: string }
export type OpenId4VciRequestTokenResponse = {
accessToken: string
cNonce?: string
dpop?: { dpopJwk: Jwk; dpopNonce?: string }
}

export interface OpenId4VciCredentialResponse {
credential: VerifiableCredential
Expand Down Expand Up @@ -112,6 +116,7 @@ export interface OpenId4VciCredentialRequestOptions extends Omit<OpenId4VciAccep
resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer
accessToken: string
cNonce?: string
dpop?: { dpopJwk: Jwk; dpopNonce?: string }

/**
* The client id used for authorization. Only required if authorization_code flow was used.
Expand Down
17 changes: 9 additions & 8 deletions packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,22 @@ export class OpenId4VcIssuerApi {
}

public async updateIssuerMetadata(
options: Pick<OpenId4VcIssuerRecordProps, 'issuerId' | 'display'> &
options: Pick<OpenId4VcIssuerRecordProps, 'issuerId' | 'display' | 'dpopSigningAlgValuesSupported'> &
(OpenId4VcIssuerRecordCredentialSupportedProps | OpenId4VcIssuerRecordCredentialConfigurationsSupportedProps)
) {
const issuer = await this.openId4VcIssuerService.getIssuerByIssuerId(this.agentContext, options.issuerId)
const { issuerId, credentialConfigurationsSupported, credentialsSupported, ...issuerOptions } = options

if (options.credentialConfigurationsSupported) {
issuer.credentialConfigurationsSupported = options.credentialConfigurationsSupported
issuer.credentialsSupported = credentialsSupportedV13ToV11(options.credentialConfigurationsSupported)
const issuer = await this.openId4VcIssuerService.getIssuerByIssuerId(this.agentContext, issuerId)

if (credentialConfigurationsSupported) {
issuer.credentialConfigurationsSupported = credentialConfigurationsSupported
issuer.credentialsSupported = credentialsSupportedV13ToV11(credentialConfigurationsSupported)
} else {
issuer.credentialsSupported = options.credentialsSupported
issuer.credentialsSupported = credentialsSupported
issuer.credentialConfigurationsSupported = undefined
}
issuer.display = options.display

return this.openId4VcIssuerService.updateIssuer(this.agentContext, issuer)
return this.openId4VcIssuerService.updateIssuer(this.agentContext, Object.assign(issuer, issuerOptions))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather manually assign the props that can be updated instead of doing object.assign. Especially since if you're not using TypeScript it will just assign any property to the record that you pass here. You could provide e.g. type: 'Random' and it will overwrite the type of the record.

}

/**
Expand Down
Loading
Loading