diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b3145..9011750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 837d91b..e1cc781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meeco/sd-jwt-vc", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meeco/sd-jwt-vc", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@meeco/sd-jwt": "^0.0.3" }, diff --git a/package.json b/package.json index 673b393..86d5632 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/util.spec.ts b/src/util.spec.ts index ba2d863..bded968 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -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', @@ -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}`); @@ -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', @@ -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}`); @@ -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( @@ -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); }); }); diff --git a/src/util.ts b/src/util.ts index 8a96baf..808b14d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -30,7 +30,7 @@ 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. */ @@ -38,7 +38,7 @@ export async function getIssuerPublicKeyFromWellKnownURI(sdJwtVC: JWT, issuerPat 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');