Skip to content

Commit

Permalink
Fix getIssuerPublicKeyFromWellKnownURI utility function.
Browse files Browse the repository at this point in the history
Version: 0.0.6
  • Loading branch information
Linas Išganaitis committed Nov 25, 2023
1 parent 7316561 commit a1b46d6
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 136 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project (loosely) adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.0.6 - 2023-11-25

### Fixed

- `getIssuerPublicKeyFromWellKnownURI` utility function jwt-issuer discovery URI forming
- don't ignore `/jwt-issuer` path part that is static and should always be present as per [specification](https://www.ietf.org/archive/id/draft-terbu-oauth-sd-jwt-vc-00.html#section-5)

## 0.0.5 - 2023-11-17

### Changed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@meeco/sd-jwt-vc",
"version": "0.0.5",
"version": "0.0.6",
"description": "SD-JWT VC implementation in typescript",
"scripts": {
"build": "tsc",
Expand Down
216 changes: 85 additions & 131 deletions src/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,17 @@ import { getIssuerPublicKeyFromWellKnownURI } from './util';
describe('getIssuerPublicKeyFromIss', () => {
const sdJwtVC =
'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSJ9.eyJpYXQiOjE2OTU2ODI0MDg4NTcsImNuZiI6eyJqd2siOnsia3R5IjoiRUMiLCJ4Ijoickg3T2xtSHFkcE5PUjJQMjhTN3Vyb3hBR2sxMzIxTnNneGdwNHhfUGlldyIsInkiOiJXR0NPSm1BN25Uc1hQOUF6X210TnkwalQ3bWRNQ21TdFRmU080RGpSc1NnIiwiY3J2IjoiUC0yNTYifX0sImlzcyI6Imh0dHBzOi8vdmFsaWQuaXNzdWVyLnVybCIsInR5cGUiOiJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsInN0YXR1cyI6eyJpZHgiOiJzdGF0dXNJbmRleCIsInVyaSI6Imh0dHBzOi8vdmFsaWQuc3RhdHVzLnVybCJ9LCJwZXJzb24iOnsiX3NkIjpbImNRbzBUTTdfZEZXb2djcUpUTlJPeGJUTnI1T0VaakNWUHNlVVBVN0ROa3ciLCJZY3BHVTNKTDFvS0NoOXY4VjAwQmxWLTQtZTFWN1h0U1BvYUtra2RuZG1BIl19fQ.iPmq7Fv-pxS5NgTpH5xUarz6uG1MIphHy4q5mWdLBJRfp6ER2eG306WeHhCBoDzrYURgWZiEySnTEBDbD2HfCA';
const issuerPath = 'jwt-issuer/user/1234';
const issuerPath = 'user/1234';

const jwt = decodeJWT(sdJwtVC);
const url = new URL(jwt.payload.iss);

const baseUrl = `${url.protocol}//${url.host}`;
const jwtIssuerWellKnownUrl = `${baseUrl}/.well-known/jwt-issuer/${issuerPath}`;
const issuerUrl = `${baseUrl}/${issuerPath}`;
const jwksUri = `${issuerUrl}/my_public_keys.jwks`;

it('should get issuer public key JWK from jwks_uri', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const jwksUri = `${issuerUrl}/my_public_keys.jwks`;
const jwks = {
keys: [
{
kty: 'RSA',
kid: 'test-key',
n: 'test-n',
e: 'AQAB',
},
],
};
const jwksResponseJson = {
issuer: jwt.payload.iss,
jwks_uri: jwksUri,
};
const expectedJWK: JWK = {
kty: 'RSA',
kid: 'test-key',
Expand All @@ -36,13 +24,27 @@ describe('getIssuerPublicKeyFromIss', () => {
};

(global as any).fetch = jest.fn().mockImplementation((url: string) => {
if (url === issuerUrl) {
if (url === jwtIssuerWellKnownUrl) {
return Promise.resolve({
json: () => Promise.resolve(jwksResponseJson),
json: () =>
Promise.resolve({
issuer: jwt.payload.iss,
jwks_uri: jwksUri,
}),
});
} else if (url === jwksUri) {
return Promise.resolve({
json: () => Promise.resolve(jwks),
json: () =>
Promise.resolve({
keys: [
{
kty: 'RSA',
kid: 'test-key',
n: 'test-n',
e: 'AQAB',
},
],
}),
});
} else {
throw new SDJWTVCError(`Unexpected URL: ${url}`);
Expand All @@ -52,31 +54,12 @@ describe('getIssuerPublicKeyFromIss', () => {
const result = await getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath);

expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenNthCalledWith(1, issuerUrl);
expect(fetch).toHaveBeenNthCalledWith(1, jwtIssuerWellKnownUrl);
expect(fetch).toHaveBeenNthCalledWith(2, jwksUri);
expect(result).toEqual(expectedJWK);
});

it('should get issuer public key JWK from jwks', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const jwks = {
keys: [
{
kty: 'RSA',
kid: 'test-key',
n: 'test-n',
e: 'AQAB',
},
],
};
const responseJson = {
issuer: jwt.payload.iss,
jwks: jwks,
};
const expectedJWK: JWK = {
kty: 'RSA',
kid: 'test-key',
Expand All @@ -85,9 +68,22 @@ describe('getIssuerPublicKeyFromIss', () => {
};

(global as any).fetch = jest.fn().mockImplementation((url: string) => {
if (url === issuerUrl) {
if (url === jwtIssuerWellKnownUrl) {
return Promise.resolve({
json: () => Promise.resolve(responseJson),
json: () =>
Promise.resolve({
issuer: jwt.payload.iss,
jwks: {
keys: [
{
kty: 'RSA',
kid: 'test-key',
n: 'test-n',
e: 'AQAB',
},
],
},
}),
});
} else {
throw new SDJWTVCError(`Unexpected URL: ${url}`);
Expand All @@ -97,163 +93,120 @@ describe('getIssuerPublicKeyFromIss', () => {
const result = await getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
expect(result).toEqual(expectedJWK);
});

it('should throw an error if issuer response is not found', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(null),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow('Issuer response not found');

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if issuer response does not contain the correct issuer', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const responseJson = {
issuer: 'wrong-issuer',
};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(responseJson),
json: () =>
Promise.resolve({
issuer: 'wrong-issuer',
}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
"The response from the issuer's well-known URI does not match the expected issuer",
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if issuer public key JWK is not found', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const responseJson = {
issuer: jwt.payload.iss,
jwks: {
keys: [],
},
};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(responseJson),
json: () =>
Promise.resolve({
issuer: jwt.payload.iss,
jwks: {
keys: [],
},
}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
'Issuer public key JWK not found',
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if issuer response does not contain jwks or jwks_uri', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const responseJson = {
issuer: jwt.payload.iss,
};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(responseJson),
json: () =>
Promise.resolve({
issuer: jwt.payload.iss,
}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
'Issuer response does not contain jwks or jwks_uri',
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if jwks_uri response does not contain the correct issuer', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const jwksUri = `${issuerUrl}/jwks_uri`;
const jwksResponseJson = {
issuer: 'wrong-issuer',
jwks_uri: jwksUri,
};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(jwksResponseJson),
json: () =>
Promise.resolve({
issuer: 'wrong-issuer',
jwks_uri: jwksUri,
}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
"The response from the issuer's well-known URI does not match the expected issuer",
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if well-known retrun empty response', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const jwksResponseJson = {};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(jwksResponseJson),
json: () => Promise.resolve({}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
"The response from the issuer's well-known URI does not match the expected issuer",
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if well-known retrun 404', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;
const jwksResponseJson = {
status: 404,
json: () => Promise.reject(new SDJWTVCError('Issuer response not found')),
};

(global as any).fetch = jest.fn().mockResolvedValueOnce({
json: () => Promise.resolve(jwksResponseJson),
json: () =>
Promise.resolve({
status: 404,
json: () => Promise.reject(new SDJWTVCError('Issuer response not found')),
}),
});

await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
"The response from the issuer's well-known URI does not match the expected issuer",
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});

it('should throw an error if well-known retrun invalid response', async () => {
const jwt = decodeJWT(sdJwtVC);
const wellKnownPath = `.well-known/${issuerPath}`;
const url = new URL(jwt.payload.iss);
const baseUrl = `${url.protocol}//${url.host}`;
const issuerUrl = `${baseUrl}/${wellKnownPath}`;

(global as any).fetch = jest.fn().mockResolvedValueOnce({
invalid: () =>
Promise.resolve(
Expand All @@ -264,7 +217,8 @@ describe('getIssuerPublicKeyFromIss', () => {
await expect(getIssuerPublicKeyFromWellKnownURI(sdJwtVC, issuerPath)).rejects.toThrow(
'Failed to fetch or parse the response from https://valid.issuer.url/.well-known/jwt-issuer/user/1234 as JSON. Error: response.json is not a function',
);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(issuerUrl);
expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownUrl);
});
});
4 changes: 2 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ export function isValidUrl(url: string): boolean {
/**
* Get the issuer public key from the issuer.
* @param sdJwtVC The SD-JWT to verify.
* @param issuerPath The issuer path postfix to .well-known/{issuerPath}, to get the issuer public key. e.g. 'jwt-issuer/user/1234'
* @param issuerPath The issuer path postfix to .well-known/jwt-issuer/{issuerPath}, to get the issuer public key. e.g. 'jwt-issuer/user/1234'
* @throws An error if the issuer public key cannot be fetched.
* @returns The issuer public key.
*/
export async function getIssuerPublicKeyFromWellKnownURI(sdJwtVC: JWT, issuerPath: string): Promise<JWK> {
const s = sdJwtVC.split(SD_JWT_FORMAT_SEPARATOR);
const jwt = decodeJWT(s.shift() || '');

const wellKnownPath = `.well-known/${issuerPath}`;
const wellKnownPath = `.well-known/jwt-issuer/${issuerPath}`;

if (!jwt.payload.iss || !isValidUrl(jwt.payload.iss)) {
throw new SDJWTVCError('Invalid issuer well-known URL');
Expand Down

0 comments on commit a1b46d6

Please sign in to comment.