-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: fetch an entity configuration chain via authority_hints
Signed-off-by: Berend Sliedrecht <[email protected]>
- Loading branch information
1 parent
112aa04
commit ba77a0b
Showing
6 changed files
with
389 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
273 changes: 273 additions & 0 deletions
273
packages/core/__tests__/fetchEntityConfigurationChains.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,273 @@ | ||
import assert from 'node:assert/strict' | ||
import { describe, it } from 'node:test' | ||
import nock from 'nock' | ||
import { fetchEntityConfigurationChains } from '../src/entityConfiguration' | ||
import type { SignCallback, VerifyCallback } from '../src/utils' | ||
import { setupConfigurationChain } from './utils/setupConfigurationChain' | ||
|
||
describe('fetch entity configuration chains', () => { | ||
const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) | ||
const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) | ||
|
||
it('should fetch a basic entity configuration chain of 2 entities', async () => { | ||
const leafEntityId = 'https://leaf.example.org' | ||
const trustAnchorEntityId = 'https://trust.example.org' | ||
|
||
const scopes = [] | ||
const claims = [] | ||
|
||
const configurations = await setupConfigurationChain( | ||
[{ entityId: leafEntityId, authorityHints: [trustAnchorEntityId] }, { entityId: trustAnchorEntityId }], | ||
signJwtCallback | ||
) | ||
|
||
for (const { entityId, jwt, claims: configurationClaims } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
claims.push(configurationClaims) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId, | ||
trustAnchorEntityIds: [trustAnchorEntityId], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 1) | ||
assert.strictEqual(trustChains[0].length, 2) | ||
|
||
assert.deepStrictEqual(trustChains[0][0], claims[0]) | ||
assert.deepStrictEqual(trustChains[0][1], claims[1]) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
|
||
it('should fetch a basic entity configuration chain of 3 entities', async () => { | ||
const leafEntityId = 'https://leaf.example.org' | ||
const intermediateEntityId = 'https://intermediate.example.org' | ||
const trustAnchorEntityId = 'https://trust.example.org' | ||
|
||
const scopes = [] | ||
const claims = [] | ||
|
||
const configurations = await setupConfigurationChain( | ||
[ | ||
{ entityId: leafEntityId, authorityHints: [intermediateEntityId] }, | ||
{ | ||
entityId: intermediateEntityId, | ||
authorityHints: [trustAnchorEntityId], | ||
}, | ||
{ entityId: trustAnchorEntityId }, | ||
], | ||
signJwtCallback | ||
) | ||
|
||
for (const { entityId, jwt, claims: configurationClaims } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
claims.push(configurationClaims) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId, | ||
trustAnchorEntityIds: [trustAnchorEntityId], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 1) | ||
assert.strictEqual(trustChains[0].length, 3) | ||
|
||
assert.deepStrictEqual(trustChains[0][0], claims[0]) | ||
assert.deepStrictEqual(trustChains[0][1], claims[1]) | ||
assert.deepStrictEqual(trustChains[0][2], claims[2]) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
|
||
it('should fetch two entity configuration chains of 2 entities each', async () => { | ||
const leafEntityId = 'https://leaf.example.org' | ||
const intermediateOneEntityId = 'https://intermediate.one.example.org' | ||
const intermediateTwoEntityId = 'https://intermediate.two.example.org' | ||
const trustAnchorOneEntityId = 'https://trust.one.example.org' | ||
const trustAnchorTwoEntityId = 'https://trust.two.example.org' | ||
|
||
const scopes = [] | ||
const claims = [] | ||
|
||
const configurations = await setupConfigurationChain( | ||
[ | ||
{ | ||
entityId: leafEntityId, | ||
authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], | ||
}, | ||
{ | ||
entityId: intermediateOneEntityId, | ||
authorityHints: [trustAnchorOneEntityId], | ||
}, | ||
{ | ||
entityId: intermediateTwoEntityId, | ||
authorityHints: [trustAnchorTwoEntityId], | ||
}, | ||
{ entityId: trustAnchorOneEntityId }, | ||
{ entityId: trustAnchorTwoEntityId }, | ||
], | ||
signJwtCallback | ||
) | ||
|
||
for (const { entityId, jwt, claims: configurationClaims } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
claims.push(configurationClaims) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId, | ||
trustAnchorEntityIds: [trustAnchorOneEntityId, trustAnchorTwoEntityId], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 2) | ||
assert.strictEqual(trustChains[0].length, 3) | ||
assert.strictEqual(trustChains[1].length, 3) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
|
||
it('should fetch one entity configuration chains when one trust anchor is provided', async () => { | ||
const leafEntityId = 'https://leaf.example.org' | ||
const intermediateOneEntityId = 'https://intermediate.one.example.org' | ||
const intermediateTwoEntityId = 'https://intermediate.two.example.org' | ||
const trustAnchorOneEntityId = 'https://trust.one.example.org' | ||
const trustAnchorTwoEntityId = 'https://trust.two.example.org' | ||
|
||
const scopes = [] | ||
const claims = [] | ||
|
||
const configurations = await setupConfigurationChain( | ||
[ | ||
{ | ||
entityId: leafEntityId, | ||
authorityHints: [intermediateOneEntityId, intermediateTwoEntityId], | ||
}, | ||
{ | ||
entityId: intermediateOneEntityId, | ||
authorityHints: [trustAnchorOneEntityId], | ||
}, | ||
{ | ||
entityId: intermediateTwoEntityId, | ||
authorityHints: [trustAnchorTwoEntityId], | ||
}, | ||
{ entityId: trustAnchorOneEntityId }, | ||
{ entityId: trustAnchorTwoEntityId }, | ||
], | ||
signJwtCallback | ||
) | ||
|
||
for (const { entityId, jwt, claims: configurationClaims } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
claims.push(configurationClaims) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId, | ||
trustAnchorEntityIds: [trustAnchorOneEntityId], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 1) | ||
assert.strictEqual(trustChains[0].length, 3) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
|
||
it('should not fetch an entity configuration chain when no authority_hints are found', async () => { | ||
const scopes = [] | ||
|
||
const configurations = await setupConfigurationChain([{ entityId: 'https://leaf.example.org' }], signJwtCallback) | ||
|
||
for (const { entityId, jwt } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId: configurations[0].entityId, | ||
trustAnchorEntityIds: ['https://trust.example.org'], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 0) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
|
||
it('should not fetch an entity configuration chain when a loop is found', async () => { | ||
const scopes = [] | ||
|
||
const leafEntityId = 'https://leaf.example.org' | ||
const intermediateOneEntityId = 'https://intermediate.one.example.org' | ||
const intermediateTwoEntityId = 'https://intermediate.two.example.org' | ||
const trustAnchorEntityId = 'https://trust.example.org' | ||
|
||
const configurations = await setupConfigurationChain( | ||
[ | ||
{ entityId: leafEntityId, authorityHints: [intermediateOneEntityId] }, | ||
{ | ||
entityId: intermediateOneEntityId, | ||
authorityHints: [intermediateTwoEntityId], | ||
}, | ||
{ | ||
entityId: intermediateTwoEntityId, | ||
authorityHints: [intermediateOneEntityId], | ||
}, | ||
], | ||
signJwtCallback | ||
) | ||
|
||
for (const { entityId, jwt } of configurations) { | ||
const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, jwt, { | ||
'content-type': 'application/entity-statement+jwt', | ||
}) | ||
|
||
scopes.push(scope) | ||
} | ||
|
||
const trustChains = await fetchEntityConfigurationChains({ | ||
verifyJwtCallback, | ||
leafEntityId: configurations[0].entityId, | ||
trustAnchorEntityIds: [trustAnchorEntityId], | ||
}) | ||
|
||
assert.strictEqual(trustChains.length, 0) | ||
|
||
for (const scope of scopes) { | ||
scope.done() | ||
} | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { | ||
type EntityConfigurationClaimsOptions, | ||
type EntityConfigurationHeaderOptions, | ||
createEntityConfiguration, | ||
} from '../../src/entityConfiguration' | ||
import type { JsonWebKeySetOptions } from '../../src/jsonWeb' | ||
import type { SignCallback } from '../../src/utils' | ||
|
||
type SetupConfigurationChainOptions = { | ||
entityId: string | ||
authorityHints?: Array<string> | ||
jwks?: JsonWebKeySetOptions | ||
kid?: string | ||
} | ||
|
||
export const setupConfigurationChain = async ( | ||
options: Array<SetupConfigurationChainOptions>, | ||
signJwtCallback: SignCallback | ||
) => { | ||
const chainData: Array<{ | ||
claims: EntityConfigurationClaimsOptions | ||
jwt: string | ||
entityId: string | ||
}> = [] | ||
for (const { entityId, authorityHints, jwks, kid } of options) { | ||
const claims: EntityConfigurationClaimsOptions = { | ||
iss: entityId, | ||
sub: entityId, | ||
exp: new Date(), | ||
iat: new Date(), | ||
jwks: jwks ?? { keys: [{ kid: 'a', kty: 'EC' }] }, | ||
authority_hints: authorityHints, | ||
} | ||
|
||
// fix so `undefined` is not in the expected claims | ||
if (!authorityHints) delete claims.authority_hints | ||
|
||
const header: EntityConfigurationHeaderOptions = { | ||
kid: kid ?? 'a', | ||
typ: 'entity-statement+jwt', | ||
} | ||
|
||
const jwt = await createEntityConfiguration({ | ||
claims, | ||
header, | ||
signJwtCallback, | ||
}) | ||
|
||
chainData.push({ claims, jwt, entityId }) | ||
} | ||
|
||
return chainData | ||
} |
56 changes: 56 additions & 0 deletions
56
packages/core/src/entityConfiguration/fetchEntityConfigurationChains.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import type { VerifyCallback } from '../utils' | ||
import type { EntityConfigurationClaims } from './entityConfigurationClaims' | ||
import { fetchEntityConfiguration } from './fetchEntityConfiguration' | ||
|
||
type FetchEntityConfigurationChainOptions = { | ||
leafEntityId: string | ||
trustAnchorEntityIds: Array<string> | ||
verifyJwtCallback: VerifyCallback | ||
} | ||
|
||
export const fetchEntityConfigurationChains = async ( | ||
options: FetchEntityConfigurationChainOptions | ||
): Promise<Array<Array<EntityConfigurationClaims>>> => { | ||
const __fetchEntityConfigurationChains = async ( | ||
currentEntityId: string, | ||
visited: Array<EntityConfigurationClaims> = [], | ||
path: Array<EntityConfigurationClaims> = [] | ||
): Promise<Array<Array<EntityConfigurationClaims>>> => { | ||
const configuration = await fetchEntityConfiguration({ | ||
verifyJwtCallback: options.verifyJwtCallback, | ||
entityId: currentEntityId, | ||
}) | ||
|
||
const localPath = [...path, configuration] | ||
const localVisited = [...visited, configuration] | ||
|
||
const allPaths: Array<Array<EntityConfigurationClaims>> = [] | ||
|
||
// Found a trust anchor | ||
if (options.trustAnchorEntityIds.includes(configuration.sub)) { | ||
allPaths.push(localPath) | ||
|
||
// TODO: we should stop the flow here | ||
} | ||
|
||
if (configuration.authority_hints) { | ||
const promises: Array<Promise<Array<Array<EntityConfigurationClaims>>>> = [] | ||
|
||
for (const superior of configuration.authority_hints) { | ||
if (localVisited.map((v) => v.sub).includes(superior)) continue | ||
|
||
promises.push(__fetchEntityConfigurationChains(superior, localVisited, localPath)) | ||
} | ||
const results = await Promise.allSettled(promises) | ||
for (const res of results) { | ||
if (res.status === 'fulfilled') { | ||
allPaths.push(...res.value) | ||
} | ||
} | ||
} | ||
|
||
return allPaths | ||
} | ||
|
||
return __fetchEntityConfigurationChains(options.leafEntityId) | ||
} |
Oops, something went wrong.