Skip to content

Commit

Permalink
feat: fetch an entity configuration chain via authority_hints
Browse files Browse the repository at this point in the history
Signed-off-by: Berend Sliedrecht <[email protected]>
  • Loading branch information
berendsliedrecht committed Jul 12, 2024
1 parent 112aa04 commit ba77a0b
Show file tree
Hide file tree
Showing 6 changed files with 389 additions and 1 deletion.
3 changes: 3 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noDelete": { "level": "off" }
},
"style": {
"useNodeAssertStrict": { "level": "error", "fix": "unsafe" },
"useNodejsImportProtocol": { "level": "off" }
Expand Down
273 changes: 273 additions & 0 deletions packages/core/__tests__/fetchEntityConfigurationChains.test.ts
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()
}
})
})
53 changes: 53 additions & 0 deletions packages/core/__tests__/utils/setupConfigurationChain.ts
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
}
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)
}
Loading

0 comments on commit ba77a0b

Please sign in to comment.