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 #131

Merged
merged 21 commits into from
Aug 2, 2024
Merged

feat: dpop support #131

merged 21 commits into from
Aug 2, 2024

Conversation

auer-martin
Copy link
Contributor

This PR adds support for dPoP.
DPoP support itself did not require many changes. Most of the changes made in this PR are refactorings, e.g., JwtIssuer and JwtVerifier to common so they can be reused ....sorry for that.
Changes made for actual dpop support can be found in this commit.
I open the PR as a draft for now, as I plan to go over the spec one last time in the next couple of days and add some tests.

Copy link
Contributor

@nklomp nklomp left a comment

Choose a reason for hiding this comment

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

Overall looking very good. Some small suggestions, comments.

I do think we probably need to create a real common module. Because with the changes proposed (which are good) now the SIOP package will start depending on the vci-common package. The latter really is about VCI ATM, so probably better to start a real common package, which will also server as a nice module for the refactor happening soon

@@ -87,10 +92,17 @@ export class AccessTokenClient {
: undefined,
});

return this.sendAuthCode(requestTokenURL, accessTokenRequest);
let dPoP: string | undefined;
if (createDPoPOptions?.dPoPSigningAlgValuesSupported && createDPoPOptions?.dPoPSigningAlgValuesSupported.length > 0) {
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
if (createDPoPOptions?.dPoPSigningAlgValuesSupported && createDPoPOptions?.dPoPSigningAlgValuesSupported.length > 0) {
if (createDPoPOptions?.dPoPSigningAlgValuesSupported && createDPoPOptions.dPoPSigningAlgValuesSupported.length > 0) {

packages/client/lib/AccessTokenClient.ts Outdated Show resolved Hide resolved
packages/client/lib/AccessTokenClient.ts Outdated Show resolved Hide resolved
packages/client/lib/AccessTokenClientV1_0_11.ts Outdated Show resolved Hide resolved
packages/client/lib/AccessTokenClientV1_0_11.ts Outdated Show resolved Hide resolved
}

// The DPoP HTTP request header field value is a single and well-formed JWT.
const dPoPHeader = jwtDecode<JwtHeader & Partial<DPoPJwtHeaderProps>>(dpop, { header: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

We have a parseJWT that does both. I guess we could change that function to accept some generics for header and payload, like the original functions do, with defaults for JwtHeader and JwtPayload

}

// Validate iat claim
const now = epochTime();
Copy link
Contributor

Choose a reason for hiding this comment

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

It is probably wise to add an small negative offset option to the function. You will typically use this in time based properties in JWTs, like for instance iat. Since there could be small offsets in time between your computer and the remote computer, we typically subtract a few seconds from the iat, because otherwise you might run into a JWT not valid yet type of situation if the remote machine is slightly lagging behind

@@ -27,7 +27,8 @@ export interface AuthorizationServerMetadata {
// Note that the presence of pushed_authorization_request_endpoint is sufficient for a client to determine that it may use the PAR flow. A request_uri value obtained from the PAR endpoint is usable at the authorization endpoint regardless of other authorization server metadata such as request_uri_parameter_supported or require_request_uri_registration
require_pushed_authorization_requests?: boolean; // Boolean parameter indicating whether Indicates whether the client is required to use PAR to initiate authorization. If omitted, the default value is false.
'pre-authorized_grant_anonymous_access_supported': boolean; // OPTIONAL. A JSON Boolean indicating whether the issuer accepts a Token Request with a Pre-Authorized Code but without a client id. The default is false

// A JSON array containing a list of the JWS alg values (from the [IANA.JOSE.ALGS] registry) supported by the authorization server for DPoP proof JWTs.
dpop_signing_alg_values_supported?: string[];
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we create a type for this and if exhaustive use the type, if not use the union of the type and string?

packages/issuer-rest/lib/IssuerTokenEndpoint.ts Outdated Show resolved Hide resolved
packages/siop-oid4vp/lib/request-object/Payload.ts Outdated Show resolved Hide resolved
Copy link
Collaborator

@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.

Very nice martin!

return this.sendAuthCode(requestTokenURL, accessTokenRequest);
let dPoP: string | undefined;
if (createDPoPOptions?.dPoPSigningAlgValuesSupported && createDPoPOptions.dPoPSigningAlgValuesSupported.length > 0) {
const htu = requestTokenURL.split('?')[0].split('#')[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we extract this into an util?

private async sendAuthCode(
requestTokenURL: string,
accessTokenRequest: AccessTokenRequest,
options?: { dPoP?: string },
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we allow passing customHeaders here as well? So if other extensions are needed we can add it over time without needing to add them? Not sure just thinking here

@@ -89,6 +91,7 @@ export class CredentialRequestClient {
context?: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
subjectIssuance?: ExperimentalSubjectIssuance;
createDPoPOptions?: CreateDPoPClientOptions;
Copy link
Collaborator

Choose a reason for hiding this comment

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

As this is inside an opts object, would just dpop as a key make more sense here / simpler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IMO not the dpop to me is the proof itself not to options for creating it.

Comment on lines 127 to 136
let dPoP: string | undefined;
if (createDPoPOptions) {
const htu = credentialEndpoint.split('?')[0].split('#')[0];
dPoP = createDPoPOptions
? await createDPoP({
...createDPoPOptions,
jwtPayloadProps: { ...createDPoPOptions.jwtPayloadProps, htu, htm: 'POST', accessToken: requestToken },
})
: undefined;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I see this repeated quite often. Maybe we can do the htu extracting in the createDpop method. That way we can do:

const dpop = createDPoPOptions ? createDPoP() : undefined

/**
* Returns the current unix timestamp in seconds.
*/
function epochTime() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not dpop specific

}

export interface JwtIssuerCustom extends JwtIssuerBase {
method: 'custom';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is alg now optional in the jwt sign callback? As with custom it will still not be provided right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no custom will not do anything

}

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

Choose a reason for hiding this comment

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

Host is not always defined or the same url. Host can be 0.0.0.0 while you use a public url. I think this needs to be configured somewhere (i've bad issues with this in credo when deploying to a https server url)

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 am not sure about it. I don't mind adding it as a parameter to the handleTokenRequest, but it looks weird to have it pass as a parameter.

@nklomp You also had a question about this.
Please doublecheck whether request.protocol isn't already including the trailing :
expressjs/express#4697 (comment)
https://stackoverflow.com/questions/10183291/how-to-get-the-full-url-in-express/10185427#10185427

For now, I changed the implementation so that the access token endpoint URL can be passed; if passed as an argument, we use it. Otherwise, we fall back to the above behavior. We can also enforce passing it. What do you think?

@auer-martin auer-martin marked this pull request as ready for review July 30, 2024 09:44
Copy link
Contributor

@nklomp nklomp left a comment

Choose a reason for hiding this comment

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

Thanks @auer-martin . This probably became a way bigger PR than imagined. 😂

A few small remarks. My only bigger issue remaining is the retryDPoP result object. I think we should create a small type for it and I am also not sure about the current handling of these methods when the dpop create options are set and the dpop methods return ok: false.

nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
}

if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
Copy link
Contributor

Choose a reason for hiding this comment

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

This for sure will ensures the DPoPOpts is present :D

nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
}

if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
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
if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
if (response.successBody && createDPoPOpts && response.successBody.token_type !== 'DPoP') {

throw new Error('The DPoP nonce was not returned');
}

return { ok: true, dpopNonce: dPoPNonce } as const;
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 create a small type for this? The ok is not immediately clear. I think having the http status code instead of ok would be nicer. The 401 is because we handle it nicely I guess, but it could also happen with a 400. So probably just using the http status is quite useful

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 created a type. Hmmmmm, Not sure about the HTTP status return. Because then, at a higher level, you need to know which status codes trigger the retry.

The 401 is required for Resource Requests. https://www.rfc-editor.org/rfc/rfc9449.html#name-resource-server-provided-no

packages/client/lib/AccessTokenClient.ts Outdated Show resolved Hide resolved
const { nowSkewedPast, nowSkewedFuture } = getNowSkewed(options.now);
if (
// iat claim is to far in the future
nowSkewedPast - (options.maxIatAgeInSeconds ?? 300) > dPoPPayload.iat ||
Copy link
Contributor

Choose a reason for hiding this comment

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

I would make the defaults a bit smaller. Systems being of 5 minutes would be very bad. Suggest to use 60

packages/common/lib/index.ts Outdated Show resolved Hide resolved
packages/common/lib/index.ts Outdated Show resolved Hide resolved
*
* See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
*/
const DEFAULT_SKEW_TIME = 300;
Copy link
Contributor

Choose a reason for hiding this comment

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

See other remark. Suggest to lower this to 60

@auer-martin
Copy link
Contributor Author

auer-martin commented Jul 31, 2024

Thanks @auer-martin . This probably became a way bigger PR than imagined. 😂

It was indeed a lot more work and took much longer than originally expected 🤣.
I still need to test the integration with the PID issuer with these changes. Probably some small nits still need to be fixed than we can merge the PR.

Copy link
Collaborator

@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.

Tested the PR in our wallet and succesfully received credentials from the Funke PID issuer.

@TimoGlastra
Copy link
Collaborator

@nklomp leaving final review / approval to you

Copy link
Contributor

@nklomp nklomp left a comment

Choose a reason for hiding this comment

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

LGTM. Will merge

@nklomp nklomp merged commit 130b3db into Sphereon-Opensource:develop Aug 2, 2024
1 check 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.

3 participants