From 12ca7096c23e6855ae110b46322f2e5e038d0c17 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 20 Jan 2025 14:25:30 +0100 Subject: [PATCH 1/9] feat: Working implementation for policies Signed-off-by: Tom Lanser --- package.json | 1 + .../fetchEntityConfigurationChains.test.ts | 143 +--- .../fetchEntityStatementChain.test.ts | 113 +-- .../fixtures/entityStatementFigure62.ts | 23 +- .../policyMerge.applyMetadataPolicy.test.ts | 195 +++++ ...erge.applyMetadataPolicyToMetadata.test.ts | 182 +++++ ...cyMerge.combineExistingPolicyRules.test.ts | 253 +++++++ .../core/__tests__/resolveTrustChains.test.ts | 669 ++++++++++++++++++ .../utils/setupConfigurationChain.ts | 99 ++- .../fetchEntityConfigurationChains.ts | 4 +- .../entityStatement/entityStatementClaims.ts | 2 +- .../fetchEntityStatementChain.ts | 8 +- packages/core/src/error/ErrorCode.ts | 3 - .../core/src/error/OpenIdFederationError.ts | 13 +- packages/core/src/error/PolicyErrorStage.ts | 7 + .../src/metadata/operator/MetadataOperator.ts | 4 +- .../src/metadata/operator/standard/add.ts | 10 +- .../src/metadata/operator/standard/default.ts | 7 +- .../metadata/operator/standard/essential.ts | 7 +- .../src/metadata/operator/standard/oneOf.ts | 10 +- .../metadata/operator/standard/subsetOf.ts | 10 +- .../metadata/operator/standard/supersetOf.ts | 10 +- .../src/metadata/operator/standard/value.ts | 7 +- .../utils/createPolicyOperatorSchema.ts | 53 +- packages/core/src/metadata/policy.ts | 69 +- packages/core/src/resolveTrustChains/index.ts | 1 + .../policies/MetadataHelper.ts | 38 + .../policies/applyMetadataPolicyToMetadata.ts | 147 ++++ .../combineExistingMetadataPolicyOperators.ts | 113 +++ .../policies/combineMetadataPolicies.ts | 82 +++ .../policies/errors/PolicyMergeError.ts | 17 + .../policies/errors/PolicyValidationError.ts | 15 + .../policies/errors/index.ts | 2 + .../src/resolveTrustChains/policies/index.ts | 3 + .../src/resolveTrustChains/policies/utils.ts | 36 + .../resolveTrustChains/resolveTrustChains.ts | 116 ++- packages/core/src/utils/data.ts | 15 + packages/core/src/utils/tryCatch.ts | 9 + packages/core/src/utils/url.ts | 4 +- 39 files changed, 2141 insertions(+), 359 deletions(-) create mode 100644 packages/core/__tests__/policyMerge.applyMetadataPolicy.test.ts create mode 100644 packages/core/__tests__/policyMerge.applyMetadataPolicyToMetadata.test.ts create mode 100644 packages/core/__tests__/policyMerge.combineExistingPolicyRules.test.ts create mode 100644 packages/core/__tests__/resolveTrustChains.test.ts delete mode 100644 packages/core/src/error/ErrorCode.ts create mode 100644 packages/core/src/error/PolicyErrorStage.ts create mode 100644 packages/core/src/resolveTrustChains/policies/MetadataHelper.ts create mode 100644 packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts create mode 100644 packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts create mode 100644 packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts create mode 100644 packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts create mode 100644 packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts create mode 100644 packages/core/src/resolveTrustChains/policies/errors/index.ts create mode 100644 packages/core/src/resolveTrustChains/policies/index.ts create mode 100644 packages/core/src/resolveTrustChains/policies/utils.ts create mode 100644 packages/core/src/utils/data.ts create mode 100644 packages/core/src/utils/tryCatch.ts diff --git a/package.json b/package.json index 77e7b13..ed81d2f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "style:check": "biome check . --unsafe", "style:fix": "pnpm style:check --write", "types:check": "tsc --noEmit", + "validate": "pnpm style:check && pnpm types:check", "test": "node --import tsx --test packages/core/__tests__/*.test.ts", "release": "pnpm build && pnpm changeset publish --no-git-tag", "changeset-version": "pnpm changeset version && pnpm style:fix" diff --git a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts index fcc2a4e..4195b00 100644 --- a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts +++ b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import nock from 'nock' -import { type EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' +import { fetchEntityConfigurationChains } from '../src/entityConfiguration' import type { SignCallback, VerifyCallback } from '../src/utils' import { setupConfigurationChain } from './utils/setupConfigurationChain' @@ -13,23 +12,11 @@ describe('fetch entity configuration chains', () => { const leafEntityId = 'https://leaf.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [{ entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { entityId: trustAnchorEntityId }], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -39,10 +26,10 @@ describe('fetch entity configuration chains', () => { 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]) + assert.deepStrictEqual(trustChains[0]?.[0], configurations[0]?.claims) + assert.deepStrictEqual(trustChains[0]?.[1], configurations[1]?.claims) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) @@ -52,10 +39,7 @@ describe('fetch entity configuration chains', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, { @@ -64,18 +48,9 @@ describe('fetch entity configuration chains', () => { }, { entityId: trustAnchorEntityId }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -85,11 +60,11 @@ describe('fetch entity configuration chains', () => { 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]) + assert.deepStrictEqual(trustChains[0]?.[0], configurations[0]?.claims) + assert.deepStrictEqual(trustChains[0]?.[1], configurations[1]?.claims) + assert.deepStrictEqual(trustChains[0]?.[2], configurations[2]?.claims) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) @@ -99,10 +74,7 @@ describe('fetch entity configuration chains', () => { const trustAnchorEntityId = 'https://trust.example.org' const superiorTrustAnchorEntityId = 'https://trust.superior.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { @@ -111,18 +83,9 @@ describe('fetch entity configuration chains', () => { }, { entityId: superiorTrustAnchorEntityId }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -132,10 +95,10 @@ describe('fetch entity configuration chains', () => { 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]) + assert.deepStrictEqual(trustChains[0]?.[0], configurations[0]?.claims) + assert.deepStrictEqual(trustChains[0]?.[1], configurations[1]?.claims) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) @@ -147,10 +110,7 @@ describe('fetch entity configuration chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, @@ -167,18 +127,9 @@ describe('fetch entity configuration chains', () => { { entityId: trustAnchorOneEntityId }, { entityId: trustAnchorTwoEntityId }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -189,7 +140,7 @@ describe('fetch entity configuration chains', () => { assert.strictEqual(trustChains[0]?.length, 3) assert.strictEqual(trustChains[1]?.length, 3) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) @@ -201,10 +152,7 @@ describe('fetch entity configuration chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, @@ -221,18 +169,9 @@ describe('fetch entity configuration chains', () => { { entityId: trustAnchorOneEntityId }, { entityId: trustAnchorTwoEntityId }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -242,23 +181,19 @@ describe('fetch entity configuration chains', () => { assert.strictEqual(trustChains.length, 1) assert.strictEqual(trustChains[0]?.length, 3) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) it('should not fetch an entity configuration chain when no authority_hints are found', async () => { - const scopes: Array = [] - - 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 { chainData: configurations, nockScopes } = await setupConfigurationChain( + [{ entityId: 'https://leaf.example.org' }], + { + signJwtCallback, + mockEndpoints: true, + } + ) const trustChains = await fetchEntityConfigurationChains({ verifyJwtCallback, @@ -268,20 +203,18 @@ describe('fetch entity configuration chains', () => { assert.strictEqual(trustChains.length, 0) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) it('should not fetch an entity configuration chain when a loop is found', async () => { - const scopes: Array = [] - 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( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateOneEntityId] }, { @@ -293,17 +226,9 @@ describe('fetch entity configuration chains', () => { authorityHints: [intermediateOneEntityId], }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -312,7 +237,7 @@ describe('fetch entity configuration chains', () => { assert.strictEqual(trustChains.length, 0) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) diff --git a/packages/core/__tests__/fetchEntityStatementChain.test.ts b/packages/core/__tests__/fetchEntityStatementChain.test.ts index c1b99a7..35a3230 100644 --- a/packages/core/__tests__/fetchEntityStatementChain.test.ts +++ b/packages/core/__tests__/fetchEntityStatementChain.test.ts @@ -1,6 +1,5 @@ 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' @@ -14,32 +13,14 @@ describe('fetch entity statement chain', () => { const leafEntityId = 'https://leaf.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes: Array = [] - const claims: Array = [] - - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { entityId: trustAnchorEntityId, subordinates: [leafEntityId] }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -49,12 +30,12 @@ describe('fetch entity statement chain', () => { 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]) + assert.deepStrictEqual(chains[0]?.[0], configurations[0]?.claims) + assert.deepStrictEqual(chains[0]?.[1], configurations[1]?.claims) const statements = await fetchEntityStatementChain({ verifyJwtCallback, - entityConfigurations: chains[0]!, + entityConfigurations: chains[0], }) assert.strictEqual(statements.length, 2) @@ -65,7 +46,7 @@ describe('fetch entity statement chain', () => { assert.deepStrictEqual(statements[1]?.iss, trustAnchorEntityId) assert.deepStrictEqual(statements[1]?.sub, trustAnchorEntityId) - for (const scope of scopes) { + for (const scope of nockScopes) { scope.done() } }) @@ -75,10 +56,9 @@ describe('fetch entity statement chain', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const scopes: Array = [] const claims: Array = [] - const configurations = await setupConfigurationChain( + const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, { @@ -88,24 +68,9 @@ describe('fetch entity statement chain', () => { }, { entityId: trustAnchorEntityId, subordinates: [intermediateEntityId] }, ], - signJwtCallback + { signJwtCallback, mockEndpoints: true } ) - 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, @@ -115,13 +80,13 @@ describe('fetch entity statement chain', () => { 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]) + assert.deepStrictEqual(chains[0]?.[0], configurations[0]?.claims) + assert.deepStrictEqual(chains[0]?.[1], configurations[1]?.claims) + assert.deepStrictEqual(chains[0]?.[2], configurations[2]?.claims) const statements = await fetchEntityStatementChain({ verifyJwtCallback, - entityConfigurations: chains[0]!, + entityConfigurations: chains[0], }) assert.strictEqual(statements.length, 3) @@ -135,59 +100,7 @@ describe('fetch entity statement chain', () => { 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) { + for (const scope of nockScopes) { scope.done() } }) diff --git a/packages/core/__tests__/fixtures/entityStatementFigure62.ts b/packages/core/__tests__/fixtures/entityStatementFigure62.ts index 40883db..f1e043b 100644 --- a/packages/core/__tests__/fixtures/entityStatementFigure62.ts +++ b/packages/core/__tests__/fixtures/entityStatementFigure62.ts @@ -1,28 +1,29 @@ export const entityStatementFigure62 = { exp: 1568397247, iat: 1568310847, - iss: 'https://edugain.geant.org', - sub: 'https://swamid.se', - source_endpoint: 'https://edugain.geant.org/edugain/api', + iss: 'https://swamid.se', + sub: 'https://umu.se', + source_endpoint: 'https://swamid.se/fedapi', jwks: { keys: [ { e: 'AQAB', - kid: 'N1pQTzFxUXZ1RXVsUkVuMG5uMnVDSURGRVdhUzdO...', + kid: 'endwNUZrNTJsX2NyQlp4bjhVcTFTTVltR2gxV2RV...', kty: 'RSA', - n: '3EQc6cR_GSBq9km9-WCHY_lWJZWkcn0M05TGtH6D9S...', + n: 'vXdXzZwQo0hxRSmZEcDIsnpg-CMEkor50SOG-1XUlM...', }, ], }, metadata_policy: { openid_provider: { - contacts: { - add: 'ops@edugain.geant.org', + id_token_signing_alg_values_supported: { + subset_of: ['RS256', 'ES256', 'ES384', 'ES512'], }, - }, - openid_relying_party: { - contacts: { - add: 'ops@edugain.geant.org', + token_endpoint_auth_methods_supported: { + subset_of: ['client_secret_jwt', 'private_key_jwt'], + }, + userinfo_signing_alg_values_supported: { + subset_of: ['ES256', 'ES384', 'ES512'], }, }, }, diff --git a/packages/core/__tests__/policyMerge.applyMetadataPolicy.test.ts b/packages/core/__tests__/policyMerge.applyMetadataPolicy.test.ts new file mode 100644 index 0000000..ce4206a --- /dev/null +++ b/packages/core/__tests__/policyMerge.applyMetadataPolicy.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { EntityConfigurationClaims } from '../src/entityConfiguration' +import { fetchEntityStatementChain } from '../src/entityStatement' +import { combineMetadataPolicies } from '../src/resolveTrustChains/policies/combineMetadataPolicies' +import type { SignCallback, VerifyCallback } from '../src/utils' +import { setupConfigurationChain } from './utils/setupConfigurationChain' + +describe('policy merge', () => { + const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) + + // add + + it('should combine add metadata policies', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const { chainData: configurations } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + add: ['ops@intermediate.example.org'], + }, + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + add: ['ops@ta.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const statements = await fetchEntityStatementChain({ + entityConfigurations: configurations.map(({ claims }) => claims as EntityConfigurationClaims), + verifyJwtCallback, + }) + + const { mergedPolicy } = await combineMetadataPolicies({ statements }) + + assert.deepStrictEqual(mergedPolicy, { + openid_relying_party: { + contacts: { + add: ['ops@ta.example.org', 'ops@intermediate.example.org'], + }, + }, + }) + }) + + it('should fail to different default metadata policies', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const { chainData: configurations } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + default: ['ops@intermediate.example.org'], + }, + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + default: ['ops@ta.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const statements = await fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: configurations.map(({ claims }) => claims as EntityConfigurationClaims), + }) + + assert.throws(() => combineMetadataPolicies({ statements })) + }) + + it('should combine default metadata policies', async () => { + const leafEntityId = 'https://leaf.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const { chainData: configurations } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + default: ['ops@contact.example.org'], + }, + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + contacts: { + default: ['ops@contact.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const statements = await fetchEntityStatementChain({ + verifyJwtCallback, + entityConfigurations: configurations.map(({ claims }) => claims as EntityConfigurationClaims), + }) + + const { mergedPolicy } = await combineMetadataPolicies({ statements }) + + assert.deepStrictEqual(mergedPolicy, { + openid_relying_party: { + contacts: { + default: ['ops@contact.example.org'], + }, + }, + }) + }) +}) diff --git a/packages/core/__tests__/policyMerge.applyMetadataPolicyToMetadata.test.ts b/packages/core/__tests__/policyMerge.applyMetadataPolicyToMetadata.test.ts new file mode 100644 index 0000000..333b7d7 --- /dev/null +++ b/packages/core/__tests__/policyMerge.applyMetadataPolicyToMetadata.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type EntityConfigurationClaims, fetchEntityConfiguration } from '../src/entityConfiguration' +import { fetchEntityStatementChain } from '../src/entityStatement' +import { applyMetadataPolicyToMetadata, combineMetadataPolicies } from '../src/resolveTrustChains/policies' +import { mergeMetadata } from '../src/resolveTrustChains/policies/utils' +import type { SignCallback, VerifyCallback } from '../src/utils' +import { setupConfigurationChain } from './utils/setupConfigurationChain' + +describe('policy application to the real metadata chain', () => { + const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) + + it('should succeed the example from the spec', async () => { + const leafEntityId = 'https://leaff.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + const { chainData: configurations } = await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateEntityId], + claims: { + metadata: { + // Figure 15: https://openid.net/specs/openid-federation-1_0.html#figure-15 + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + contacts: ['rp_admins@rp.example.org'], + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + // Figure 13: https://openid.net/specs/openid-federation-1_0.html#figure-13 + metadata_policy: { + openid_relying_party: { + grant_types: { + subset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['self_signed_tls_client_auth'], + }, + contacts: { + add: ['helpdesk@org.example.org'], + }, + }, + }, + metadata: { + openid_relying_party: { + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + // Figure 12: https://openid.net/specs/openid-federation-1_0.html#figure-12 + metadata_policy: { + openid_relying_party: { + grant_types: { + default: ['authorization_code'], + subset_of: ['authorization_code', 'refresh_token'], + superset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['private_key_jwt', 'self_signed_tls_client_auth'], + essential: true, + }, + token_endpoint_auth_signing_alg: { + one_of: ['PS256', 'ES256'], + }, + subject_type: { + value: 'pairwise', + }, + contacts: { + add: ['helpdesk@federation.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const statements = await fetchEntityStatementChain({ + entityConfigurations: configurations.map(({ claims }) => claims as EntityConfigurationClaims), + verifyJwtCallback, + }) + + const statementsWithoutLeaf = statements.slice(0, -1) + + const { mergedPolicy } = await combineMetadataPolicies({ statements: statementsWithoutLeaf }) + + assert.deepStrictEqual(mergedPolicy, { + // Figure 14 https://openid.net/specs/openid-federation-1_0.html#figure-14 + openid_relying_party: { + grant_types: { + default: ['authorization_code'], + superset_of: ['authorization_code'], + subset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['self_signed_tls_client_auth'], + essential: true, + }, + token_endpoint_auth_signing_alg: { + one_of: ['PS256', 'ES256'], + }, + subject_type: { + value: 'pairwise', + }, + contacts: { + add: ['helpdesk@federation.example.org', 'helpdesk@org.example.org'], + }, + }, + }) + + const { metadata: leafMetadata } = await fetchEntityConfiguration({ + entityId: leafEntityId, + verifyJwtCallback, + }) + if (!leafMetadata) throw new Error('Leaf metadata is not defined') + + // When the chain only has one entity configuration we don't have a superior entity statement so it will always be correct so in practice it can be returned early + + const superiorEntityStatement = statementsWithoutLeaf[0] + if (!superiorEntityStatement) throw new Error('Superior entity statement is not defined') + + const mergedLeafMetadata = mergeMetadata(leafMetadata, superiorEntityStatement.metadata ?? {}) + + const applyMetadataPolicyToMetadataResult = await applyMetadataPolicyToMetadata({ + leafMetadata: mergedLeafMetadata, + policyMetadata: mergedPolicy, + }) + + assert.deepStrictEqual(applyMetadataPolicyToMetadataResult.resolvedLeafMetadata, { + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + subject_type: 'pairwise', + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + contacts: ['rp_admins@rp.example.org', 'helpdesk@federation.example.org', 'helpdesk@org.example.org'], + + // Not from the figure + client_registration_types: ['automatic'], + }, + + // Not from the figure + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + }) + }) +}) diff --git a/packages/core/__tests__/policyMerge.combineExistingPolicyRules.test.ts b/packages/core/__tests__/policyMerge.combineExistingPolicyRules.test.ts new file mode 100644 index 0000000..6622115 --- /dev/null +++ b/packages/core/__tests__/policyMerge.combineExistingPolicyRules.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { combineExistingMetadataPolicyOperators } from '../src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators' + +describe('policy merge', () => { + // add + + it('it should combine the add policy rules', () => { + const superiorPolicyRule = { + add: ['one', 'two'], + } as const + + const policyRule = { + add: ['three'], + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + add: ['one', 'two', 'three'], + }) + }) + + // essential + + it('it should still be true with essential from superior true', () => { + const superiorPolicyRule = { + essential: true, + } as const + + const policyRule = { + essential: false, + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + essential: true, + }) + }) + + // subset_of + + it('it should combine the subset_of policy rules', () => { + const superiorPolicyRule = { + subset_of: ['one', 'two'], + } as const + + const policyRule = { + subset_of: ['two', 'three'], + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + subset_of: ['two'], + }) + }) + + // superset_of + + it('it should fail the subset_of policy rules', () => { + const superiorPolicyRule = { + subset_of: ['one', 'two'], + } as const + + const policyRule = { + subset_of: ['three'], + } as const + + assert.throws(() => + combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + ) + }) + + it('it should combine the superset_of policy rules', () => { + const superiorPolicyRule = { + superset_of: ['one', 'two'], + } as const + + const policyRule = { + superset_of: ['two', 'three'], + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + superset_of: ['one', 'two', 'three'], + }) + }) + + // default + + it('it should fail the default policy rules', () => { + const superiorPolicyRule = { + default: 'one', + } as const + + const policyRule = { + default: 'two', + } as const + + assert.throws(() => + combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + ) + }) + + it('it should combine the default policy rules', () => { + const superiorPolicyRule = { + default: 'one', + } as const + + const policyRule = { + default: 'one', + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + default: 'one', + }) + }) + + // value + + it('it should fail the value policy rules', () => { + const superiorPolicyRule = { + value: 'one', + } as const + + const policyRule = { + value: 'two', + } as const + + assert.throws(() => + combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + ) + }) + + it('it should combine the value policy rules', () => { + const superiorPolicyRule = { + value: 'one', + } as const + + const policyRule = { + value: 'one', + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + value: 'one', + }) + }) + + // one_of + + it('it should combine the one_of policy rules', () => { + const superiorPolicyRule = { + one_of: ['one', 'two'], + } as const + + const policyRule = { + one_of: ['two', 'three'], + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + one_of: ['two'], + }) + }) + + // all supported policies (without the logic of that they can be together only the merge) + + it('it should combine all supported policies', () => { + const superiorPolicyRule = { + add: ['one', 'two'], + default: 'one', + essential: true, + one_of: ['one', 'two'], + subset_of: ['one', 'two'], + superset_of: ['one', 'two'], + value: 'one', + } as const + + const policyRule = { + add: ['three'], + default: 'one', + essential: false, + one_of: ['one', 'two', 'three', 'four'], + subset_of: ['one', 'two', 'three', 'four'], + superset_of: ['one', 'two', 'three', 'four'], + value: 'one', + } as const + + const combinedPolicyRules = combineExistingMetadataPolicyOperators({ + contextPath: 'openid_relying_party.contacts', + existingPolicyRules: superiorPolicyRule, + newPolicyRules: policyRule, + }) + + assert.deepStrictEqual(combinedPolicyRules, { + add: ['one', 'two', 'three'], + default: 'one', + essential: true, + one_of: ['one', 'two'], + subset_of: ['one', 'two'], + superset_of: ['one', 'two', 'three', 'four'], + value: 'one', + }) + }) +}) diff --git a/packages/core/__tests__/resolveTrustChains.test.ts b/packages/core/__tests__/resolveTrustChains.test.ts new file mode 100644 index 0000000..2f0b291 --- /dev/null +++ b/packages/core/__tests__/resolveTrustChains.test.ts @@ -0,0 +1,669 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' +import { resolveTrustChains } from '../src/resolveTrustChains' +import type { SignCallback, VerifyCallback } from '../src/utils' +import { setupConfigurationChain } from './utils/setupConfigurationChain' + +describe('fetch trust 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 { nockScopes } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, + { entityId: trustAnchorEntityId, subordinates: [leafEntityId] }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]!.chain.length, 2) + + for (const scope of nockScopes) { + 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 { chainData: configurations, nockScopes } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [leafEntityId], + }, + { entityId: trustAnchorEntityId, subordinates: [intermediateEntityId] }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const claims: Array = configurations.map( + ({ claims: configurationClaims }) => configurationClaims + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]!.chain.length, 3) + + // assert.deepStrictEqual(trustChains[0]!.chain[0], claims[0]) + // assert.deepStrictEqual(trustChains[0]!.chain[1], claims[1]) + // assert.deepStrictEqual(trustChains[0]!.chain[2], claims[2]) + + for (const scope of nockScopes) { + scope.done() + } + }) + + it('should fetch a policy based 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 { chainData: configurations, nockScopes } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + id_token_signed_response_alg: { + one_of: ['ES256'], + }, + grant_types: { + add: ['client_credentials'], + subset_of: ['authorization_code', 'client_credentials'], + }, + }, + }, + }, + }, + ], + }, + { entityId: trustAnchorEntityId, subordinates: [intermediateEntityId] }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const claims: Array = configurations.map( + ({ claims: configurationClaims }) => configurationClaims + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 3) + + for (const scope of nockScopes) { + 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 { nockScopes } = await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], + }, + { + entityId: intermediateOneEntityId, + authorityHints: [trustAnchorOneEntityId], + subordinates: [leafEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [trustAnchorTwoEntityId], + subordinates: [leafEntityId], + }, + { entityId: trustAnchorOneEntityId, subordinates: [intermediateOneEntityId] }, + { entityId: trustAnchorTwoEntityId, subordinates: [intermediateTwoEntityId] }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorOneEntityId, trustAnchorTwoEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 2) + assert.strictEqual(trustChains[0]!.chain.length, 3) + assert.strictEqual(trustChains[1]!.chain.length, 3) + + for (const scope of nockScopes) { + 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 { chainData: configurations, nockScopes } = await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], + }, + { + entityId: intermediateOneEntityId, + authorityHints: [trustAnchorOneEntityId], + subordinates: [leafEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [trustAnchorTwoEntityId], + subordinates: [leafEntityId], + }, + { entityId: trustAnchorOneEntityId, subordinates: [intermediateOneEntityId] }, + { entityId: trustAnchorTwoEntityId, subordinates: [intermediateTwoEntityId] }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorOneEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]!.chain.length, 3) + }) + + it('should not fetch an entity configuration chain when no authority_hints are found', async () => { + const { chainData: configurations, nockScopes } = await setupConfigurationChain( + [{ entityId: 'https://leaf.example.org' }], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: configurations[0]!.entityId, + trustAnchorEntityIds: ['https://trust.example.org'], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 0) + + for (const scope of nockScopes) { + scope.done() + } + }) + + it('should not fetch an entity configuration chain when a loop is found', async () => { + 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 { chainData: configurations, nockScopes } = await setupConfigurationChain( + [ + { entityId: leafEntityId, authorityHints: [intermediateOneEntityId] }, + { + entityId: intermediateOneEntityId, + authorityHints: [intermediateTwoEntityId], + }, + { + entityId: intermediateTwoEntityId, + authorityHints: [intermediateOneEntityId], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + verifyJwtCallback, + entityId: configurations[0]!.entityId, + trustAnchorEntityIds: [trustAnchorEntityId], + }) + + assert.strictEqual(trustChains.length, 0) + + for (const scope of nockScopes) { + scope.done() + } + }) + + it('should not fetch an entity configuration chain when no trust anchors are provided', async () => { + await assert.rejects( + resolveTrustChains({ + verifyJwtCallback, + entityId: 'https://example.org', + trustAnchorEntityIds: [], + }) + ) + }) + + it('should succeed the example from the spec', async () => { + const leafEntityId = 'https://leaff.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateEntityId], + claims: { + metadata: { + // Figure 15: https://openid.net/specs/openid-federation-1_0.html#figure-15 + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + contacts: ['rp_admins@rp.example.org'], + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + // Figure 13: https://openid.net/specs/openid-federation-1_0.html#figure-13 + metadata_policy: { + openid_relying_party: { + grant_types: { + subset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['self_signed_tls_client_auth'], + }, + contacts: { + add: ['helpdesk@org.example.org'], + }, + }, + }, + metadata: { + openid_relying_party: { + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + // Figure 12: https://openid.net/specs/openid-federation-1_0.html#figure-12 + metadata_policy: { + openid_relying_party: { + grant_types: { + default: ['authorization_code'], + subset_of: ['authorization_code', 'refresh_token'], + superset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['private_key_jwt', 'self_signed_tls_client_auth'], + essential: true, + }, + token_endpoint_auth_signing_alg: { + one_of: ['PS256', 'ES256'], + }, + subject_type: { + value: 'pairwise', + }, + contacts: { + add: ['helpdesk@federation.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 3) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + subject_type: 'pairwise', + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + contacts: ['rp_admins@rp.example.org', 'helpdesk@federation.example.org', 'helpdesk@org.example.org'], + + // Not from the figure + client_registration_types: ['automatic'], + }, + + // Not from the figure + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + }) + }) + + it('should work when the leaf is also the TA', async () => { + const leafEntityId = 'https://leaff.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateEntityId], + claims: { + metadata: { + // Figure 15: https://openid.net/specs/openid-federation-1_0.html#figure-15 + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + contacts: ['rp_admins@rp.example.org'], + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + // Figure 13: https://openid.net/specs/openid-federation-1_0.html#figure-13 + metadata_policy: { + openid_relying_party: { + grant_types: { + subset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['self_signed_tls_client_auth'], + }, + contacts: { + add: ['helpdesk@org.example.org'], + }, + }, + }, + metadata: { + openid_relying_party: { + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + // Figure 12: https://openid.net/specs/openid-federation-1_0.html#figure-12 + metadata_policy: { + openid_relying_party: { + grant_types: { + default: ['authorization_code'], + subset_of: ['authorization_code', 'refresh_token'], + superset_of: ['authorization_code'], + }, + token_endpoint_auth_method: { + one_of: ['private_key_jwt', 'self_signed_tls_client_auth'], + essential: true, + }, + token_endpoint_auth_signing_alg: { + one_of: ['PS256', 'ES256'], + }, + subject_type: { + value: 'pairwise', + }, + contacts: { + add: ['helpdesk@federation.example.org'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [leafEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 1) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + openid_relying_party: { + contacts: ['rp_admins@rp.example.org'], + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + + // Not from the figure + client_registration_types: ['automatic'], + }, + + // Not from the figure + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + }) + }) + + it('should not resolve when there is a metadata_policy_crit custom function', async () => { + const leafEntityId = 'https://leaff.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [trustAnchorEntityId], + claims: { + metadata: { + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + contacts: ['rp_admins@rp.example.org'], + + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy_crit: ['regexp'], + metadata_policy: { + openid_relying_party: { + sector_identifier_uri: { + regexp: '^https:\\/\\/', + }, + }, + }, + metadata: { + openid_relying_party: { + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + // The chain + assert.strictEqual(trustChains.length, 0) + }) + + it('should succeed when there is a unknown function and it is not critical', async () => { + const leafEntityId = 'https://leaff.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [trustAnchorEntityId], + claims: { + metadata: { + openid_relying_party: { + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + contacts: ['rp_admins@rp.example.org'], + + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + policy_uri: 'https://org.example.org/policy.html', + + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + sector_identifier_uri: { + regexp: '^https:\\/\\/', + }, + }, + }, + metadata: { + openid_relying_party: { + // (Required property was not given in the spec) + client_registration_types: ['automatic'], + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 2) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + openid_relying_party: { + contacts: ['rp_admins@rp.example.org'], + redirect_uris: ['https://rp.example.org/callback'], + response_types: ['code'], + token_endpoint_auth_method: 'self_signed_tls_client_auth', + policy_uri: 'https://org.example.org/policy.html', + sector_identifier_uri: 'https://org.example.org/sector-ids.json', + // Not from the figure + client_registration_types: ['automatic'], + }, + + // Not from the figure + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + }) + }) +}) diff --git a/packages/core/__tests__/utils/setupConfigurationChain.ts b/packages/core/__tests__/utils/setupConfigurationChain.ts index 337d689..6780865 100644 --- a/packages/core/__tests__/utils/setupConfigurationChain.ts +++ b/packages/core/__tests__/utils/setupConfigurationChain.ts @@ -1,43 +1,71 @@ +import nock from 'nock' import { type EntityConfigurationClaimsOptions, type EntityConfigurationHeaderOptions, createEntityConfiguration, } from '../../src/entityConfiguration' -import { createEntityStatement } from '../../src/entityStatement' +import { type EntityStatementClaimsOptions, createEntityStatement } from '../../src/entityStatement' import type { JsonWebKeySetOptions } from '../../src/jsonWeb' import type { SignCallback } from '../../src/utils' -type SetupConfigurationChainOptions = { +type SubordinateOptions = { + entityId: string + claims: Partial +} + +type ChainConfigurationOptions = { entityId: string authorityHints?: Array - subordinates?: Array + subordinates?: Array jwks?: JsonWebKeySetOptions kid?: string includeSourceEndpoint?: boolean + + claims?: Partial } -export const setupConfigurationChain = async ( - options: Array, - signJwtCallback: SignCallback +export const setupConfigurationChain = async ( + chainOptions: Array, + { + signJwtCallback, + mockEndpoints = false as MockEndpoints, + }: { + signJwtCallback: SignCallback + /** + * @default false + */ + mockEndpoints?: MockEndpoints + } ) => { const chainData: Array<{ claims: EntityConfigurationClaimsOptions jwt: string entityId: string - subordinateStatements?: Array<{ entityId: string; jwt: string }> + subordinateStatements?: Array<{ entityId: string; jwt: string; claims: EntityStatementClaimsOptions }> }> = [] - for (const { entityId, authorityHints, jwks, kid, subordinates, includeSourceEndpoint = true } of options) { + const nockScopes: Array = [] + for (const { + entityId, + authorityHints, + jwks, + kid, + subordinates, + includeSourceEndpoint = true, + claims: givenClaims = {}, + } of chainOptions) { const claims: EntityConfigurationClaimsOptions = { iss: entityId, sub: entityId, - exp: new Date(), + exp: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days iat: new Date(), jwks: jwks ?? { keys: [{ kid: 'a', kty: 'EC' }] }, authority_hints: authorityHints, + ...givenClaims, metadata: { federation_entity: { federation_fetch_endpoint: `${entityId}/fetch`, }, + ...(givenClaims.metadata ?? {}), }, } @@ -56,21 +84,51 @@ export const setupConfigurationChain = async ( signJwtCallback, }) - const subordinateStatements = [] + if (mockEndpoints) { + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + nockScopes.push(scope) + } + + const subordinateStatements: Array<{ entityId: string; jwt: string; claims: EntityStatementClaimsOptions }> = [] for (const sub of subordinates ?? []) { + const givenClaims = typeof sub === 'string' ? { sub } : { sub: sub.entityId, ...sub.claims } + + const subordinateClaims = { + jwks: { keys: [] }, + iss: entityId, + exp: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + iat: new Date(), + ...givenClaims, + } + const entityStatementJwt = await createEntityStatement({ signJwtCallback, jwk: claims.jwks.keys[0]!, - claims: { - jwks: { keys: [] }, - iss: entityId, - sub, - exp: new Date(), - iat: new Date(), - }, + claims: subordinateClaims, }) - subordinateStatements.push({ entityId: sub, jwt: entityStatementJwt }) + if (mockEndpoints && claims.metadata?.federation_entity?.federation_fetch_endpoint) { + const scope = nock(claims.metadata.federation_entity.federation_fetch_endpoint) + .get('') + .query({ + sub: subordinateClaims.sub, + iss: entityId, + }) + .reply(200, entityStatementJwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + nockScopes.push(scope) + } + + subordinateStatements.push({ + entityId: subordinateClaims.sub, + jwt: entityStatementJwt, + claims: subordinateClaims, + }) } chainData.push({ @@ -81,5 +139,8 @@ export const setupConfigurationChain = async ( }) } - return chainData + return { + chainData, + nockScopes: (mockEndpoints ? nockScopes : undefined) as MockEndpoints extends true ? typeof nockScopes : never, + } as const } diff --git a/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts index b666e5e..f2a8e14 100644 --- a/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts +++ b/packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts @@ -1,5 +1,5 @@ -import { ErrorCode } from '../error/ErrorCode' import { OpenIdFederationError } from '../error/OpenIdFederationError' +import { PolicyErrorStage } from '../error/PolicyErrorStage' import type { FetchCallback, VerifyCallback } from '../utils' import type { EntityConfigurationClaims } from './entityConfigurationClaims' import { fetchEntityConfiguration } from './fetchEntityConfiguration' @@ -23,7 +23,7 @@ export const fetchEntityConfigurationChains = async ( ): Promise>> => { if (options.trustAnchorEntityIds.length === 0) { throw new OpenIdFederationError( - ErrorCode.Validation, + PolicyErrorStage.Validation, 'Cannot establish a configuration chain for zero trust anchors' ) } diff --git a/packages/core/src/entityStatement/entityStatementClaims.ts b/packages/core/src/entityStatement/entityStatementClaims.ts index 14fa364..2f52d0b 100644 --- a/packages/core/src/entityStatement/entityStatementClaims.ts +++ b/packages/core/src/entityStatement/entityStatementClaims.ts @@ -15,7 +15,7 @@ export const entityStatementClaimsSchema = z jwks: jsonWebKeySetSchema, authority_hints: z.array(z.string().url()).optional(), metadata: metadataSchema.optional(), - metadata_policy: metadataPolicySchema.optional(), + metadata_policy: z.record(z.record(metadataPolicySchema.optional())).optional(), constraints: constraintSchema.optional(), crit: z.array(z.string()).optional(), metadata_policy_crit: z.array(z.string()).optional(), diff --git a/packages/core/src/entityStatement/fetchEntityStatementChain.ts b/packages/core/src/entityStatement/fetchEntityStatementChain.ts index 99693d6..b98f4f2 100644 --- a/packages/core/src/entityStatement/fetchEntityStatementChain.ts +++ b/packages/core/src/entityStatement/fetchEntityStatementChain.ts @@ -1,6 +1,6 @@ import type { EntityConfigurationClaims } from '../entityConfiguration' -import { ErrorCode } from '../error/ErrorCode' import { OpenIdFederationError } from '../error/OpenIdFederationError' +import { PolicyErrorStage } from '../error/PolicyErrorStage' import type { VerifyCallback } from '../utils' import type { EntityStatementClaims } from './entityStatementClaims' import { fetchEntityStatement } from './fetchEntityStatement' @@ -16,7 +16,7 @@ export const fetchEntityStatementChain = async ({ }: FetchEntityStatementChainOptions): Promise> => { if (entityConfigurations.length === 0) { throw new OpenIdFederationError( - ErrorCode.Validation, + PolicyErrorStage.Validation, 'Cannot establish a statement chain for zero entity configurations' ) } @@ -41,7 +41,7 @@ export const fetchEntityStatementChain = async ({ if (!fetchEndpoint) { throw new OpenIdFederationError( - ErrorCode.Validation, + PolicyErrorStage.Validation, `No fetch endpoint found for configuration for: '${configuration?.sub}'` ) } @@ -62,7 +62,7 @@ export const fetchEntityStatementChain = async ({ const trustAnchorEntityConfiguration = entityConfigurations[entityConfigurations.length - 1] // Should never happen because there will always be a trust anchor in a valid chain if (!trustAnchorEntityConfiguration) { - throw new OpenIdFederationError(ErrorCode.Validation, 'No trust anchor entity configuration found') + throw new OpenIdFederationError(PolicyErrorStage.Validation, 'No trust anchor entity configuration found') } return [trustAnchorEntityConfiguration, ...(await Promise.all(promises))].reverse() diff --git a/packages/core/src/error/ErrorCode.ts b/packages/core/src/error/ErrorCode.ts deleted file mode 100644 index fd8b04f..0000000 --- a/packages/core/src/error/ErrorCode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum ErrorCode { - Validation = 0, -} diff --git a/packages/core/src/error/OpenIdFederationError.ts b/packages/core/src/error/OpenIdFederationError.ts index 7cae0be..ce9cc49 100644 --- a/packages/core/src/error/OpenIdFederationError.ts +++ b/packages/core/src/error/OpenIdFederationError.ts @@ -1,17 +1,20 @@ import type { ZodError } from 'zod' -import { ErrorCode } from './ErrorCode' +import { PolicyErrorStage } from './PolicyErrorStage' -// TODO: Extend to get more properties on the error export class OpenIdFederationError extends Error { public constructor( - public errorCode: ErrorCode, + public readonly errorCode: PolicyErrorStage, message?: string, - public context?: Error + public readonly cause?: unknown ) { super(message) } public static fromZodError(zodError: ZodError) { - return new OpenIdFederationError(ErrorCode.Validation, undefined, zodError) + return new OpenIdFederationError(PolicyErrorStage.Validation, undefined, zodError) + } + + public static isMetadataPolicyCritError(error: unknown) { + return error instanceof OpenIdFederationError && error.errorCode === PolicyErrorStage.MetadataPolicyCrit } } diff --git a/packages/core/src/error/PolicyErrorStage.ts b/packages/core/src/error/PolicyErrorStage.ts new file mode 100644 index 0000000..9ea5d85 --- /dev/null +++ b/packages/core/src/error/PolicyErrorStage.ts @@ -0,0 +1,7 @@ +export enum PolicyErrorStage { + Generic = 0, + Validation = 1, + MetadataPolicyCrit = 2, + PolicyMerge = 3, + PolicyApply = 4, +} diff --git a/packages/core/src/metadata/operator/MetadataOperator.ts b/packages/core/src/metadata/operator/MetadataOperator.ts index 835a042..e014898 100644 --- a/packages/core/src/metadata/operator/MetadataOperator.ts +++ b/packages/core/src/metadata/operator/MetadataOperator.ts @@ -2,8 +2,8 @@ import type { z } from 'zod' import type { MetadataMergeStrategy } from './MetadataMergeStrategy' import type { MetadataOrderOfApplication } from './MetadataOrderOfApplication' -export type MetadataOperator = { - key: string +export type MetadataOperator = { + key: TKey parameterJsonValues: Array operatorJsonValues: Array canBeCombinedWith: Array diff --git a/packages/core/src/metadata/operator/standard/add.ts b/packages/core/src/metadata/operator/standard/add.ts index c694d37..92ba732 100644 --- a/packages/core/src/metadata/operator/standard/add.ts +++ b/packages/core/src/metadata/operator/standard/add.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const addOperator: MetadataOperator = { +export const addOperator = createPolicyOperatorSchema({ key: 'add', parameterJsonValues: [ z.array(z.string()), @@ -13,12 +12,11 @@ export const addOperator: MetadataOperator = { ], operatorJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], canBeCombinedWith: ['default', 'subset_of', 'superset_of', 'essential'], orderOfApplication: MetadataOrderOfApplication.AfterValue, mergeStrategy: MetadataMergeStrategy.Union, -} - -export const addOperatorSchema = createPolicyOperatorSchema(addOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/default.ts b/packages/core/src/metadata/operator/standard/default.ts index 8aefaab..11e78ff 100644 --- a/packages/core/src/metadata/operator/standard/default.ts +++ b/packages/core/src/metadata/operator/standard/default.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const defaultOperator: MetadataOperator = { +export const defaultOperator = createPolicyOperatorSchema({ key: 'default', parameterJsonValues: [ z.string(), @@ -23,6 +22,4 @@ export const defaultOperator: MetadataOperator = { canBeCombinedWith: ['add', 'one_of', 'subset_of', 'superset_of', 'essential'], orderOfApplication: MetadataOrderOfApplication.AfterAdd, mergeStrategy: MetadataMergeStrategy.OperatorValuesEqual, -} - -export const defaultOperatorSchema = createPolicyOperatorSchema(defaultOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/essential.ts b/packages/core/src/metadata/operator/standard/essential.ts index 1ca9575..aab1865 100644 --- a/packages/core/src/metadata/operator/standard/essential.ts +++ b/packages/core/src/metadata/operator/standard/essential.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const essentialOperator: MetadataOperator = { +export const essentialOperator = createPolicyOperatorSchema({ key: 'essential', parameterJsonValues: [ z.string(), @@ -18,6 +17,4 @@ export const essentialOperator: MetadataOperator = { canBeCombinedWith: ['add', 'default', 'one_of', 'subset_of', 'superset_of', 'value'], orderOfApplication: MetadataOrderOfApplication.Last, mergeStrategy: MetadataMergeStrategy.SuperiorFollowsIfTrue, -} - -export const essentialOperatorSchema = createPolicyOperatorSchema(essentialOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/oneOf.ts b/packages/core/src/metadata/operator/standard/oneOf.ts index ae66fe6..d906dd6 100644 --- a/packages/core/src/metadata/operator/standard/oneOf.ts +++ b/packages/core/src/metadata/operator/standard/oneOf.ts @@ -1,20 +1,18 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const oneOfOperator: MetadataOperator = { +export const oneOfOperator = createPolicyOperatorSchema({ key: 'one_of', parameterJsonValues: [z.string(), z.record(z.string().or(z.number()), z.unknown()), z.number()], operatorJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], canBeCombinedWith: ['default', 'essential'], orderOfApplication: MetadataOrderOfApplication.AfterDefault, mergeStrategy: MetadataMergeStrategy.Intersection, -} - -export const oneOfOperatorSchema = createPolicyOperatorSchema(oneOfOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/subsetOf.ts b/packages/core/src/metadata/operator/standard/subsetOf.ts index 3ab7a52..4bead30 100644 --- a/packages/core/src/metadata/operator/standard/subsetOf.ts +++ b/packages/core/src/metadata/operator/standard/subsetOf.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const subsetOfOperator: MetadataOperator = { +export const subsetOfOperator = createPolicyOperatorSchema({ key: 'subset_of', parameterJsonValues: [ z.array(z.string()), @@ -13,12 +12,11 @@ export const subsetOfOperator: MetadataOperator = { ], operatorJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], canBeCombinedWith: ['add', 'default', 'superset_of', 'essential'], orderOfApplication: MetadataOrderOfApplication.AfterOneOf, mergeStrategy: MetadataMergeStrategy.Intersection, -} - -export const subsetOfOperatorSchema = createPolicyOperatorSchema(subsetOfOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/supersetOf.ts b/packages/core/src/metadata/operator/standard/supersetOf.ts index 9470dd4..a8d2b26 100644 --- a/packages/core/src/metadata/operator/standard/supersetOf.ts +++ b/packages/core/src/metadata/operator/standard/supersetOf.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const supersetOfOperator: MetadataOperator = { +export const supersetOfOperator = createPolicyOperatorSchema({ key: 'superset_of', parameterJsonValues: [ z.array(z.string()), @@ -13,12 +12,11 @@ export const supersetOfOperator: MetadataOperator = { ], operatorJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], canBeCombinedWith: ['add', 'default', 'subset_of', 'essential'], orderOfApplication: MetadataOrderOfApplication.AfterSubsetOf, mergeStrategy: MetadataMergeStrategy.Union, -} - -export const supersetOfOperatorSchema = createPolicyOperatorSchema(supersetOfOperator) +}) diff --git a/packages/core/src/metadata/operator/standard/value.ts b/packages/core/src/metadata/operator/standard/value.ts index 59ccf76..dc9744a 100644 --- a/packages/core/src/metadata/operator/standard/value.ts +++ b/packages/core/src/metadata/operator/standard/value.ts @@ -1,10 +1,9 @@ import { z } from 'zod' import { MetadataMergeStrategy } from '../MetadataMergeStrategy' -import type { MetadataOperator } from '../MetadataOperator' import { MetadataOrderOfApplication } from '../MetadataOrderOfApplication' import { createPolicyOperatorSchema } from '../utils' -export const valueOperator: MetadataOperator = { +export const valueOperator = createPolicyOperatorSchema({ key: 'value', parameterJsonValues: [ z.string(), @@ -24,6 +23,4 @@ export const valueOperator: MetadataOperator = { canBeCombinedWith: ['essential'], orderOfApplication: MetadataOrderOfApplication.First, mergeStrategy: MetadataMergeStrategy.OperatorValuesEqual, -} - -export const valueOperatorSchema = createPolicyOperatorSchema(valueOperator) +}) diff --git a/packages/core/src/metadata/operator/utils/createPolicyOperatorSchema.ts b/packages/core/src/metadata/operator/utils/createPolicyOperatorSchema.ts index 80c2c35..bdfbb05 100644 --- a/packages/core/src/metadata/operator/utils/createPolicyOperatorSchema.ts +++ b/packages/core/src/metadata/operator/utils/createPolicyOperatorSchema.ts @@ -1,28 +1,29 @@ -import { z } from 'zod' import type { MetadataOperator } from '../MetadataOperator' -export const createPolicyOperatorSchema = (operator: MetadataOperator) => - z - .object({ - [operator.key]: operator.operatorJsonValues.reduce((acc, schema) => acc.or(schema)).optional(), - }) - .passthrough() - .superRefine((data, ctx) => { - const dataKeys = Object.keys(data) - - if ( - dataKeys.includes(operator.key) && - dataKeys.some((key) => key !== operator.key && !operator.canBeCombinedWith.includes(key)) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `policy operator '${ - operator.key - }' can only be combined with one of: [${operator.canBeCombinedWith.join( - ', ' - )}]. keys: [${Object.keys(data).join(',')}]`, - }) - } - - return data - }) +export const createPolicyOperatorSchema = (operator: MetadataOperator) => + ({ + /** + * The key of the operator which is used to identify the operator + */ + key: operator.key, + /** + * The merge strategy of the operator + */ + mergeStrategy: operator.mergeStrategy, + /** + * The order of application of the operator + */ + orderOfApplication: operator.orderOfApplication, + /** + * The operator itself + */ + operator: operator, + /** + * The schema for the operator itself so for essential it is only a boolean + */ + operatorSchema: operator.operatorJsonValues.reduce((acc, schema) => acc.or(schema)).optional(), + /** + * The schema for the parameter the operator can be applied to so when the policy is targeting federation_entity we can check if the policy can handle that property + */ + parameterSchema: operator.parameterJsonValues.reduce((acc, schema) => acc.or(schema)).optional(), + }) as const diff --git a/packages/core/src/metadata/policy.ts b/packages/core/src/metadata/policy.ts index fe8c05c..d2aeebb 100644 --- a/packages/core/src/metadata/policy.ts +++ b/packages/core/src/metadata/policy.ts @@ -1,20 +1,59 @@ -import type { z } from 'zod' +import { z } from 'zod' import { - addOperatorSchema, - defaultOperatorSchema, - essentialOperatorSchema, - oneOfOperatorSchema, - subsetOfOperatorSchema, - supersetOfOperatorSchema, - valueOperatorSchema, + addOperator, + defaultOperator, + essentialOperator, + oneOfOperator, + subsetOfOperator, + supersetOfOperator, + valueOperator, } from './operator' -export const metadataPolicySchema = addOperatorSchema - .and(defaultOperatorSchema.optional()) - .and(essentialOperatorSchema.optional()) - .and(oneOfOperatorSchema.optional()) - .and(supersetOfOperatorSchema.optional()) - .and(subsetOfOperatorSchema.optional()) - .and(valueOperatorSchema.optional()) +export const allSupportedPolicies = { + [addOperator.key]: addOperator, + [defaultOperator.key]: defaultOperator, + [essentialOperator.key]: essentialOperator, + [oneOfOperator.key]: oneOfOperator, + [subsetOfOperator.key]: subsetOfOperator, + [supersetOfOperator.key]: supersetOfOperator, + [valueOperator.key]: valueOperator, +} as const + +export type SupportedPolicyKey = keyof typeof allSupportedPolicies + +export const isExistingPolicyKey = (key: string): key is SupportedPolicyKey => key in allSupportedPolicies + +export const metadataPolicySchema = z + .object( + Object.entries(allSupportedPolicies).reduce( + // biome-ignore lint/performance/noAccumulatingSpread: In this case we want to use the spread operator + (acc, [key, policy]) => ({ ...acc, [key]: policy.operatorSchema }), + {} as { + [key in keyof typeof allSupportedPolicies]: (typeof allSupportedPolicies)[key]['operatorSchema'] + } + ) + ) + .passthrough() + .superRefine((data, ctx) => { + const dataKeys = Object.keys(data) + + for (const dataKey of dataKeys) { + if (!isExistingPolicyKey(dataKey)) continue + const { operator } = allSupportedPolicies[dataKey] + + if (dataKeys.some((key) => key !== operator.key && !operator.canBeCombinedWith.includes(key))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `policy operator '${ + operator.key + }' can only be combined with one of: [${operator.canBeCombinedWith.join( + ', ' + )}]. keys: [${Object.keys(data).join(',')}]`, + }) + } + } + + return data + }) export type MetadataPolicyOperator = z.input diff --git a/packages/core/src/resolveTrustChains/index.ts b/packages/core/src/resolveTrustChains/index.ts index 6bca3a2..219ebcd 100644 --- a/packages/core/src/resolveTrustChains/index.ts +++ b/packages/core/src/resolveTrustChains/index.ts @@ -1 +1,2 @@ export * from './resolveTrustChains' +export * from './policies' diff --git a/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts b/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts new file mode 100644 index 0000000..dc2fa4f --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts @@ -0,0 +1,38 @@ +import type { Metadata } from '../../metadata' + +export class MetadataHelper { + public constructor(private readonly leafMetadata: Record>) {} + + public get metadata(): Metadata { + return this.leafMetadata + } + + public hasProperty(service: string, property: string) { + return this.leafMetadata[service]?.[property] !== undefined + } + + public getPropertyValue(service: string, property: string): T | undefined { + const serviceBlock = this.leafMetadata[service] + if (serviceBlock === undefined) return undefined + const propertyValue = serviceBlock[property] + if (propertyValue === undefined) return undefined + return propertyValue as T + } + + public setPropertyValue(service: string, property: string, value: unknown) { + this.leafMetadata[service] ??= {} + this.leafMetadata[service][property] = value + } + + public deleteProperty(service: string, property: string) { + const serviceBlock = this.leafMetadata[service] + if (serviceBlock === undefined) return + + delete serviceBlock[property] + + // When the service block is empty we can delete the service block + if (Object.keys(serviceBlock).length === 0) { + delete this.leafMetadata[service] + } + } +} diff --git a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts new file mode 100644 index 0000000..df04520 --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts @@ -0,0 +1,147 @@ +import { objectToEntries } from '../../utils/data' + +import { cloneDeep } from '../../utils/data' + +import { type Metadata, type MetadataPolicyOperator, allSupportedPolicies } from '../../metadata' +import { MetadataHelper } from './MetadataHelper' +import { PolicyValidationError } from './errors/PolicyValidationError' +import { type PolicyValue, union } from './utils' + +export async function applyMetadataPolicyToMetadata({ + leafMetadata, + policyMetadata, +}: { leafMetadata: Metadata; policyMetadata: Record> }) { + const resolvedLeafMetadata = new MetadataHelper(cloneDeep(leafMetadata)) + + for (const [serviceKey, service] of objectToEntries(policyMetadata)) { + for (const [servicePropertyKey, policyValue] of objectToEntries(service)) { + const policies = objectToEntries(policyValue).sort( + ([policyKeyA], [policyKeyB]) => + allSupportedPolicies[policyKeyA].orderOfApplication - allSupportedPolicies[policyKeyB].orderOfApplication + ) + + const path = `${serviceKey}.${servicePropertyKey}` + + for (const [policyPropertyKey, valueFromPolicy] of policies) { + switch (policyPropertyKey) { + case 'value': + resolvedLeafMetadata.setPropertyValue(serviceKey, servicePropertyKey, valueFromPolicy) + break + case 'add': { + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) ?? [] + if (!Array.isArray(targetValue)) + throw new PolicyValidationError('Cannot apply add policy because the target is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + + const newValue = union(targetValue, valueFromPolicy) + resolvedLeafMetadata.setPropertyValue(serviceKey, servicePropertyKey, newValue) + break + } + case 'one_of': { + const targetValue = resolvedLeafMetadata.getPropertyValue( + serviceKey, + servicePropertyKey + ) + // With one_of it's allowed to not have a value and can be enforced with essential + if (targetValue === undefined) break + + if (!Array.isArray(valueFromPolicy)) + throw new PolicyValidationError('Cannot apply one_of policy because the value is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + + if (!valueFromPolicy.some((value) => value === targetValue)) + throw new PolicyValidationError('Cannot apply one_of policy because the intersection is empty', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + break + } + case 'default': + if (resolvedLeafMetadata.hasProperty(serviceKey, servicePropertyKey)) continue + + resolvedLeafMetadata.setPropertyValue(serviceKey, servicePropertyKey, valueFromPolicy) + break + case 'subset_of': { + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) + if (!Array.isArray(targetValue)) + throw new PolicyValidationError('Cannot apply subset_of policy because the target is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + if (!Array.isArray(valueFromPolicy)) + throw new PolicyValidationError('Cannot apply subset_of policy because the value is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + + const newValue = targetValue.filter((value) => valueFromPolicy.includes(value)) + + if (newValue.length === 0) { + resolvedLeafMetadata.deleteProperty(serviceKey, servicePropertyKey) + break + } + + resolvedLeafMetadata.setPropertyValue(serviceKey, servicePropertyKey, newValue) + + break + } + case 'superset_of': { + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) + if (!Array.isArray(targetValue)) + throw new PolicyValidationError('Cannot apply superset_of policy because the target is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + if (!Array.isArray(valueFromPolicy)) + throw new PolicyValidationError('Cannot apply superset_of policy because the value is not an array', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + + if (!targetValue.every((value) => valueFromPolicy.includes(value))) + throw new PolicyValidationError('The target does not contain all the values from the policy superset', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + break + } + case 'essential': { + if (!valueFromPolicy) break + + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) + if (targetValue === undefined) + throw new PolicyValidationError('The policy is required to have a value', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + + if (Array.isArray(targetValue) && targetValue.length === 0) + throw new PolicyValidationError('The target is empty and is essential to have a value', { + path, + policyValue: valueFromPolicy, + targetValue, + }) + break + } + } + } + } + } + + return { + resolvedLeafMetadata: resolvedLeafMetadata.metadata, + } +} diff --git a/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts new file mode 100644 index 0000000..ec15095 --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts @@ -0,0 +1,113 @@ +import { type MetadataPolicyOperator, allSupportedPolicies } from '../../metadata' +import { MetadataMergeStrategy } from '../../metadata/operator/MetadataMergeStrategy' +import { objectToEntries } from '../../utils/data' +import { PolicyOperatorMergeError } from './errors' +import { intersect, union } from './utils' + +// TODO: Add support for objects (they are optional in the spec) + +export function combineExistingMetadataPolicyOperators({ + contextPath, + existingPolicyRules, + newPolicyRules, +}: { + contextPath: string + existingPolicyRules: MetadataPolicyOperator + newPolicyRules: MetadataPolicyOperator +}) { + const combinedPolicyRules = { ...existingPolicyRules } + + for (const [policyPropertyKey, policyRuleValue] of objectToEntries(newPolicyRules)) { + if (!combinedPolicyRules[policyPropertyKey]) { + combinedPolicyRules[policyPropertyKey] = policyRuleValue + continue + } + + const existingPolicyRuleValue = combinedPolicyRules[policyPropertyKey] + const operator = allSupportedPolicies[policyPropertyKey] + + // Check if the new policy rule value is correctly used + const operatorInputResult = operator.operatorSchema.safeParse(policyRuleValue) + if (!operatorInputResult.success) throw operatorInputResult.error + const newPolicyRuleValue = operatorInputResult.data + + switch (operator.mergeStrategy) { + case MetadataMergeStrategy.OperatorValuesEqual: + if (Array.isArray(existingPolicyRuleValue) && Array.isArray(newPolicyRuleValue)) { + if (existingPolicyRuleValue.length !== newPolicyRuleValue.length) + throw new PolicyOperatorMergeError('Policy rule values are not equal', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + + if (existingPolicyRuleValue.some((value, i) => value !== newPolicyRuleValue[i])) + throw new PolicyOperatorMergeError('Policy rule values are not equal', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } else if (existingPolicyRuleValue !== newPolicyRuleValue) { + throw new PolicyOperatorMergeError('Policy rule values are not equal', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + + // Don't have to do anything because it is already there by the existing policy rule + break + case MetadataMergeStrategy.Union: + if (!Array.isArray(existingPolicyRuleValue) || !Array.isArray(newPolicyRuleValue)) { + throw new PolicyOperatorMergeError('Operator values are not an array', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + + combinedPolicyRules[policyPropertyKey] = union(existingPolicyRuleValue, newPolicyRuleValue) + break + case MetadataMergeStrategy.Intersection: { + if (!Array.isArray(existingPolicyRuleValue) || !Array.isArray(newPolicyRuleValue)) { + throw new PolicyOperatorMergeError('Existing policy rule value is not an array', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + const intersection = intersect(existingPolicyRuleValue, newPolicyRuleValue) + if (intersection.length === 0) { + throw new PolicyOperatorMergeError('Intersection is empty', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + + combinedPolicyRules[policyPropertyKey] = intersection + + break + } + case MetadataMergeStrategy.SuperiorFollowsIfTrue: + if (typeof existingPolicyRuleValue !== 'boolean' || typeof newPolicyRuleValue !== 'boolean') { + throw new PolicyOperatorMergeError('Existing policy rule value is not a boolean', { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + combinedPolicyRules[policyPropertyKey] = existingPolicyRuleValue || newPolicyRuleValue + break + + default: + throw new PolicyOperatorMergeError(`Unknown merge strategy: ${operator.mergeStrategy}`, { + path: contextPath, + operatorA: existingPolicyRules, + operatorB: newPolicyRules, + }) + } + } + + return combinedPolicyRules +} diff --git a/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts b/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts new file mode 100644 index 0000000..7ef3cfd --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts @@ -0,0 +1,82 @@ +import type { EntityStatementClaims } from '../../entityStatement' +import { OpenIdFederationError } from '../../error/OpenIdFederationError' +import { PolicyErrorStage } from '../../error/PolicyErrorStage' +import { type MetadataPolicyOperator, isExistingPolicyKey, metadataPolicySchema } from '../../metadata' +import { objectToEntries } from '../../utils/data' +import { combineExistingMetadataPolicyOperators } from './combineExistingMetadataPolicyOperators' + +type MetadataPolicy = Record> + +export function combineMetadataPolicies({ + statements, +}: { + /** + * The entity statements of the chain without the leaf entity + */ + statements: Array +}): { + mergedPolicy: MetadataPolicy +} { + if (statements.length === 0) throw new Error('Chain is empty') + const mergedPolicyMap: MetadataPolicy = {} + + // We start from the TA and go down to the intermediate + for (let i = statements.length - 1; i >= 0; i--) { + const entityStatement = statements[i] + if (!entityStatement) continue + // When the current entity doesn't have a metadata policy we can skip it + if (!entityStatement.metadata_policy) continue + + const metadataPolicyCrit = entityStatement.metadata_policy_crit + + for (const [serviceKey, serviceProperties] of objectToEntries(entityStatement.metadata_policy)) { + for (const [servicePropertyKey, newPolicyRules] of objectToEntries(serviceProperties)) { + if (newPolicyRules === undefined) continue + + const contextPath = `${serviceKey}.${servicePropertyKey}` + + // When the metadata_policy_crit is not empty we need to check if it contains a policy method that is not supported and is used by the new policy rule + if (metadataPolicyCrit) { + const methodsToCheck = Object.keys(newPolicyRules) + const unsupportedMethodsAndRequired = methodsToCheck.filter( + (method) => !isExistingPolicyKey(method) && metadataPolicyCrit.includes(method) + ) + if (unsupportedMethodsAndRequired.length > 0) { + throw new OpenIdFederationError( + PolicyErrorStage.MetadataPolicyCrit, + `Unsupported policy method and is required by the metadata_policy_crit: ${unsupportedMethodsAndRequired.join(', ')}` + ) + } + } + + const target = mergedPolicyMap[serviceKey] + const existingPolicyRule = target?.[servicePropertyKey] + if (!existingPolicyRule) { + // When there is no existing policy rule yet we can set the new policy rule + mergedPolicyMap[serviceKey] ??= {} + mergedPolicyMap[serviceKey][servicePropertyKey] = newPolicyRules + continue + } + + // So we found a new property which we already have in the map yet so this can be a federation_entity property for example + // We need to combine the existing policy rule together with the new one + const combinedPolicyRulesRaw = combineExistingMetadataPolicyOperators({ + contextPath: contextPath, + existingPolicyRules: existingPolicyRule, + newPolicyRules: newPolicyRules, + }) + + // Check if the the new properties are able to be combined + const combinedPolicyRules = metadataPolicySchema.safeParse(combinedPolicyRulesRaw) + if (!combinedPolicyRules.success) throw combinedPolicyRules.error + + mergedPolicyMap[serviceKey] ??= {} + mergedPolicyMap[serviceKey][servicePropertyKey] = combinedPolicyRules.data + } + } + } + + return { + mergedPolicy: mergedPolicyMap, + } +} diff --git a/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts b/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts new file mode 100644 index 0000000..911ca2b --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts @@ -0,0 +1,17 @@ +import type { MetadataPolicyOperator } from '../../../metadata' + +type Details = { + path: string + operatorA: MetadataPolicyOperator + operatorB: MetadataPolicyOperator +} + +export class PolicyOperatorMergeError extends Error { + public readonly details: Details + + constructor(message: string, details: Details) { + super(message) + this.name = 'PolicyMergeError' + this.details = details + } +} diff --git a/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts b/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts new file mode 100644 index 0000000..6ae42bb --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts @@ -0,0 +1,15 @@ +type Details = { + path: string + policyValue: unknown + targetValue: unknown +} + +export class PolicyValidationError extends Error { + public readonly details: Details + + public constructor(message: string, details: Details) { + super(message) + this.name = 'PolicyValidationError' + this.details = details + } +} diff --git a/packages/core/src/resolveTrustChains/policies/errors/index.ts b/packages/core/src/resolveTrustChains/policies/errors/index.ts new file mode 100644 index 0000000..31939da --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/errors/index.ts @@ -0,0 +1,2 @@ +export * from './PolicyMergeError' +export * from './PolicyValidationError' diff --git a/packages/core/src/resolveTrustChains/policies/index.ts b/packages/core/src/resolveTrustChains/policies/index.ts new file mode 100644 index 0000000..93700e5 --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/index.ts @@ -0,0 +1,3 @@ +export * from './combineMetadataPolicies' +export * from './applyMetadataPolicyToMetadata' +export * from './errors' diff --git a/packages/core/src/resolveTrustChains/policies/utils.ts b/packages/core/src/resolveTrustChains/policies/utils.ts new file mode 100644 index 0000000..a5a68e5 --- /dev/null +++ b/packages/core/src/resolveTrustChains/policies/utils.ts @@ -0,0 +1,36 @@ +import { objectToEntries } from '../../utils/data' + +import type { Metadata } from '../../metadata' +import { cloneDeep } from '../../utils/data' +import { MetadataHelper } from './MetadataHelper' + +export type PolicyValue = boolean | string | number + +type PolicyValueArray = (boolean | string | number)[] + +export function intersect(operator1: PolicyValueArray, operator2: PolicyValueArray) { + const set1 = new Set(operator1) + const set2 = new Set(operator2) + const intersection = [...set1].filter((value) => set2.has(value)) + return intersection +} + +export function union(operator1: PolicyValueArray, operator2: PolicyValueArray) { + return [...new Set([...operator1, ...operator2])] +} + +/** + * Merges the metadata of the leaf entity with the metadata of the superior entity statement + * The superior always has the highest priority + */ +export function mergeMetadata(leafConfigMetadata: Metadata, superiorEntityStatement: Metadata): Metadata { + const mergedLeafMetadata = new MetadataHelper(cloneDeep(leafConfigMetadata)) + + for (const [entityType, entityConfigMetadata] of objectToEntries(superiorEntityStatement)) { + for (const [key, value] of objectToEntries(entityConfigMetadata)) { + mergedLeafMetadata.setPropertyValue(entityType, key, value) + } + } + + return mergedLeafMetadata.metadata +} diff --git a/packages/core/src/resolveTrustChains/resolveTrustChains.ts b/packages/core/src/resolveTrustChains/resolveTrustChains.ts index 72e4360..333494c 100644 --- a/packages/core/src/resolveTrustChains/resolveTrustChains.ts +++ b/packages/core/src/resolveTrustChains/resolveTrustChains.ts @@ -1,17 +1,16 @@ -// * Fetch the entity configurations, until the trust anchors are hit -// * Fetch the entity statements back until the entityId is hit -// * Merge and apply the policies, trickeling down -// * Return a list of trust chains where the policies are applied, ending up at the `entityId` again -// * Errors -// * when no trust anchor could be found -// * when no trust chain with valid applied could be found -// resolveTrustChains(entityId: string, trustAnchorEntityIds: Array) -> Promise> - import { type fetchEntityConfiguration, fetchEntityConfigurationChains } from '../entityConfiguration' import { fetchEntityStatementChain } from '../entityStatement' -import { ErrorCode } from '../error/ErrorCode' import { OpenIdFederationError } from '../error/OpenIdFederationError' +import { PolicyErrorStage } from '../error/PolicyErrorStage' import type { VerifyCallback } from '../utils' +import { tryCatch } from '../utils/tryCatch' +import { + PolicyOperatorMergeError, + PolicyValidationError, + applyMetadataPolicyToMetadata, + combineMetadataPolicies, +} from './policies' +import { mergeMetadata } from './policies/utils' type Options = { verifyJwtCallback: VerifyCallback @@ -19,17 +18,24 @@ type Options = { trustAnchorEntityIds: Array } -// TODO: Use more direct types instead of Awaited return types +type leafEntityConfiguration = Awaited> + type TrustChain = { chain: Awaited> - // TODO: Not sure if this needs to be the entity configuration with all the policies applied - leafEntityConfiguration: Awaited> + /** + * The raw leaf entity configuration before the policy is applied. + * So the metadata is not valid yet. + */ + rawLeafEntityConfiguration: leafEntityConfiguration + /** + * The resolved leaf metadata after the policy is applied and the metadata is merged with the superior entity's metadata. + * This should be used to + */ + resolvedLeafMetadata: leafEntityConfiguration['metadata'] trustAnchorEntityConfiguration: Awaited> } -// TODO: Apply the policies -// TODO: Look into what we want to return in this function. Because the entity configuration is also very valuable -// TODO: We might also need to return the entity configuration which has all the policies applied. So that a chain has both the statements and the configuration +// TODO: Think about how we make this more open for debugging. Because when something goes wrong now in the policies it will be skipped but you can't really see what went wrong. /** * Resolves the trust chains for the given entityId and trust anchor entityIds. @@ -59,24 +65,92 @@ export const resolveTrustChains = async (options: Options): Promise statement.exp < now)) { // Skip expired chains + // TODO: Think about how we want to share this conclusion with the caller continue } - // TODO: Merge all the policies and check them against the metadata of the leaf entity + if (entityStatementChain.length === 1) { + // When there is only one statement, we can assume that the leaf is also the trust anchor + const leafEntityConfiguration = entityConfigurationChain[0] + if (!leafEntityConfiguration) + throw new OpenIdFederationError(PolicyErrorStage.Validation, 'No leaf entity configuration found') + + trustChains.push({ + chain: entityStatementChain, + trustAnchorEntityConfiguration: leafEntityConfiguration, + rawLeafEntityConfiguration: leafEntityConfiguration, + resolvedLeafMetadata: leafEntityConfiguration.metadata, + }) + continue + } const leafEntityConfiguration = entityConfigurationChain[0] - // Should never happen but for the type safety if (!leafEntityConfiguration) - throw new OpenIdFederationError(ErrorCode.Validation, 'No leaf entity configuration found') + throw new OpenIdFederationError(PolicyErrorStage.Validation, 'No leaf entity configuration found') + + const statementsWithoutLeaf = entityStatementChain.slice(0, -1) + const combinedPolicyResult = await tryCatch(async () => + combineMetadataPolicies({ + statements: statementsWithoutLeaf, + }) + ) + if (!combinedPolicyResult.success) { + if (combinedPolicyResult.error instanceof PolicyOperatorMergeError) { + // When some operators can't be merged, we can declare the chain invalid + // TODO: Think about how we want to share this conclusion with the caller + continue + } + if (OpenIdFederationError.isMetadataPolicyCritError(combinedPolicyResult.error)) { + // When the error is a metadata_policy_crit error, we can declare the chain invalid + // TODO: Think about how we want to share this conclusion with the caller + continue + } + + throw new OpenIdFederationError( + PolicyErrorStage.PolicyMerge, + 'Unexpected error while applying policy', + combinedPolicyResult.error + ) + } + const { mergedPolicy } = combinedPolicyResult.value + + // When the superior entity has a metadata in it's statement we need to merge that first with the leaf metadata. Before applying the policy + const superiorEntityStatement = statementsWithoutLeaf[0] + const mergedLeafMetadata = mergeMetadata( + leafEntityConfiguration.metadata ?? {}, + superiorEntityStatement?.metadata ?? {} + ) + + const policyApplyResult = await tryCatch(() => + applyMetadataPolicyToMetadata({ + leafMetadata: mergedLeafMetadata, + policyMetadata: mergedPolicy, + }) + ) + if (!policyApplyResult.success) { + if (policyApplyResult.error instanceof PolicyValidationError) { + // When the policy validation fails on the leaf metadata, we can declare the chain invalid + // TODO: Think about how we want to share this conclusion with the caller + continue + } + + throw new OpenIdFederationError( + PolicyErrorStage.PolicyMerge, + 'Unexpected error while applying policy', + policyApplyResult.error + ) + } + const { resolvedLeafMetadata } = policyApplyResult.value const trustAnchorEntityConfiguration = entityConfigurationChain[entityConfigurationChain.length - 1] if (!trustAnchorEntityConfiguration) - throw new OpenIdFederationError(ErrorCode.Validation, 'No trust anchor entity configuration found') + throw new OpenIdFederationError(PolicyErrorStage.Validation, 'No trust anchor entity configuration found') trustChains.push({ chain: entityStatementChain, trustAnchorEntityConfiguration, - leafEntityConfiguration, + rawLeafEntityConfiguration: leafEntityConfiguration, + resolvedLeafMetadata: resolvedLeafMetadata, }) } diff --git a/packages/core/src/utils/data.ts b/packages/core/src/utils/data.ts new file mode 100644 index 0000000..00c9553 --- /dev/null +++ b/packages/core/src/utils/data.ts @@ -0,0 +1,15 @@ +/** + * Converts an object to an array of entries with better typing for the key + */ +export function objectToEntries< + TObject extends { + [Tkey in keyof TObject]: TObject[Tkey] + }, +>(obj: TObject) { + return Object.entries(obj) as [keyof TObject, TObject[keyof TObject]][] +} + +/** + * Creates a deep clone of an object + */ +export const cloneDeep = (obj: T): T => JSON.parse(JSON.stringify(obj)) diff --git a/packages/core/src/utils/tryCatch.ts b/packages/core/src/utils/tryCatch.ts new file mode 100644 index 0000000..32186b4 --- /dev/null +++ b/packages/core/src/utils/tryCatch.ts @@ -0,0 +1,9 @@ +export async function tryCatch( + fn: () => Promise +): Promise<{ success: true; value: T } | { success: false; error: unknown }> { + try { + return { success: true, value: await fn() } as const + } catch (error) { + return { success: false, error } as const + } +} diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index d54bc55..dfe8fb5 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -1,5 +1,5 @@ -import { ErrorCode } from '../error/ErrorCode' import { OpenIdFederationError } from '../error/OpenIdFederationError' +import { PolicyErrorStage } from '../error/PolicyErrorStage' /** * @@ -22,7 +22,7 @@ import { OpenIdFederationError } from '../error/OpenIdFederationError' export const addPaths = (baseUrl: string, ...paths: Array) => { const [scheme, rest] = baseUrl.split('://') if (!rest) { - throw new OpenIdFederationError(ErrorCode.Validation, 'not a valid URL') + throw new OpenIdFederationError(PolicyErrorStage.Validation, 'not a valid URL') } const urlWithoutScheme = rest From bc15c4866a4df702b3d20bab51bf5d79af0e489c Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 20 Jan 2025 14:48:11 +0100 Subject: [PATCH 2/9] fix: Typing Signed-off-by: Tom Lanser --- packages/core/src/metadata/policy.ts | 2 +- .../policies/applyMetadataPolicyToMetadata.ts | 28 +++++++++++-------- .../combineExistingMetadataPolicyOperators.ts | 5 +++- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/core/src/metadata/policy.ts b/packages/core/src/metadata/policy.ts index d2aeebb..a608d05 100644 --- a/packages/core/src/metadata/policy.ts +++ b/packages/core/src/metadata/policy.ts @@ -33,7 +33,7 @@ export const metadataPolicySchema = z } ) ) - .passthrough() + .and(z.record(z.string(), z.any())) .superRefine((data, ctx) => { const dataKeys = Object.keys(data) diff --git a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts index df04520..37a1525 100644 --- a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts +++ b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts @@ -2,7 +2,13 @@ import { objectToEntries } from '../../utils/data' import { cloneDeep } from '../../utils/data' -import { type Metadata, type MetadataPolicyOperator, allSupportedPolicies } from '../../metadata' +import { + type Metadata, + type MetadataPolicyOperator, + type SupportedPolicyKey, + allSupportedPolicies, + isExistingPolicyKey, +} from '../../metadata' import { MetadataHelper } from './MetadataHelper' import { PolicyValidationError } from './errors/PolicyValidationError' import { type PolicyValue, union } from './utils' @@ -15,10 +21,13 @@ export async function applyMetadataPolicyToMetadata({ for (const [serviceKey, service] of objectToEntries(policyMetadata)) { for (const [servicePropertyKey, policyValue] of objectToEntries(service)) { - const policies = objectToEntries(policyValue).sort( - ([policyKeyA], [policyKeyB]) => - allSupportedPolicies[policyKeyA].orderOfApplication - allSupportedPolicies[policyKeyB].orderOfApplication - ) + const policies = objectToEntries(policyValue) + .filter(([key]) => isExistingPolicyKey(key)) + .sort( + ([policyKeyA], [policyKeyB]) => + allSupportedPolicies[policyKeyA as SupportedPolicyKey].orderOfApplication - + allSupportedPolicies[policyKeyB as SupportedPolicyKey].orderOfApplication + ) const path = `${serviceKey}.${servicePropertyKey}` @@ -41,10 +50,7 @@ export async function applyMetadataPolicyToMetadata({ break } case 'one_of': { - const targetValue = resolvedLeafMetadata.getPropertyValue( - serviceKey, - servicePropertyKey - ) + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) // With one_of it's allowed to not have a value and can be enforced with essential if (targetValue === undefined) break @@ -95,7 +101,7 @@ export async function applyMetadataPolicyToMetadata({ break } case 'superset_of': { - const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) if (!Array.isArray(targetValue)) throw new PolicyValidationError('Cannot apply superset_of policy because the target is not an array', { path, @@ -120,7 +126,7 @@ export async function applyMetadataPolicyToMetadata({ case 'essential': { if (!valueFromPolicy) break - const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) + const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) if (targetValue === undefined) throw new PolicyValidationError('The policy is required to have a value', { path, diff --git a/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts index ec15095..558ae34 100644 --- a/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts +++ b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts @@ -1,4 +1,4 @@ -import { type MetadataPolicyOperator, allSupportedPolicies } from '../../metadata' +import { type MetadataPolicyOperator, allSupportedPolicies, isExistingPolicyKey } from '../../metadata' import { MetadataMergeStrategy } from '../../metadata/operator/MetadataMergeStrategy' import { objectToEntries } from '../../utils/data' import { PolicyOperatorMergeError } from './errors' @@ -18,6 +18,9 @@ export function combineExistingMetadataPolicyOperators({ const combinedPolicyRules = { ...existingPolicyRules } for (const [policyPropertyKey, policyRuleValue] of objectToEntries(newPolicyRules)) { + // When we don't know the policy key we can skip it because we already know it's not critical + if (!isExistingPolicyKey(policyPropertyKey)) continue + if (!combinedPolicyRules[policyPropertyKey]) { combinedPolicyRules[policyPropertyKey] = policyRuleValue continue From a24f5d59c6b3df65a645bb03ed7a1d3b508901b7 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 27 Jan 2025 14:46:04 +0100 Subject: [PATCH 3/9] feat: Implementation for value: null Signed-off-by: Tom Lanser --- .../core/__tests__/resolveTrustChains.test.ts | 59 +++++++++++++++++++ .../policies/applyMetadataPolicyToMetadata.ts | 6 ++ packages/core/src/utils/data.ts | 3 + 3 files changed, 68 insertions(+) diff --git a/packages/core/__tests__/resolveTrustChains.test.ts b/packages/core/__tests__/resolveTrustChains.test.ts index 2f0b291..41df82d 100644 --- a/packages/core/__tests__/resolveTrustChains.test.ts +++ b/packages/core/__tests__/resolveTrustChains.test.ts @@ -666,4 +666,63 @@ describe('fetch trust chains', () => { }, }) }) + + it('should remove a property when a operator value is null', async () => { + const leafEntityId = 'https://leaff.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [trustAnchorEntityId], + claims: { + metadata: { + openid_relying_party: { + policy_uri: 'https://org.example.org/policy.html', + + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + policy_uri: { + value: null, + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 2) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + openid_relying_party: { + client_registration_types: ['automatic'], + }, + }) + }) }) diff --git a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts index 37a1525..8ca1f65 100644 --- a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts +++ b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts @@ -34,6 +34,12 @@ export async function applyMetadataPolicyToMetadata({ for (const [policyPropertyKey, valueFromPolicy] of policies) { switch (policyPropertyKey) { case 'value': + if (valueFromPolicy === null) { + // When the policy value is null, we delete the property + resolvedLeafMetadata.deleteProperty(serviceKey, servicePropertyKey) + break + } + resolvedLeafMetadata.setPropertyValue(serviceKey, servicePropertyKey, valueFromPolicy) break case 'add': { diff --git a/packages/core/src/utils/data.ts b/packages/core/src/utils/data.ts index 00c9553..a12deb6 100644 --- a/packages/core/src/utils/data.ts +++ b/packages/core/src/utils/data.ts @@ -13,3 +13,6 @@ export function objectToEntries< * Creates a deep clone of an object */ export const cloneDeep = (obj: T): T => JSON.parse(JSON.stringify(obj)) + +export const isNullOrUndefined = (value: unknown): value is null | undefined => value === null || value === undefined + From ab5f3bf468627d3ea083802668b663475b0b3778 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Wed, 29 Jan 2025 12:15:50 +0100 Subject: [PATCH 4/9] feat: Processed more feedback Signed-off-by: Tom Lanser --- biome.json | 4 ++ .../core/__tests__/resolveTrustChains.test.ts | 15 ++---- .../fetchEntityStatementChain.ts | 4 +- packages/core/src/error/PolicyErrorStage.ts | 10 ++-- .../policies/MetadataHelper.ts | 33 ++++++++++--- .../policies/applyMetadataPolicyToMetadata.ts | 14 +++--- .../combineExistingMetadataPolicyOperators.ts | 2 +- .../policies/combineMetadataPolicies.ts | 21 ++++---- .../policies/errors/PolicyMergeError.ts | 8 +-- .../policies/errors/PolicyValidationError.ts | 6 +-- .../src/resolveTrustChains/policies/utils.ts | 9 ++-- .../resolveTrustChains/resolveTrustChains.ts | 49 ++++++++----------- packages/core/src/utils/data.ts | 15 ++++-- packages/core/src/utils/tryCatch.ts | 22 +++++++-- 14 files changed, 115 insertions(+), 97 deletions(-) diff --git a/biome.json b/biome.json index f389a39..1a47735 100644 --- a/biome.json +++ b/biome.json @@ -42,6 +42,10 @@ "style": { "useNodeAssertStrict": { "level": "error", "fix": "unsafe" }, "useNodejsImportProtocol": { "level": "off" } + }, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" } } }, diff --git a/packages/core/__tests__/resolveTrustChains.test.ts b/packages/core/__tests__/resolveTrustChains.test.ts index 41df82d..ae16f0b 100644 --- a/packages/core/__tests__/resolveTrustChains.test.ts +++ b/packages/core/__tests__/resolveTrustChains.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' import { resolveTrustChains } from '../src/resolveTrustChains' import type { SignCallback, VerifyCallback } from '../src/utils' import { setupConfigurationChain } from './utils/setupConfigurationChain' @@ -40,7 +39,7 @@ describe('fetch trust chains', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const { chainData: configurations, nockScopes } = await setupConfigurationChain( + const { nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, { @@ -53,10 +52,6 @@ describe('fetch trust chains', () => { { signJwtCallback, mockEndpoints: true } ) - const claims: Array = configurations.map( - ({ claims: configurationClaims }) => configurationClaims - ) - const trustChains = await resolveTrustChains({ entityId: leafEntityId, trustAnchorEntityIds: [trustAnchorEntityId], @@ -80,7 +75,7 @@ describe('fetch trust chains', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const { chainData: configurations, nockScopes } = await setupConfigurationChain( + const { nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, { @@ -110,10 +105,6 @@ describe('fetch trust chains', () => { { signJwtCallback, mockEndpoints: true } ) - const claims: Array = configurations.map( - ({ claims: configurationClaims }) => configurationClaims - ) - const trustChains = await resolveTrustChains({ entityId: leafEntityId, trustAnchorEntityIds: [trustAnchorEntityId], @@ -179,7 +170,7 @@ describe('fetch trust chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const { chainData: configurations, nockScopes } = await setupConfigurationChain( + await setupConfigurationChain( [ { entityId: leafEntityId, diff --git a/packages/core/src/entityStatement/fetchEntityStatementChain.ts b/packages/core/src/entityStatement/fetchEntityStatementChain.ts index b98f4f2..df88222 100644 --- a/packages/core/src/entityStatement/fetchEntityStatementChain.ts +++ b/packages/core/src/entityStatement/fetchEntityStatementChain.ts @@ -10,10 +10,12 @@ export type FetchEntityStatementChainOptions = { verifyJwtCallback: VerifyCallback } +export type EntityStatementChain = Array + export const fetchEntityStatementChain = async ({ verifyJwtCallback, entityConfigurations, -}: FetchEntityStatementChainOptions): Promise> => { +}: FetchEntityStatementChainOptions): Promise => { if (entityConfigurations.length === 0) { throw new OpenIdFederationError( PolicyErrorStage.Validation, diff --git a/packages/core/src/error/PolicyErrorStage.ts b/packages/core/src/error/PolicyErrorStage.ts index 9ea5d85..27ce4f7 100644 --- a/packages/core/src/error/PolicyErrorStage.ts +++ b/packages/core/src/error/PolicyErrorStage.ts @@ -1,7 +1,7 @@ export enum PolicyErrorStage { - Generic = 0, - Validation = 1, - MetadataPolicyCrit = 2, - PolicyMerge = 3, - PolicyApply = 4, + Generic = 'generic', + Validation = 'validation', + MetadataPolicyCrit = 'metadataPolicyCrit', + PolicyMerge = 'policyMerge', + PolicyApply = 'policyApply', } diff --git a/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts b/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts index dc2fa4f..6ded258 100644 --- a/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts +++ b/packages/core/src/resolveTrustChains/policies/MetadataHelper.ts @@ -1,11 +1,8 @@ import type { Metadata } from '../../metadata' +import type { MetadataPolicy } from '../../metadata/metadataPolicy' export class MetadataHelper { - public constructor(private readonly leafMetadata: Record>) {} - - public get metadata(): Metadata { - return this.leafMetadata - } + public constructor(private leafMetadata: Record>) {} public hasProperty(service: string, property: string) { return this.leafMetadata[service]?.[property] !== undefined @@ -20,19 +17,39 @@ export class MetadataHelper { } public setPropertyValue(service: string, property: string, value: unknown) { - this.leafMetadata[service] ??= {} - this.leafMetadata[service][property] = value + this.leafMetadata = { + ...this.leafMetadata, + [service]: { + ...this.leafMetadata[service], // optionally add a helper method to get a value or an empty object otherwise + [property]: value, + }, + } } public deleteProperty(service: string, property: string) { const serviceBlock = this.leafMetadata[service] if (serviceBlock === undefined) return - delete serviceBlock[property] + this.leafMetadata = { + ...this.leafMetadata, + [service]: { + ...this.leafMetadata[service], + }, + } + + delete this.leafMetadata[service][property] // When the service block is empty we can delete the service block if (Object.keys(serviceBlock).length === 0) { delete this.leafMetadata[service] } } + + public asMetadata(): Metadata { + return this.leafMetadata + } + + public asMetadataPolicy(): MetadataPolicy { + return this.leafMetadata + } } diff --git a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts index 8ca1f65..c235e1c 100644 --- a/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts +++ b/packages/core/src/resolveTrustChains/policies/applyMetadataPolicyToMetadata.ts @@ -1,6 +1,4 @@ -import { objectToEntries } from '../../utils/data' - -import { cloneDeep } from '../../utils/data' +import { immutable, isNullOrUndefined, objectToEntries } from '../../utils/data' import { type Metadata, @@ -13,11 +11,11 @@ import { MetadataHelper } from './MetadataHelper' import { PolicyValidationError } from './errors/PolicyValidationError' import { type PolicyValue, union } from './utils' -export async function applyMetadataPolicyToMetadata({ +export function applyMetadataPolicyToMetadata({ leafMetadata, policyMetadata, }: { leafMetadata: Metadata; policyMetadata: Record> }) { - const resolvedLeafMetadata = new MetadataHelper(cloneDeep(leafMetadata)) + const resolvedLeafMetadata = new MetadataHelper(immutable(leafMetadata)) for (const [serviceKey, service] of objectToEntries(policyMetadata)) { for (const [servicePropertyKey, policyValue] of objectToEntries(service)) { @@ -58,7 +56,7 @@ export async function applyMetadataPolicyToMetadata({ case 'one_of': { const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) // With one_of it's allowed to not have a value and can be enforced with essential - if (targetValue === undefined) break + if (isNullOrUndefined(targetValue)) break if (!Array.isArray(valueFromPolicy)) throw new PolicyValidationError('Cannot apply one_of policy because the value is not an array', { @@ -133,7 +131,7 @@ export async function applyMetadataPolicyToMetadata({ if (!valueFromPolicy) break const targetValue = resolvedLeafMetadata.getPropertyValue(serviceKey, servicePropertyKey) - if (targetValue === undefined) + if (isNullOrUndefined(targetValue)) throw new PolicyValidationError('The policy is required to have a value', { path, policyValue: valueFromPolicy, @@ -154,6 +152,6 @@ export async function applyMetadataPolicyToMetadata({ } return { - resolvedLeafMetadata: resolvedLeafMetadata.metadata, + resolvedLeafMetadata: resolvedLeafMetadata.asMetadata(), } } diff --git a/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts index 558ae34..d3fdf6d 100644 --- a/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts +++ b/packages/core/src/resolveTrustChains/policies/combineExistingMetadataPolicyOperators.ts @@ -100,7 +100,7 @@ export function combineExistingMetadataPolicyOperators({ operatorB: newPolicyRules, }) } - combinedPolicyRules[policyPropertyKey] = existingPolicyRuleValue || newPolicyRuleValue + combinedPolicyRules[policyPropertyKey] = existingPolicyRuleValue ?? newPolicyRuleValue break default: diff --git a/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts b/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts index 7ef3cfd..30fad61 100644 --- a/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts +++ b/packages/core/src/resolveTrustChains/policies/combineMetadataPolicies.ts @@ -1,12 +1,12 @@ import type { EntityStatementClaims } from '../../entityStatement' import { OpenIdFederationError } from '../../error/OpenIdFederationError' import { PolicyErrorStage } from '../../error/PolicyErrorStage' -import { type MetadataPolicyOperator, isExistingPolicyKey, metadataPolicySchema } from '../../metadata' +import { isExistingPolicyKey, metadataPolicySchema } from '../../metadata' +import type { MetadataPolicy } from '../../metadata/metadataPolicy' import { objectToEntries } from '../../utils/data' +import { MetadataHelper } from './MetadataHelper' import { combineExistingMetadataPolicyOperators } from './combineExistingMetadataPolicyOperators' -type MetadataPolicy = Record> - export function combineMetadataPolicies({ statements, }: { @@ -17,8 +17,8 @@ export function combineMetadataPolicies({ }): { mergedPolicy: MetadataPolicy } { - if (statements.length === 0) throw new Error('Chain is empty') - const mergedPolicyMap: MetadataPolicy = {} + if (statements.length === 0) throw new OpenIdFederationError(PolicyErrorStage.Generic, 'Chain is empty') + const mergedPolicyMap = new MetadataHelper({}) // We start from the TA and go down to the intermediate for (let i = statements.length - 1; i >= 0; i--) { @@ -49,12 +49,10 @@ export function combineMetadataPolicies({ } } - const target = mergedPolicyMap[serviceKey] - const existingPolicyRule = target?.[servicePropertyKey] + const existingPolicyRule = mergedPolicyMap.getPropertyValue(serviceKey, servicePropertyKey) if (!existingPolicyRule) { // When there is no existing policy rule yet we can set the new policy rule - mergedPolicyMap[serviceKey] ??= {} - mergedPolicyMap[serviceKey][servicePropertyKey] = newPolicyRules + mergedPolicyMap.setPropertyValue(serviceKey, servicePropertyKey, newPolicyRules) continue } @@ -70,13 +68,12 @@ export function combineMetadataPolicies({ const combinedPolicyRules = metadataPolicySchema.safeParse(combinedPolicyRulesRaw) if (!combinedPolicyRules.success) throw combinedPolicyRules.error - mergedPolicyMap[serviceKey] ??= {} - mergedPolicyMap[serviceKey][servicePropertyKey] = combinedPolicyRules.data + mergedPolicyMap.setPropertyValue(serviceKey, servicePropertyKey, combinedPolicyRules.data) } } } return { - mergedPolicy: mergedPolicyMap, + mergedPolicy: mergedPolicyMap.asMetadataPolicy(), } } diff --git a/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts b/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts index 911ca2b..37fe44e 100644 --- a/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts +++ b/packages/core/src/resolveTrustChains/policies/errors/PolicyMergeError.ts @@ -1,17 +1,17 @@ import type { MetadataPolicyOperator } from '../../../metadata' -type Details = { +export type PolicyOperatorMergeErrorDetails = { path: string operatorA: MetadataPolicyOperator operatorB: MetadataPolicyOperator } export class PolicyOperatorMergeError extends Error { - public readonly details: Details + public readonly details: PolicyOperatorMergeErrorDetails - constructor(message: string, details: Details) { + constructor(message: string, details: PolicyOperatorMergeErrorDetails) { super(message) - this.name = 'PolicyMergeError' + this.name = 'PolicyOperatorMergeError' this.details = details } } diff --git a/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts b/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts index 6ae42bb..2c98e6d 100644 --- a/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts +++ b/packages/core/src/resolveTrustChains/policies/errors/PolicyValidationError.ts @@ -1,13 +1,13 @@ -type Details = { +export type PolicyValidationErrorDetails = { path: string policyValue: unknown targetValue: unknown } export class PolicyValidationError extends Error { - public readonly details: Details + public readonly details: PolicyValidationErrorDetails - public constructor(message: string, details: Details) { + public constructor(message: string, details: PolicyValidationErrorDetails) { super(message) this.name = 'PolicyValidationError' this.details = details diff --git a/packages/core/src/resolveTrustChains/policies/utils.ts b/packages/core/src/resolveTrustChains/policies/utils.ts index a5a68e5..79514fa 100644 --- a/packages/core/src/resolveTrustChains/policies/utils.ts +++ b/packages/core/src/resolveTrustChains/policies/utils.ts @@ -1,12 +1,11 @@ -import { objectToEntries } from '../../utils/data' +import { immutable, objectToEntries } from '../../utils/data' import type { Metadata } from '../../metadata' -import { cloneDeep } from '../../utils/data' import { MetadataHelper } from './MetadataHelper' export type PolicyValue = boolean | string | number -type PolicyValueArray = (boolean | string | number)[] +export type PolicyValueArray = PolicyValue[] export function intersect(operator1: PolicyValueArray, operator2: PolicyValueArray) { const set1 = new Set(operator1) @@ -24,7 +23,7 @@ export function union(operator1: PolicyValueArray, operator2: PolicyValueArray) * The superior always has the highest priority */ export function mergeMetadata(leafConfigMetadata: Metadata, superiorEntityStatement: Metadata): Metadata { - const mergedLeafMetadata = new MetadataHelper(cloneDeep(leafConfigMetadata)) + const mergedLeafMetadata = new MetadataHelper(immutable(leafConfigMetadata)) for (const [entityType, entityConfigMetadata] of objectToEntries(superiorEntityStatement)) { for (const [key, value] of objectToEntries(entityConfigMetadata)) { @@ -32,5 +31,5 @@ export function mergeMetadata(leafConfigMetadata: Metadata, superiorEntityStatem } } - return mergedLeafMetadata.metadata + return mergedLeafMetadata.asMetadata() } diff --git a/packages/core/src/resolveTrustChains/resolveTrustChains.ts b/packages/core/src/resolveTrustChains/resolveTrustChains.ts index 333494c..6a562cc 100644 --- a/packages/core/src/resolveTrustChains/resolveTrustChains.ts +++ b/packages/core/src/resolveTrustChains/resolveTrustChains.ts @@ -1,5 +1,5 @@ -import { type fetchEntityConfiguration, fetchEntityConfigurationChains } from '../entityConfiguration' -import { fetchEntityStatementChain } from '../entityStatement' +import { type EntityConfigurationClaims, fetchEntityConfigurationChains } from '../entityConfiguration' +import { type EntityStatementChain, fetchEntityStatementChain } from '../entityStatement' import { OpenIdFederationError } from '../error/OpenIdFederationError' import { PolicyErrorStage } from '../error/PolicyErrorStage' import type { VerifyCallback } from '../utils' @@ -18,21 +18,19 @@ type Options = { trustAnchorEntityIds: Array } -type leafEntityConfiguration = Awaited> - -type TrustChain = { - chain: Awaited> +export type TrustChain = { + chain: EntityStatementChain /** * The raw leaf entity configuration before the policy is applied. * So the metadata is not valid yet. */ - rawLeafEntityConfiguration: leafEntityConfiguration + rawLeafEntityConfiguration: EntityConfigurationClaims /** * The resolved leaf metadata after the policy is applied and the metadata is merged with the superior entity's metadata. * This should be used to */ - resolvedLeafMetadata: leafEntityConfiguration['metadata'] - trustAnchorEntityConfiguration: Awaited> + resolvedLeafMetadata: EntityConfigurationClaims['metadata'] + trustAnchorEntityConfiguration: EntityConfigurationClaims } // TODO: Think about how we make this more open for debugging. Because when something goes wrong now in the policies it will be skipped but you can't really see what went wrong. @@ -62,6 +60,7 @@ export const resolveTrustChains = async (options: Options): Promise statement.exp < now)) { // Skip expired chains @@ -69,11 +68,10 @@ export const resolveTrustChains = async (options: Options): Promise + const combinedPolicyResult = tryCatch(() => combineMetadataPolicies({ statements: statementsWithoutLeaf, }) ) if (!combinedPolicyResult.success) { + // TODO: In this function we can make conclusions they all now continue but at some point they should be reflected back to the caller if (combinedPolicyResult.error instanceof PolicyOperatorMergeError) { // When some operators can't be merged, we can declare the chain invalid // TODO: Think about how we want to share this conclusion with the caller @@ -106,11 +101,9 @@ export const resolveTrustChains = async (options: Options): Promise + const policyApplyResult = tryCatch(() => applyMetadataPolicyToMetadata({ leafMetadata: mergedLeafMetadata, policyMetadata: mergedPolicy, }) ) if (!policyApplyResult.success) { + // TODO: In this function we can make conclusions they all now continue but at some point they should be reflected back to the caller if (policyApplyResult.error instanceof PolicyValidationError) { // When the policy validation fails on the leaf metadata, we can declare the chain invalid // TODO: Think about how we want to share this conclusion with the caller continue } - throw new OpenIdFederationError( - PolicyErrorStage.PolicyMerge, - 'Unexpected error while applying policy', - policyApplyResult.error - ) + // An unexpected error occurred while applying the policy + // TODO: Think about how we want to share this conclusion with the caller + continue } const { resolvedLeafMetadata } = policyApplyResult.value const trustAnchorEntityConfiguration = entityConfigurationChain[entityConfigurationChain.length - 1] if (!trustAnchorEntityConfiguration) + // Should never fail throw new OpenIdFederationError(PolicyErrorStage.Validation, 'No trust anchor entity configuration found') trustChains.push({ diff --git a/packages/core/src/utils/data.ts b/packages/core/src/utils/data.ts index a12deb6..a935f0b 100644 --- a/packages/core/src/utils/data.ts +++ b/packages/core/src/utils/data.ts @@ -9,10 +9,15 @@ export function objectToEntries< return Object.entries(obj) as [keyof TObject, TObject[keyof TObject]][] } -/** - * Creates a deep clone of an object - */ -export const cloneDeep = (obj: T): T => JSON.parse(JSON.stringify(obj)) - export const isNullOrUndefined = (value: unknown): value is null | undefined => value === null || value === undefined +export const immutable = (obj: T): T => + new Proxy(obj, { + get(target: T, prop: string | symbol) { + const value = target[prop as keyof T] + return typeof value === 'object' && value !== null ? immutable(value) : value + }, + set() { + throw new Error('This object is immutable.') + }, + }) diff --git a/packages/core/src/utils/tryCatch.ts b/packages/core/src/utils/tryCatch.ts index 32186b4..10ba167 100644 --- a/packages/core/src/utils/tryCatch.ts +++ b/packages/core/src/utils/tryCatch.ts @@ -1,9 +1,21 @@ -export async function tryCatch( - fn: () => Promise -): Promise<{ success: true; value: T } | { success: false; error: unknown }> { +type Result = { success: true; value: T } | { success: false; error: unknown } + +export function tryCatch( + fn: () => TReturn +): TReturn extends Promise ? Promise> : Result { try { - return { success: true, value: await fn() } as const + const result = fn() + + if (result instanceof Promise) { + return result + .then((value) => ({ success: true, value }) as const) + .catch((error: unknown) => ({ success: false, error }) as const) as TReturn extends Promise + ? Promise> + : never + } + + return { success: true, value: result } as TReturn extends Promise ? Promise> : Result } catch (error) { - return { success: false, error } as const + return { success: false, error } as TReturn extends Promise ? Promise> : Result } } From 60dadcc1fc0d9b5693afab2bbeebacb50d31fdba Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Wed, 29 Jan 2025 12:56:31 +0100 Subject: [PATCH 5/9] feat: Added more test cases Signed-off-by: Tom Lanser --- .../core/__tests__/resolveTrustChains.test.ts | 276 ++++++++++++++++++ .../utils/setupConfigurationChain.ts | 4 + packages/core/src/utils/data.ts | 11 +- 3 files changed, 290 insertions(+), 1 deletion(-) diff --git a/packages/core/__tests__/resolveTrustChains.test.ts b/packages/core/__tests__/resolveTrustChains.test.ts index ae16f0b..6c88be1 100644 --- a/packages/core/__tests__/resolveTrustChains.test.ts +++ b/packages/core/__tests__/resolveTrustChains.test.ts @@ -716,4 +716,280 @@ describe('fetch trust chains', () => { }, }) }) + + it('should handle multiple value operators in the same chain', async () => { + const leafEntityId = 'https://leaff.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateEntityId], + claims: { + metadata: { + openid_relying_party: { + policy_uri: 'https://leaf.example.org/policy.html', + tos_uri: 'https://leaf.example.org/tos.html', + logo_uri: 'https://leaf.example.org/logo.png', + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + policy_uri: { + value: 'https://intermediate.example.org/policy.html', + }, + tos_uri: { + value: null, + }, + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + logo_uri: { + value: 'https://trust.example.org/logo.png', + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 3) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + openid_relying_party: { + policy_uri: 'https://intermediate.example.org/policy.html', + logo_uri: 'https://trust.example.org/logo.png', + client_registration_types: ['automatic'], + }, + }) + }) + + it('should handle complex array operations in metadata policy', async () => { + const leafEntityId = 'https://leaff.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [trustAnchorEntityId], + claims: { + metadata: { + openid_relying_party: { + grant_types: ['authorization_code', 'refresh_token', 'client_credentials'], + response_types: ['code', 'code id_token'], + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + grant_types: { + subset_of: ['authorization_code', 'refresh_token'], + }, + response_types: { + subset_of: ['code'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 2) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + openid_relying_party: { + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_registration_types: ['automatic'], + }, + }) + }) + + it('should handle order of value and essential properties in metadata policy', async () => { + const leafEntityId = 'https://leaff.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [trustAnchorEntityId], + claims: { + metadata: { + openid_relying_party: { + client_registration_types: ['automatic'], + // Missing required token_endpoint_auth_method + }, + }, + }, + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + token_endpoint_auth_method: { + value: 'private_key_jwt', + essential: true, + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + // Should succeed because the value is set by the trust anchor + assert.strictEqual(trustChains.length, 1) + }) + + it('should handle combining multiple array operators across chain', async () => { + const leafEntityId = 'https://leaff.example.org' + const intermediateEntityId = 'https://intermediate.example.org' + const trustAnchorEntityId = 'https://trust.example.org' + + await setupConfigurationChain( + [ + { + entityId: leafEntityId, + authorityHints: [intermediateEntityId], + claims: { + metadata: { + openid_relying_party: { + grant_types: ['authorization_code'], + client_registration_types: ['automatic'], + }, + }, + }, + }, + { + entityId: intermediateEntityId, + authorityHints: [trustAnchorEntityId], + subordinates: [ + { + entityId: leafEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + grant_types: { + add: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'], + }, + }, + }, + }, + }, + ], + }, + { + entityId: trustAnchorEntityId, + subordinates: [ + { + entityId: intermediateEntityId, + claims: { + metadata_policy: { + openid_relying_party: { + grant_types: { + subset_of: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'], + }, + }, + }, + }, + }, + ], + }, + ], + { signJwtCallback, mockEndpoints: true } + ) + + const trustChains = await resolveTrustChains({ + entityId: leafEntityId, + trustAnchorEntityIds: [trustAnchorEntityId], + verifyJwtCallback, + }) + + assert.strictEqual(trustChains.length, 1) + assert.strictEqual(trustChains[0]?.chain.length, 3) + + assert.deepStrictEqual(trustChains[0]?.resolvedLeafMetadata, { + federation_entity: { + federation_fetch_endpoint: 'https://leaff.example.org/fetch', + }, + openid_relying_party: { + client_registration_types: ['automatic'], + grant_types: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'], + }, + }) + }) }) diff --git a/packages/core/__tests__/utils/setupConfigurationChain.ts b/packages/core/__tests__/utils/setupConfigurationChain.ts index 6780865..8c20e19 100644 --- a/packages/core/__tests__/utils/setupConfigurationChain.ts +++ b/packages/core/__tests__/utils/setupConfigurationChain.ts @@ -37,6 +37,10 @@ export const setupConfigurationChain = async ( mockEndpoints?: MockEndpoints } ) => { + if (mockEndpoints) { + nock.cleanAll() + } + const chainData: Array<{ claims: EntityConfigurationClaimsOptions jwt: string diff --git a/packages/core/src/utils/data.ts b/packages/core/src/utils/data.ts index a935f0b..1e3cf66 100644 --- a/packages/core/src/utils/data.ts +++ b/packages/core/src/utils/data.ts @@ -15,9 +15,18 @@ export const immutable = (obj: T): T => new Proxy(obj, { get(target: T, prop: string | symbol) { const value = target[prop as keyof T] - return typeof value === 'object' && value !== null ? immutable(value) : value + return typeof value === 'object' && value !== null + ? Array.isArray(value) + ? Object.freeze( + [...value].map((item) => (typeof item === 'object' && item !== null ? immutable(item) : item)) + ) + : immutable(value) + : value }, set() { throw new Error('This object is immutable.') }, + deleteProperty() { + throw new Error('This object is immutable.') + }, }) From aeeae0c495d42ad2815f019bd6aa9665937c1640 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Wed, 29 Jan 2025 12:59:49 +0100 Subject: [PATCH 6/9] feat: Added a new valid property Signed-off-by: Tom Lanser --- packages/core/src/resolveTrustChains/resolveTrustChains.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/src/resolveTrustChains/resolveTrustChains.ts b/packages/core/src/resolveTrustChains/resolveTrustChains.ts index 6a562cc..b73666e 100644 --- a/packages/core/src/resolveTrustChains/resolveTrustChains.ts +++ b/packages/core/src/resolveTrustChains/resolveTrustChains.ts @@ -19,6 +19,11 @@ type Options = { } export type TrustChain = { + /** + * Later this will give us the ability to provide the failed chains to the caller + */ + valid: true + chain: EntityStatementChain /** * The raw leaf entity configuration before the policy is applied. @@ -74,6 +79,7 @@ export const resolveTrustChains = async (options: Options): Promise Date: Wed, 29 Jan 2025 13:01:17 +0100 Subject: [PATCH 7/9] fix: validation Signed-off-by: Tom Lanser --- .../core/__tests__/fetchEntityConfigurationChains.test.ts | 2 +- packages/core/__tests__/fetchEntityStatementChain.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts index 4195b00..f27ac6e 100644 --- a/packages/core/__tests__/fetchEntityConfigurationChains.test.ts +++ b/packages/core/__tests__/fetchEntityConfigurationChains.test.ts @@ -152,7 +152,7 @@ describe('fetch entity configuration chains', () => { const trustAnchorOneEntityId = 'https://trust.one.example.org' const trustAnchorTwoEntityId = 'https://trust.two.example.org' - const { chainData: configurations, nockScopes } = await setupConfigurationChain( + const { nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, diff --git a/packages/core/__tests__/fetchEntityStatementChain.test.ts b/packages/core/__tests__/fetchEntityStatementChain.test.ts index 35a3230..f51a3a7 100644 --- a/packages/core/__tests__/fetchEntityStatementChain.test.ts +++ b/packages/core/__tests__/fetchEntityStatementChain.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type EntityConfigurationClaimsOptions, fetchEntityConfigurationChains } from '../src/entityConfiguration' +import { fetchEntityConfigurationChains } from '../src/entityConfiguration' import { fetchEntityStatementChain } from '../src/entityStatement' import type { SignCallback, VerifyCallback } from '../src/utils' import { setupConfigurationChain } from './utils/setupConfigurationChain' @@ -56,8 +56,6 @@ describe('fetch entity statement chain', () => { const intermediateEntityId = 'https://intermediate.example.org' const trustAnchorEntityId = 'https://trust.example.org' - const claims: Array = [] - const { chainData: configurations, nockScopes } = await setupConfigurationChain( [ { entityId: leafEntityId, authorityHints: [intermediateEntityId] }, From a1caf3a50a0bd661790b6c201281eacded688a8e Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Wed, 29 Jan 2025 13:04:53 +0100 Subject: [PATCH 8/9] chore: Process the object input for the policy Signed-off-by: Tom Lanser --- packages/core/src/metadata/operator/standard/add.ts | 3 ++- packages/core/src/metadata/operator/standard/oneOf.ts | 7 ++++++- packages/core/src/metadata/operator/standard/subsetOf.ts | 3 ++- packages/core/src/metadata/operator/standard/supersetOf.ts | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/metadata/operator/standard/add.ts b/packages/core/src/metadata/operator/standard/add.ts index 92ba732..2281086 100644 --- a/packages/core/src/metadata/operator/standard/add.ts +++ b/packages/core/src/metadata/operator/standard/add.ts @@ -7,7 +7,8 @@ export const addOperator = createPolicyOperatorSchema({ key: 'add', parameterJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], operatorJsonValues: [ diff --git a/packages/core/src/metadata/operator/standard/oneOf.ts b/packages/core/src/metadata/operator/standard/oneOf.ts index d906dd6..208ecb4 100644 --- a/packages/core/src/metadata/operator/standard/oneOf.ts +++ b/packages/core/src/metadata/operator/standard/oneOf.ts @@ -5,7 +5,12 @@ import { createPolicyOperatorSchema } from '../utils' export const oneOfOperator = createPolicyOperatorSchema({ key: 'one_of', - parameterJsonValues: [z.string(), z.record(z.string().or(z.number()), z.unknown()), z.number()], + parameterJsonValues: [ + z.string(), + // TODO: See how we want to we handle the comparison of objects + // z.record(z.string().or(z.number()), z.unknown()), + z.number(), + ], operatorJsonValues: [ z.array(z.string()), // TODO: See how we want to we handle the comparison of objects diff --git a/packages/core/src/metadata/operator/standard/subsetOf.ts b/packages/core/src/metadata/operator/standard/subsetOf.ts index 4bead30..b99fa93 100644 --- a/packages/core/src/metadata/operator/standard/subsetOf.ts +++ b/packages/core/src/metadata/operator/standard/subsetOf.ts @@ -7,7 +7,8 @@ export const subsetOfOperator = createPolicyOperatorSchema({ key: 'subset_of', parameterJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], operatorJsonValues: [ diff --git a/packages/core/src/metadata/operator/standard/supersetOf.ts b/packages/core/src/metadata/operator/standard/supersetOf.ts index a8d2b26..a4bf4b7 100644 --- a/packages/core/src/metadata/operator/standard/supersetOf.ts +++ b/packages/core/src/metadata/operator/standard/supersetOf.ts @@ -7,7 +7,8 @@ export const supersetOfOperator = createPolicyOperatorSchema({ key: 'superset_of', parameterJsonValues: [ z.array(z.string()), - z.array(z.record(z.string().or(z.number()), z.unknown())), + // TODO: See how we want to we handle the comparison of objects + // z.array(z.record(z.string().or(z.number()), z.unknown())), z.array(z.number()), ], operatorJsonValues: [ From 2e2d6094fd425eedefce07c5f1dab89bb7a3f627 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 30 Jan 2025 13:47:43 +0800 Subject: [PATCH 9/9] docs: add changeset Signed-off-by: Timo Glastra --- .changeset/smart-falcons-smoke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smart-falcons-smoke.md diff --git a/.changeset/smart-falcons-smoke.md b/.changeset/smart-falcons-smoke.md new file mode 100644 index 0000000..e13db15 --- /dev/null +++ b/.changeset/smart-falcons-smoke.md @@ -0,0 +1,5 @@ +--- +"@openid-federation/core": minor +--- + +feat: add support for policies