From 74e0684bb82becc7b0f8618c62210c1a2c6ccf02 Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Fri, 12 Jul 2024 14:36:32 +0200 Subject: [PATCH] feat: fetch entity statement chain Signed-off-by: Berend Sliedrecht --- biome.json | 1 + .../fetchEntityConfigurationChains.test.ts | 66 +++--- .../fetchEntityStatementChain.test.ts | 203 ++++++++++++++++++ .../utils/setupConfigurationChain.ts | 32 ++- .../fetchEntityConfigurationChains.ts | 11 +- .../fetchEntityStatementChain.ts | 62 ++++++ packages/core/src/entityStatement/index.ts | 3 + packages/core/src/jsonWeb/jsonWebToken.ts | 28 ++- packages/core/src/utils/url.ts | 7 + tsconfig.json | 5 +- 10 files changed, 384 insertions(+), 34 deletions(-) create mode 100644 packages/core/__tests__/fetchEntityStatementChain.test.ts create mode 100644 packages/core/src/entityStatement/fetchEntityStatementChain.ts diff --git a/biome.json b/biome.json index 7a664e0..51684bb 100644 --- a/biome.json +++ b/biome.json @@ -34,6 +34,7 @@ }, "linter": { "enabled": true, + "ignore": ["__tests__/*.ts"], "rules": { "recommended": true, "performance": { diff --git a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts index d790043..d0de893 100644 --- a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts +++ b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import nock from 'nock' -import { fetchEntityConfigurationChains } from '../src/entityConfiguration' +import { EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' import type { SignCallback, VerifyCallback } from '../src/utils' import { setupConfigurationChain } from './utils/setupConfigurationChain' @@ -13,8 +13,8 @@ describe('fetch entity configuration chains', () => { const leafEntityId = 'https://leaf.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes = [] - const claims = [] + const scopes: Array = [] + const claims: Array = [] const configurations = await setupConfigurationChain( [{ entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { entityId: trustAnchorEntityId }], @@ -37,10 +37,10 @@ describe('fetch entity configuration chains', () => { }) assert.strictEqual(trustChains.length, 1) - assert.strictEqual(trustChains[0].length, 2) + assert.strictEqual(trustChains[0]!.length, 2) - assert.deepStrictEqual(trustChains[0][0], claims[0]) - assert.deepStrictEqual(trustChains[0][1], claims[1]) + assert.deepStrictEqual(trustChains[0]![0], claims[0]) + assert.deepStrictEqual(trustChains[0]![1], claims[1]) for (const scope of scopes) { scope.done() @@ -52,8 +52,8 @@ describe('fetch entity configuration chains', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes = [] - const claims = [] + const scopes: Array = [] + const claims: Array = [] const configurations = await setupConfigurationChain( [ @@ -83,11 +83,11 @@ describe('fetch entity configuration chains', () => { }) assert.strictEqual(trustChains.length, 1) - assert.strictEqual(trustChains[0].length, 3) + assert.strictEqual(trustChains[0]!.length, 3) - assert.deepStrictEqual(trustChains[0][0], claims[0]) - assert.deepStrictEqual(trustChains[0][1], claims[1]) - assert.deepStrictEqual(trustChains[0][2], claims[2]) + assert.deepStrictEqual(trustChains[0]![0], claims[0]) + assert.deepStrictEqual(trustChains[0]![1], claims[1]) + assert.deepStrictEqual(trustChains[0]![2], claims[2]) for (const scope of scopes) { scope.done() @@ -99,8 +99,8 @@ describe('fetch entity configuration chains', () => { const trustAnchorEntityId = 'https://trust.example.org' const superiorTrustAnchorEntityId = 'https://trust.superior.example.org' - const scopes = [] - const claims = [] + const scopes: Array = [] + const claims: Array = [] const configurations = await setupConfigurationChain( [ @@ -130,10 +130,10 @@ describe('fetch entity configuration chains', () => { }) assert.strictEqual(trustChains.length, 1) - assert.strictEqual(trustChains[0].length, 2) + assert.strictEqual(trustChains[0]!.length, 2) - assert.deepStrictEqual(trustChains[0][0], claims[0]) - assert.deepStrictEqual(trustChains[0][1], claims[1]) + assert.deepStrictEqual(trustChains[0]![0], claims[0]) + assert.deepStrictEqual(trustChains[0]![1], claims[1]) for (const scope of scopes) { scope.done() @@ -147,8 +147,8 @@ describe('fetch entity configuration chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const scopes = [] - const claims = [] + const scopes: Array = [] + const claims: Array = [] const configurations = await setupConfigurationChain( [ @@ -186,8 +186,8 @@ describe('fetch entity configuration chains', () => { }) assert.strictEqual(trustChains.length, 2) - assert.strictEqual(trustChains[0].length, 3) - assert.strictEqual(trustChains[1].length, 3) + assert.strictEqual(trustChains[0]!.length, 3) + assert.strictEqual(trustChains[1]!.length, 3) for (const scope of scopes) { scope.done() @@ -201,8 +201,8 @@ describe('fetch entity configuration chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const scopes = [] - const claims = [] + const scopes: Array = [] + const claims: Array = [] const configurations = await setupConfigurationChain( [ @@ -240,7 +240,7 @@ describe('fetch entity configuration chains', () => { }) assert.strictEqual(trustChains.length, 1) - assert.strictEqual(trustChains[0].length, 3) + assert.strictEqual(trustChains[0]!.length, 3) for (const scope of scopes) { scope.done() @@ -248,7 +248,7 @@ describe('fetch entity configuration chains', () => { }) it('should not fetch an entity configuration chain when no authority_hints are found', async () => { - const scopes = [] + const scopes: Array = [] const configurations = await setupConfigurationChain([{ entityId: 'https://leaf.example.org' }], signJwtCallback) @@ -262,7 +262,7 @@ describe('fetch entity configuration chains', () => { const trustChains = await fetchEntityConfigurationChains({ verifyJwtCallback, - leafEntityId: configurations[0].entityId, + leafEntityId: configurations[0]!.entityId, trustAnchorEntityIds: ['https://trust.example.org'], }) @@ -274,7 +274,7 @@ describe('fetch entity configuration chains', () => { }) it('should not fetch an entity configuration chain when a loop is found', async () => { - const scopes = [] + const scopes: Array = [] const leafEntityId = 'https://leaf.example.org' const intermediateOneEntityId = 'https://intermediate.one.example.org' @@ -306,7 +306,7 @@ describe('fetch entity configuration chains', () => { const trustChains = await fetchEntityConfigurationChains({ verifyJwtCallback, - leafEntityId: configurations[0].entityId, + leafEntityId: configurations[0]!.entityId, trustAnchorEntityIds: [trustAnchorEntityId], }) @@ -316,4 +316,14 @@ describe('fetch entity configuration chains', () => { scope.done() } }) + + it('should not fetch an entity configuration chain when no trust anchors are provided', async () => { + await assert.rejects( + fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId: 'https://example.org', + trustAnchorEntityIds: [], + }) + ) + }) }) diff --git a/packages/core/__tests__/fetchEntityStatementChain.test.ts b/packages/core/__tests__/fetchEntityStatementChain.test.ts new file mode 100644 index 0000000..4ccea2d --- /dev/null +++ b/packages/core/__tests__/fetchEntityStatementChain.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import nock from 'nock' +import { type EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' +import { fetchEntityStatementChain } from '../src/entityStatement' +import type { SignCallback, VerifyCallback } from '../src/utils' +import { setupConfigurationChain } from './utils/setupConfigurationChain' + +describe('fetch entity statement chain', () => { + const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) + + it('should fetch a basic entity statement chain', async () => { + const leafEntityId = 'https://leaf.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const scopes: Array = [] + const claims: Array = [] + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, + { entityId: trustAnchorEntityId, subordinates: [leafEntityId] }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims, subordinateStatements } of configurations) { + claims.push(configurationClaims) + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + for (const { jwt, entityId } of subordinateStatements ?? []) { + scope.get('/fetch').query({ iss: configurationClaims.iss, sub: entityId }).reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + } + + scopes.push(scope) + } + + const chains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(chains.length, 1) + assert.strictEqual(chains[0]!.length, 2) + + assert.deepStrictEqual(chains[0]![0], claims[0]) + assert.deepStrictEqual(chains[0]![1], claims[1]) + + const statements = await fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: chains[0]!, + }) + + assert.strictEqual(statements.length, 2) + + assert.deepStrictEqual(statements[0]!.iss, trustAnchorEntityId) + assert.deepStrictEqual(statements[0]!.sub, leafEntityId) + + assert.deepStrictEqual(statements[1]!.iss, trustAnchorEntityId) + assert.deepStrictEqual(statements[1]!.sub, trustAnchorEntityId) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should fetch a basic entity statement chain of 3 entities', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const scopes: Array = [] + const claims: Array = [] + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [leafEntityId], + }, + { entityId: trustAnchorEntityId, subordinates: [intermediateEntityId] }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims, subordinateStatements } of configurations) { + claims.push(configurationClaims) + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + for (const { jwt, entityId } of subordinateStatements ?? []) { + scope.get('/fetch').query({ iss: configurationClaims.iss, sub: entityId }).reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + } + + scopes.push(scope) + } + + const chains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(chains.length, 1) + assert.strictEqual(chains[0]!.length, 3) + + assert.deepStrictEqual(chains[0]![0], claims[0]) + assert.deepStrictEqual(chains[0]![1], claims[1]) + assert.deepStrictEqual(chains[0]![2], claims[2]) + + const statements = await fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: chains[0]!, + }) + + assert.strictEqual(statements.length, 3) + + assert.deepStrictEqual(statements[0]!.iss, intermediateEntityId) + assert.deepStrictEqual(statements[0]!.sub, leafEntityId) + + assert.deepStrictEqual(statements[1]!.iss, trustAnchorEntityId) + assert.deepStrictEqual(statements[1]!.sub, intermediateEntityId) + + assert.deepStrictEqual(statements[2]!.iss, trustAnchorEntityId) + assert.deepStrictEqual(statements[2]!.sub, trustAnchorEntityId) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should not fetch an entity statement chain when no source_endpoint is found', async () => { + const leafEntityId = 'https://leaf.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const scopes: Array = [] + const claims: Array = [] + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, + { + entityId: trustAnchorEntityId, + subordinates: [leafEntityId], + includeSourceEndpoint: false, + }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + claims.push(configurationClaims) + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + } + + const chains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(chains.length, 1) + assert.strictEqual(chains[0]!.length, 2) + + assert.deepStrictEqual(chains[0]![0], claims[0]) + assert.deepStrictEqual(chains[0]![1], claims[1]) + + await assert.rejects( + fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: chains[0]!, + }) + ) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should not fetch an entity statement chain when no entity configurations are provided', async () => { + await assert.rejects( + fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: [], + }) + ) + }) +}) diff --git a/packages/core/__tests__/utils/setupConfigurationChain.ts b/packages/core/__tests__/utils/setupConfigurationChain.ts index 450369a..609bfc8 100644 --- a/packages/core/__tests__/utils/setupConfigurationChain.ts +++ b/packages/core/__tests__/utils/setupConfigurationChain.ts @@ -3,14 +3,17 @@ import { type EntityConfigurationHeaderOptions, createEntityConfiguration, } from '../../src/entityConfiguration' +import { createEntityStatement } from '../../src/entityStatement' import type { JsonWebKeySetOptions } from '../../src/jsonWeb' import type { SignCallback } from '../../src/utils' type SetupConfigurationChainOptions = { entityId: string authorityHints?: Array + subordinates?: Array jwks?: JsonWebKeySetOptions kid?: string + includeSourceEndpoint?: boolean } export const setupConfigurationChain = async ( @@ -21,8 +24,9 @@ export const setupConfigurationChain = async ( claims: EntityConfigurationClaimsOptions jwt: string entityId: string + subordinateStatements?: Array<{ entityId: string; jwt: string }> }> = [] - for (const { entityId, authorityHints, jwks, kid } of options) { + for (const { entityId, authorityHints, jwks, kid, subordinates, includeSourceEndpoint = true } of options) { const claims: EntityConfigurationClaimsOptions = { iss: entityId, sub: entityId, @@ -30,10 +34,12 @@ export const setupConfigurationChain = async ( iat: new Date(), jwks: jwks ?? { keys: [{ kid: 'a', kty: 'EC' }] }, authority_hints: authorityHints, + source_endpoint: `${entityId}/fetch`, } // fix so `undefined` is not in the expected claims if (!authorityHints) delete claims.authority_hints + if (!includeSourceEndpoint) delete claims.source_endpoint const header: EntityConfigurationHeaderOptions = { kid: kid ?? 'a', @@ -46,7 +52,29 @@ export const setupConfigurationChain = async ( signJwtCallback, }) - chainData.push({ claims, jwt, entityId }) + const subordinateStatements = [] + for (const sub of subordinates ?? []) { + const entityStatementJwt = await createEntityStatement({ + signJwtCallback, + jwk: claims.jwks.keys[0]!, + claims: { + jwks: { keys: [] }, + iss: entityId, + sub, + exp: new Date(), + iat: new Date(), + }, + }) + + subordinateStatements.push({ entityId: sub, jwt: entityStatementJwt }) + } + + chainData.push({ + claims, + jwt, + entityId, + subordinateStatements, + }) } return chainData diff --git a/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts index cd18f10..941f7c4 100644 --- a/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts +++ b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts @@ -1,8 +1,10 @@ +import { ErrorCode } from '../error/ErrorCode' +import { OpenIdFederationError } from '../error/OpenIdFederationError' import type { VerifyCallback } from '../utils' import type { EntityConfigurationClaims } from './entityConfigurationClaims' import { fetchEntityConfiguration } from './fetchEntityConfiguration' -type FetchEntityConfigurationChainOptions = { +export type FetchEntityConfigurationChainOptions = { leafEntityId: string trustAnchorEntityIds: Array verifyJwtCallback: VerifyCallback @@ -18,6 +20,13 @@ type FetchEntityConfigurationChainOptions = { export const fetchEntityConfigurationChains = async ( options: FetchEntityConfigurationChainOptions ): Promise>> => { + if (options.trustAnchorEntityIds.length === 0) { + throw new OpenIdFederationError( + ErrorCode.Validation, + 'Cannot establish a configuration chain for zero trust anchors' + ) + } + // inner function so we can expose a more user-friendly API const __fetchEntityConfigurationChains = async ( currentEntityId: string, diff --git a/packages/core/src/entityStatement/fetchEntityStatementChain.ts b/packages/core/src/entityStatement/fetchEntityStatementChain.ts new file mode 100644 index 0000000..70af6f7 --- /dev/null +++ b/packages/core/src/entityStatement/fetchEntityStatementChain.ts @@ -0,0 +1,62 @@ +import type { EntityConfigurationClaims } from '../entityConfiguration' +import { ErrorCode } from '../error/ErrorCode' +import { OpenIdFederationError } from '../error/OpenIdFederationError' +import type { VerifyCallback } from '../utils' +import type { EntityStatementClaims } from './entityStatementClaims' +import { fetchEntityStatement } from './fetchEntityStatement' + +export type FetchEntityStatementChainOptions = { + entityConfigurations: Array + verifyJwtCallback: VerifyCallback +} + +export const fetchEntityStatementChain = async ({ + verifyJwtCallback, + entityConfigurations, +}: FetchEntityStatementChainOptions) => { + if (entityConfigurations.length === 0) { + throw new OpenIdFederationError( + ErrorCode.Validation, + 'Cannot establish a statement chain for zero entity configurations' + ) + } + + // Reverse the configurations as we have to fetch the entity configuration of the trust anchor first + // A copy is done here as the reverse method mutates in place + const reversedConfigurations = [...entityConfigurations].reverse() + + const promises: Array> = [] + + for (let i = 0; i < reversedConfigurations.length; i++) { + // Get the configuration of the issuer + const configuration = reversedConfigurations[i] + + // Get the configuration of the subject, i.e. the next in the chain + const subjectConfiguration = reversedConfigurations[i + 1] + + // If we have no subject configuration we have reached the leaf entity as the `configuration` + if (!subjectConfiguration) continue + + const fetchEndpoint = configuration?.source_endpoint + + if (!fetchEndpoint) { + throw new OpenIdFederationError( + ErrorCode.Validation, + `No source endpoint found for configuration for: '${configuration?.sub}'` + ) + } + + promises.push( + fetchEntityStatement({ + verifyJwtCallback, + endpoint: fetchEndpoint, + iss: configuration.iss, + sub: subjectConfiguration.sub, + issEntityConfiguration: configuration, + }) + ) + } + + // The trust anchors (i.e. the last item of the list) entity configuration will be used instead of a statement issued by a superior + return [entityConfigurations[entityConfigurations.length - 1], ...(await Promise.all(promises))].reverse() +} diff --git a/packages/core/src/entityStatement/index.ts b/packages/core/src/entityStatement/index.ts index 76fd6f9..28ff9f5 100644 --- a/packages/core/src/entityStatement/index.ts +++ b/packages/core/src/entityStatement/index.ts @@ -1,3 +1,6 @@ export * from './entityStatementClaims' +export * from './entityStatementHeader' + export * from './createEntityStatement' export * from './fetchEntityStatement' +export * from './fetchEntityStatementChain' diff --git a/packages/core/src/jsonWeb/jsonWebToken.ts b/packages/core/src/jsonWeb/jsonWebToken.ts index c5ba771..e90596f 100644 --- a/packages/core/src/jsonWeb/jsonWebToken.ts +++ b/packages/core/src/jsonWeb/jsonWebToken.ts @@ -24,8 +24,34 @@ export const jsonWebTokenSchema = < headerSchema: defaultSchema as unknown as HS, } ) => - z.string().transform((s) => { + z.string().transform((s, ctx) => { const [header, claims, signature] = s.split('.') + + if (!header) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JWT does not contain a header parameter', + }) + return z.NEVER + } + + if (!claims) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JWT does not contain a payload', + }) + return z.NEVER + } + + if (!signature) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JWT does not contain a signature', + }) + + return z.NEVER + } + const decodedHeader = Buffer.from(header, 'base64url').toString() const decodedClaims = Buffer.from(claims, 'base64url').toString() const decodedSignature = Buffer.from(signature, 'base64url') diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 8073321..d54bc55 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -1,3 +1,6 @@ +import { ErrorCode } from '../error/ErrorCode' +import { OpenIdFederationError } from '../error/OpenIdFederationError' + /** * * Add paths to a url @@ -18,6 +21,10 @@ */ export const addPaths = (baseUrl: string, ...paths: Array) => { const [scheme, rest] = baseUrl.split('://') + if (!rest) { + throw new OpenIdFederationError(ErrorCode.Validation, 'not a valid URL') + } + const urlWithoutScheme = rest // Get all base the parts .split('/') diff --git a/tsconfig.json b/tsconfig.json index 3cb2e95..bdaa9d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "forceConsistentCasingInFileNames": true, "strict": true, "declaration": true, - "sourceMap": true + "sourceMap": true, + "noUncheckedIndexedAccess": true }, - "exclude": ["**/tests", "**/build"] + "exclude": ["**/__tests__", "**/build"] }