diff --git a/biome.json b/biome.json index 1ae4e93..7a664e0 100644 --- a/biome.json +++ b/biome.json @@ -36,6 +36,9 @@ "enabled": true, "rules": { "recommended": true, + "performance": { + "noDelete": { "level": "off" } + }, "style": { "useNodeAssertStrict": { "level": "error", "fix": "unsafe" }, "useNodejsImportProtocol": { "level": "off" } diff --git a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts new file mode 100644 index 0000000..d790043 --- /dev/null +++ b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts @@ -0,0 +1,319 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import nock from 'nock' +import { fetchEntityConfigurationChains } from '../src/entityConfiguration' +import type { SignCallback, VerifyCallback } from '../src/utils' +import { setupConfigurationChain } from './utils/setupConfigurationChain' + +describe('fetch entity configuration chains', () => { + const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) + + it('should fetch a basic entity configuration chain of 2 entities', async () => { + const leafEntityId = 'https://leaf.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const scopes = [] + const claims = [] + + const configurations = await setupConfigurationChain( + [{ entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { entityId: trustAnchorEntityId }], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + claims.push(configurationClaims) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0].length, 2) + + assert.deepStrictEqual(trustChains[0][0], claims[0]) + assert.deepStrictEqual(trustChains[0][1], claims[1]) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should fetch a basic entity configuration 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 = [] + const claims = [] + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + }, + { entityId: trustAnchorEntityId }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + claims.push(configurationClaims) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(trustChains.length, 1) + 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]) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should stop to fetch the entity configurations for a chain when a trust anchor is hit', async () => { + const leafEntityId = 'https://leaf.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + const superiorTrustAnchorEntityId = 'https://trust.superior.example.org' + + const scopes = [] + const claims = [] + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, + { + entityId: trustAnchorEntityId, + authorityHints: [superiorTrustAnchorEntityId], + }, + { entityId: superiorTrustAnchorEntityId }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + claims.push(configurationClaims) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0].length, 2) + + assert.deepStrictEqual(trustChains[0][0], claims[0]) + assert.deepStrictEqual(trustChains[0][1], claims[1]) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should fetch two entity configuration chains of 2 entities each', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateOneEntityId = 'https://intermediate.one.example.org' + const intermediateTwoEntityId = 'https://intermediate.two.example.org' + const trustAnchorOneEntityId = 'https://trust.one.example.org' + const trustAnchorTwoEntityId = 'https://trust.two.example.org' + + const scopes = [] + const claims = [] + + const configurations = await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], + }, + { + entityId: intermediateOneEntityId, + authorityHints: [trustAnchorOneEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [trustAnchorTwoEntityId], + }, + { entityId: trustAnchorOneEntityId }, + { entityId: trustAnchorTwoEntityId }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + claims.push(configurationClaims) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorOneEntityId, trustAnchorTwoEntityId], + }) + + assert.strictEqual(trustChains.length, 2) + assert.strictEqual(trustChains[0].length, 3) + assert.strictEqual(trustChains[1].length, 3) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should fetch one entity configuration chains when one trust anchor is provided', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateOneEntityId = 'https://intermediate.one.example.org' + const intermediateTwoEntityId = 'https://intermediate.two.example.org' + const trustAnchorOneEntityId = 'https://trust.one.example.org' + const trustAnchorTwoEntityId = 'https://trust.two.example.org' + + const scopes = [] + const claims = [] + + const configurations = await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], + }, + { + entityId: intermediateOneEntityId, + authorityHints: [trustAnchorOneEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [trustAnchorTwoEntityId], + }, + { entityId: trustAnchorOneEntityId }, + { entityId: trustAnchorTwoEntityId }, + ], + signJwtCallback + ) + + for (const { entityId, jwt, claims: configurationClaims } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + claims.push(configurationClaims) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId, + trustAnchorEntityIds: [trustAnchorOneEntityId], + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0].length, 3) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should not fetch an entity configuration chain when no authority_hints are found', async () => { + const scopes = [] + + const configurations = await setupConfigurationChain([{ entityId: 'https://leaf.example.org' }], signJwtCallback) + + for (const { entityId, jwt } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId: configurations[0].entityId, + trustAnchorEntityIds: ['https://trust.example.org'], + }) + + assert.strictEqual(trustChains.length, 0) + + for (const scope of scopes) { + scope.done() + } + }) + + it('should not fetch an entity configuration chain when a loop is found', async () => { + const scopes = [] + + const leafEntityId = 'https://leaf.example.org' + const intermediateOneEntityId = 'https://intermediate.one.example.org' + const intermediateTwoEntityId = 'https://intermediate.two.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const configurations = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateOneEntityId] }, + { + entityId: intermediateOneEntityId, + authorityHints: [intermediateTwoEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [intermediateOneEntityId], + }, + ], + signJwtCallback + ) + + for (const { entityId, jwt } of configurations) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + scopes.push(scope) + } + + const trustChains = await fetchEntityConfigurationChains({ + verifyJwtCallback, + leafEntityId: configurations[0].entityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(trustChains.length, 0) + + for (const scope of scopes) { + scope.done() + } + }) +}) diff --git a/packages/core/__tests__/utils/setupConfigurationChain.ts b/packages/core/__tests__/utils/setupConfigurationChain.ts new file mode 100644 index 0000000..450369a --- /dev/null +++ b/packages/core/__tests__/utils/setupConfigurationChain.ts @@ -0,0 +1,53 @@ +import { + type EntityConfigurationClaimsOptions, + type EntityConfigurationHeaderOptions, + createEntityConfiguration, +} from '../../src/entityConfiguration' +import type { JsonWebKeySetOptions } from '../../src/jsonWeb' +import type { SignCallback } from '../../src/utils' + +type SetupConfigurationChainOptions = { + entityId: string + authorityHints?: Array + jwks?: JsonWebKeySetOptions + kid?: string +} + +export const setupConfigurationChain = async ( + options: Array, + signJwtCallback: SignCallback +) => { + const chainData: Array<{ + claims: EntityConfigurationClaimsOptions + jwt: string + entityId: string + }> = [] + for (const { entityId, authorityHints, jwks, kid } of options) { + const claims: EntityConfigurationClaimsOptions = { + iss: entityId, + sub: entityId, + exp: new Date(), + iat: new Date(), + jwks: jwks ?? { keys: [{ kid: 'a', kty: 'EC' }] }, + authority_hints: authorityHints, + } + + // fix so `undefined` is not in the expected claims + if (!authorityHints) delete claims.authority_hints + + const header: EntityConfigurationHeaderOptions = { + kid: kid ?? 'a', + typ: 'entity-statement+jwt', + } + + const jwt = await createEntityConfiguration({ + claims, + header, + signJwtCallback, + }) + + chainData.push({ claims, jwt, entityId }) + } + + return chainData +} diff --git a/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts new file mode 100644 index 0000000..cd18f10 --- /dev/null +++ b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts @@ -0,0 +1,67 @@ +import type { VerifyCallback } from '../utils' +import type { EntityConfigurationClaims } from './entityConfigurationClaims' +import { fetchEntityConfiguration } from './fetchEntityConfiguration' + +type FetchEntityConfigurationChainOptions = { + leafEntityId: string + trustAnchorEntityIds: Array + verifyJwtCallback: VerifyCallback +} + +/** + * + * Fetch an entity configuration chain until one of the trust anchors is hit (per chain) + * + * returns a list of chains (a list of lists of entity configurations) + * + */ +export const fetchEntityConfigurationChains = async ( + options: FetchEntityConfigurationChainOptions +): Promise>> => { + // inner function so we can expose a more user-friendly API + const __fetchEntityConfigurationChains = async ( + currentEntityId: string, + path: Array = [] + ): Promise>> => { + // Fetch the entity configuration of the current entity id + const configuration = await fetchEntityConfiguration({ + verifyJwtCallback: options.verifyJwtCallback, + entityId: currentEntityId, + }) + + // Append the configuration to the visited paths + const localPath = [...path, configuration] + + const allPaths: Array> = [] + + // Found a trust anchor + if (options.trustAnchorEntityIds.includes(configuration.sub)) { + allPaths.push(localPath) + } + + // Fetch the superior entity configurations + if (configuration.authority_hints) { + const promises: Array>>> = [] + + for (const superior of configuration.authority_hints) { + // Do not fetch superiors we already have in the local map to avoid loops + if (localPath.map((v) => v.sub).includes(superior)) continue + + // Recursively fetch the entity chains + promises.push(__fetchEntityConfigurationChains(superior, localPath)) + } + + const results = await Promise.allSettled(promises) + for (const res of results) { + if (res.status === 'fulfilled') { + allPaths.push(...res.value) + } + } + } + + // Return a list of chains + return allPaths + } + + return __fetchEntityConfigurationChains(options.leafEntityId) +} diff --git a/packages/core/src/entityConfiguration/index.ts b/packages/core/src/entityConfiguration/index.ts index 99b0b38..2a4c4be 100644 --- a/packages/core/src/entityConfiguration/index.ts +++ b/packages/core/src/entityConfiguration/index.ts @@ -2,4 +2,5 @@ export * from './entityConfigurationClaims' export * from './entityConfigurationHeader' export * from './fetchEntityConfiguration' +export * from './fetchEntityConfigurationChains' export * from './createEntityConfiguration' diff --git a/packages/core/src/jsonWeb/jsonWebKeySet.ts b/packages/core/src/jsonWeb/jsonWebKeySet.ts index 2c7738a..5ab069c 100644 --- a/packages/core/src/jsonWeb/jsonWebKeySet.ts +++ b/packages/core/src/jsonWeb/jsonWebKeySet.ts @@ -5,4 +5,6 @@ export const jsonWebKeySetSchema = z.object({ keys: z.array(jsonWebKeySchema), }) -export type JsonWebKeySet = z.input +export type JsonWebKeySetOptions = z.input + +export type JsonWebKeySet = z.output