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

feat: dpop support #1966

merged 15 commits into from
Aug 5, 2024

Conversation

auer-martin
Copy link
Contributor

blocked by for now Sphereon-Opensource/OID4VC#131

Copy link

changeset-bot bot commented Jul 25, 2024

🦋 Changeset detected

Latest commit: 33ed706

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@credo-ts/core Patch
@credo-ts/openid4vc Patch
@credo-ts/action-menu Patch
@credo-ts/anoncreds Patch
@credo-ts/askar Patch
@credo-ts/bbs-signatures Patch
@credo-ts/cheqd Patch
@credo-ts/drpc Patch
@credo-ts/indy-sdk-to-askar-migration Patch
@credo-ts/indy-vdr Patch
@credo-ts/node Patch
@credo-ts/question-answer Patch
@credo-ts/react-native Patch
@credo-ts/tenants Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Contributor

@TimoGlastra TimoGlastra left a comment

Choose a reason for hiding this comment

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

Nice very good stuff martin!!'

Only thinkg i'm not sure about is the retuen value of requestAccesToken. Bit unfortunate we already released the previous pr as now it will be a breaking change...

if (!dPoPSigningAlgValuesSupported) return undefined

const alg = dPoPSigningAlgValuesSupported
.flatMap((alg) => alg)
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? It's already string array right?

@@ -271,6 +319,11 @@ export class OpenId4VciHolderService {
let accessTokenResponse: OpenIDResponse<AccessTokenResponse>

const accessTokenClient = new AccessTokenClient()

const createDPoPOptions = await this.getCreateDPoPOptions(agentContext, metadata)
Copy link
Contributor

Choose a reason for hiding this comment

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

In credo we usually remove the capitalization if it's being used aa a word.

So createDpopOptions. Then you always know when a word starts and stops.

const createDPoPOptions = await this.getCreateDPoPOptions(agentContext, metadata)
const dPoPJwk = createDPoPOptions ? getJwkFromJson(createDPoPOptions.jwtIssuer.jwk) : undefined

resolvedCredentialOffer.metadata.credentialIssuerMetadata
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
resolvedCredentialOffer.metadata.credentialIssuerMetadata

@@ -297,7 +352,7 @@ export class OpenId4VciHolderService {

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

return accessTokenResponse.successBody
return { ...accessTokenResponse.successBody, dPoPJwk }
Copy link
Contributor

Choose a reason for hiding this comment

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

This is infortunate, but i don't think we should mix it. Ideally we return a new object with keys: {response: {body,headers,code}, dpop: {jwk}}

Then It's more extendable.

Maybe deprecate and add a new one? Or we keep it like this for now and upgrade when moving to 0.6

But still would like to have dpop.jwk so we can extend dpop values as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would leave it as is for now. Maybe change with 0.6 then.

But still would like to have dpop.jwk so we can extend dpop values as well.
On the client side you only receive the dpop not really things that can be extended IMO

Copy link
Contributor

Choose a reason for hiding this comment

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

Until we maybe want to return the dpop that was sent, or the htu, or anything.. I think we've had a few times now where not adding an extra layer of nesting resulted in using having to make breaking changes. Rather take that into account now

@@ -308,6 +363,7 @@ export class OpenId4VciHolderService {
resolvedAuthorizationRequestWithCode?: OpenId4VciResolvedAuthorizationRequestWithCode
accessToken?: string
cNonce?: string
dPoPJwk?: Jwk
Copy link
Contributor

Choose a reason for hiding this comment

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

dpopJwk

@@ -120,6 +122,30 @@ export function handleTokenRequest(config: OpenId4VciAccessTokenEndpointConfig)
const issuerMetadata = openId4VcIssuerService.getIssuerMetadata(agentContext, issuer)
const accessTokenSigningKey = Key.fromFingerprint(issuer.accessTokenPublicKeyFingerprint)

let dPoPJwk: JWK | undefined
if (request.headers.dpop) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we require dpop?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, we could implement it. But in theory, the client can opt into it for more security.
Also, supporting dpop without having clients use it is perfectly viable.
If we implement it we should probably create a config field allowing users to opt into that strict behaviour

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 funke pid issuer it is a requirement. There's going to be cases where the issuer wants to require it (fine to keep it out for now, but something we can add later. Maybe on the issuer record, or per credential offer)

@@ -27,6 +36,15 @@ export async function verifyAccessToken(
},
})

const fullUrl = request.protocol + '://' + request.get('host') + request.originalUrl
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here . Need to use baseUrl from configuration

@@ -27,6 +36,15 @@ export async function verifyAccessToken(
},
})

const fullUrl = request.protocol + '://' + request.get('host') + request.originalUrl
await verifyResourceDPoP(
Copy link
Contributor

Choose a reason for hiding this comment

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

I know it looks a bit weird, but consistency is king imo

Suggested change
await verifyResourceDPoP(
await verifyResourceDpop(

if (!issuer.startsWith('https://')) {
throw new CredoError('The X509 certificate issuer must be a HTTPS URI.')
}

if (leafCertificate.sanUriNames?.includes(issuer)) {
return {
...openId4VcTokenIssuer,
alg: alg as unknown as SigningAlgo,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the lib should just support string. As you can use any algorithm and this is a bit annoying to cast everytime

@@ -218,6 +223,7 @@ describe('OpenId4Vc', () => {
},
})
const openIdIssuerTenant2 = await issuerTenant2.modules.openId4VcIssuer.createIssuer({
dPoPSigningAlgValuesSupported: ['EdDSA'],
Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking in general whether this is something you want to configure per issuer? It will make uodates in multi-tenancy quite cumbersome. But i also see the case for having different values per issuer

Comment on lines +327 to +329
if (!createDPoPOpts.jwtIssuer.jwk.kty) {
throw new CredoError('Missing required key type (kty) in the jwk.')
}
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

if (!createDPoPOpts.jwtIssuer.jwk.kty) {
throw new CredoError('Missing required key type (kty) in the jwk.')
}
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

Comment on lines +149 to +153
const {
access_token: accessToken,
c_nonce: cNonce,
dpop,
} = await this.openId4VciHolderService.requestAccessToken(this.agentContext, options)
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.

@@ -311,6 +371,7 @@ export class OpenId4VciHolderService {
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?

? {
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 }),

Comment on lines 496 to 506
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.


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.

Comment on lines +86 to +88
if (!jwtIssuer.jwk.kty) {
throw new CredoError('Missing required key type (kty) in the jwk.')
}
Copy link
Contributor

Choose a reason for hiding this comment

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

again, why this check?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

can be undefined in sphereon

Copy link
Contributor

Choose a reason for hiding this comment

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

But in this case we have a jwk class instance right? So it won't happen?

@TimoGlastra TimoGlastra marked this pull request as ready for review August 3, 2024 09:27
@TimoGlastra
Copy link
Contributor

Made a small mistake in my PR on jwk thumbprint, so we need to wait for Sphereon-Opensource/OID4VC#140

TimoGlastra and others added 4 commits August 3, 2024 12:07
Signed-off-by: Timo Glastra <[email protected]>
Signed-off-by: Martin Auer <[email protected]>
Signed-off-by: Timo Glastra <[email protected]>
Signed-off-by: Timo Glastra <[email protected]>
@TimoGlastra TimoGlastra merged commit fa62b74 into openwallet-foundation:main Aug 5, 2024
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants