diff --git a/biome.json b/biome.json index 2267feb..1ae4e93 100644 --- a/biome.json +++ b/biome.json @@ -35,7 +35,11 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "useNodeAssertStrict": { "level": "error", "fix": "unsafe" }, + "useNodejsImportProtocol": { "level": "off" } + } } } } diff --git a/packages/core/__tests__/createEntityConfiguration.test.ts b/packages/core/__tests__/createEntityConfiguration.test.ts index bb831c1..ae101a8 100644 --- a/packages/core/__tests__/createEntityConfiguration.test.ts +++ b/packages/core/__tests__/createEntityConfiguration.test.ts @@ -1,4 +1,4 @@ -import assert from 'node:assert' +import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { createEntityConfiguration } from '../src/entityConfiguration' import type { SignCallback } from '../src/utils' @@ -27,7 +27,7 @@ describe('create entity configuration', () => { it('should create a more complex entity configuration', async () => { const entityConfiguration = await createEntityConfiguration({ - signJwtCallback: signJwtCallback, + signJwtCallback, claims: { exp: 1, iat: 1, @@ -48,7 +48,7 @@ describe('create entity configuration', () => { it('should not create a entity configuration when iss and sub are not equal', async () => { await assert.rejects( createEntityConfiguration({ - signJwtCallback: signJwtCallback, + signJwtCallback, claims: { exp: 1, iat: 1, @@ -57,17 +57,14 @@ describe('create entity configuration', () => { jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, }, header: { kid: 'a', typ: 'entity-statement+jwt' }, - }), - { - name: 'ZodError', - } + }) ) }) it('should not create a entity configuration when kid is not found in jwks.keys', async () => { await assert.rejects( createEntityConfiguration({ - signJwtCallback: signJwtCallback, + signJwtCallback, claims: { exp: 1, iat: 1, @@ -76,18 +73,14 @@ describe('create entity configuration', () => { jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, }, header: { kid: 'invalid_id', typ: 'entity-statement+jwt' }, - }), - { - name: 'Error', - message: "key with id: 'invalid_id' could not be found in the claims", - } + }) ) }) it("should not create a entity configuration when typ is not 'entity-statement+jwt'", async () => { await assert.rejects( createEntityConfiguration({ - signJwtCallback: signJwtCallback, + signJwtCallback, claims: { exp: 1, iat: 1, @@ -97,17 +90,14 @@ describe('create entity configuration', () => { }, // @ts-ignore header: { kid: 'a', typ: 'invalid_typ' }, - }), - { - name: 'ZodError', - } + }) ) }) it('should not create a entity configuration when jwks.keys include keys with the same kid', async () => { await assert.rejects( createEntityConfiguration({ - signJwtCallback: signJwtCallback, + signJwtCallback, claims: { exp: 1, iat: 1, @@ -121,10 +111,7 @@ describe('create entity configuration', () => { }, }, header: { kid: 'a', typ: 'entity-statement+jwt' }, - }), - { - name: 'ZodError', - } + }) ) }) }) diff --git a/packages/core/__tests__/createEntityStatement.test.ts b/packages/core/__tests__/createEntityStatement.test.ts index d357ddb..e64a411 100644 --- a/packages/core/__tests__/createEntityStatement.test.ts +++ b/packages/core/__tests__/createEntityStatement.test.ts @@ -1,4 +1,4 @@ -import assert from 'node:assert' +import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { createEntityStatement } from '../src/entityStatement/createEntityStatement' import type { SignCallback } from '../src/utils' @@ -51,6 +51,11 @@ describe('create entity statement', () => { it('should not create a basic entity statement with an invalid typ', async () => { await assert.rejects( createEntityStatement({ + header: { + kid: 'a', + // @ts-ignore + typ: 'invalid-typ', + }, jwk: { kty: 'EC', kid: 'a' }, claims: { exp: 1, @@ -59,11 +64,6 @@ describe('create entity statement', () => { sub: 'https://one.example.org', jwks: { keys: [{ kty: 'EC', kid: 'b' }] }, }, - header: { - kid: 'a', - // @ts-ignore - typ: 'invalid-typ', - }, signJwtCallback, }) ) diff --git a/packages/core/__tests__/e2e.test.ts b/packages/core/__tests__/e2e.test.ts new file mode 100644 index 0000000..38ba03f --- /dev/null +++ b/packages/core/__tests__/e2e.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { subtle } from 'node:crypto' +import { describe, it } from 'node:test' + +import nock from 'nock' + +import { createEntityConfiguration, fetchEntityConfiguration } from '../src/entityConfiguration' +import { createEntityStatement, fetchEntityStatement } from '../src/entityStatement' +import type { SignCallback, VerifyCallback } from '../src/utils' + +describe('End To End', async () => { + const key = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) + const exportedKey = await subtle.exportKey('jwk', key.publicKey) + const publicKeyJwk = { + kid: 'some-id', + kty: 'EC', + key_ops: exportedKey.key_ops, + x: exportedKey.x, + y: exportedKey.y, + } + + const signJwtCallback: SignCallback = async ({ toBeSigned }) => + new Uint8Array(await subtle.sign({ hash: 'SHA-256', name: 'ECDSA' }, key.privateKey, toBeSigned)) + + const verifyJwtCallback: VerifyCallback = async ({ signature, data }) => + subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key.publicKey, signature, data) + + it('should fetch an entity configuration', async () => { + const iss = 'https://example.org' + + const claims = { + iss, + sub: iss, + exp: new Date(), + iat: new Date(), + jwks: { + keys: [publicKeyJwk], + }, + } + + const entityConfigurationJwt = await createEntityConfiguration({ + signJwtCallback, + claims, + header: { + kid: 'some-id', + typ: 'entity-statement+jwt', + }, + }) + + const scope = nock(iss).get('/.well-known/openid-federation').reply(200, entityConfigurationJwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + const fetchedEntityConfigurationClaims = await fetchEntityConfiguration({ + entityId: iss, + verifyJwtCallback, + }) + + assert.deepStrictEqual(fetchedEntityConfigurationClaims, claims) + + scope.done() + }) + + it('should fetch an entity statement', async () => { + const iss = 'https://example.org' + const sub = 'https://sub.example.org' + + const entityConfigurationJwt = await createEntityConfiguration({ + signJwtCallback, + claims: { + iss, + sub: iss, + exp: new Date(), + iat: new Date(), + jwks: { + keys: [publicKeyJwk], + }, + source_endpoint: `${iss}/fetch`, + }, + header: { + kid: 'some-id', + typ: 'entity-statement+jwt', + }, + }) + + const entityStamentClaims = { + iss, + sub: iss, + exp: new Date(), + iat: new Date(), + jwks: { + keys: [], + }, + authority_hints: [iss], + metadata: { + federation_entity: { + organization_name: 'my org!', + }, + }, + } + + const entityStatementJwt = await createEntityStatement({ + signJwtCallback, + claims: entityStamentClaims, + header: { + kid: 'some-id', + typ: 'entity-statement+jwt', + }, + jwk: publicKeyJwk, + }) + + const scope = nock(iss) + .get('/.well-known/openid-federation') + .reply(200, entityConfigurationJwt, { + 'content-type': 'application/entity-statement+jwt', + }) + .get('/fetch') + .query({ iss, sub }) + .reply(200, entityStatementJwt, { + 'content-type': 'application/entity-statement+jwt', + }) + + const fetchedEntityConfigurationClaims = await fetchEntityConfiguration({ + entityId: iss, + verifyJwtCallback, + }) + + const fetchedEntityStatementClaims = await fetchEntityStatement({ + iss, + sub, + issEntityConfiguration: fetchedEntityConfigurationClaims, + verifyJwtCallback, + }) + + assert.deepStrictEqual(fetchedEntityStatementClaims, entityStamentClaims) + + scope.done() + }) +}) diff --git a/packages/core/__tests__/fetchEntityConfiguration.test.ts b/packages/core/__tests__/fetchEntityConfiguration.test.ts index 51d63ef..f2f5d20 100644 --- a/packages/core/__tests__/fetchEntityConfiguration.test.ts +++ b/packages/core/__tests__/fetchEntityConfiguration.test.ts @@ -8,7 +8,7 @@ import nock from 'nock' describe('fetch entity configuration', () => { const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) - const signCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + const signJwtCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) it('should fetch a simple entity configuration', async () => { const entityId = 'https://example.org' @@ -24,7 +24,7 @@ describe('fetch entity configuration', () => { const entityConfiguration = await createEntityConfiguration({ header: { kid: 'a', typ: 'entity-statement+jwt' }, claims, - signJwtCallback: signCallback, + signJwtCallback, }) const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, entityConfiguration, { @@ -55,14 +55,14 @@ describe('fetch entity configuration', () => { const entityConfiguration = await createEntityConfiguration({ header: { kid: 'a', typ: 'entity-statement+jwt' }, claims, - signJwtCallback: signCallback, + signJwtCallback, }) const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, entityConfiguration, { 'content-type': 'invalid-type', }) - await assert.rejects(fetchEntityConfiguration({ entityId, verifyJwtCallback }), { name: 'Error' }) + await assert.rejects(fetchEntityConfiguration({ entityId, verifyJwtCallback })) scope.done() }) @@ -70,6 +70,6 @@ describe('fetch entity configuration', () => { it('should not fetch an entity configuration when there is no entity configuration', async () => { const entityId = 'https://examplethree.org' - await assert.rejects(fetchEntityConfiguration({ entityId, verifyJwtCallback }), { name: 'TypeError' }) + await assert.rejects(fetchEntityConfiguration({ entityId, verifyJwtCallback })) }) }) diff --git a/packages/core/__tests__/schemas.test.ts b/packages/core/__tests__/validate.test.ts similarity index 54% rename from packages/core/__tests__/schemas.test.ts rename to packages/core/__tests__/validate.test.ts index 543b904..0a73d4a 100644 --- a/packages/core/__tests__/schemas.test.ts +++ b/packages/core/__tests__/validate.test.ts @@ -11,6 +11,7 @@ import { trustMarkOwnerSchema } from '../src/trustMark' import { federationEntityMetadata } from '../src/metadata' import { metadataPolicySchema } from '../src/metadata/metadataPolicy' +import { validate } from '../src/utils/validate' import { constraintsFigure17 } from './fixtures/constraintsFigure17' import { entityConfigurationFigure8 } from './fixtures/entityConfigurationFigure8' import { entityConfigurationFigure9 } from './fixtures/entityConfigurationFigure9' @@ -42,111 +43,226 @@ import { trustMarkClaimsFigure20 } from './fixtures/trustmarkClaimsFigure20' describe('zod validation schemas', () => { describe('validate valid test vectors', () => { it('should validate figure 2 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure2)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure2, + }) + ) }) it('should validate figure 3 -- trust mark owners', () => { - assert.doesNotThrow(() => trustMarkOwnerSchema.parse(trustMarkOwnersFigure3)) + assert.doesNotThrow(() => validate({ schema: trustMarkOwnerSchema, data: trustMarkOwnersFigure3 })) }) it('should validate figure 4 -- trust mark issuers', () => { - assert.doesNotThrow(() => trustMarkIssuerSchema.parse(trustMarkIssuersFigure4)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkIssuerSchema, + data: trustMarkIssuersFigure4, + }) + ) }) it('should validate figure 7 -- federation entity metadata', () => { - assert.doesNotThrow(() => federationEntityMetadata.schema.parse(federationEntityMetadataFigure7)) + assert.doesNotThrow(() => + validate({ + schema: federationEntityMetadata.schema, + data: federationEntityMetadataFigure7, + }) + ) }) it('should validate figure 8 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure8)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure8, + }) + ) }) it('should validate figure 9 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure9)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure9, + }) + ) }) it('should validate figure 12 -- metadata policy', () => { - assert.doesNotThrow(() => metadataPolicySchema.parse(metadataPolicyFigure12)) + assert.doesNotThrow(() => validate({ schema: metadataPolicySchema, data: metadataPolicyFigure12 })) }) it('should validate figure 17 -- constraints', () => { - assert.doesNotThrow(() => constraintSchema.parse(constraintsFigure17)) + assert.doesNotThrow(() => validate({ schema: constraintSchema, data: constraintsFigure17 })) }) it('should validate figure 18 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure18)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure18, + }) + ) }) it('should validate figure 19 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure19)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure19, + }) + ) }) it('should validate figure 20 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure20)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure20, + }) + ) }) it('should validate figure 21 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure21)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure21, + }) + ) }) it('should validate figure 22 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure22)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure22, + }) + ) }) it('should validate figure 23 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure23)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure23, + }) + ) }) it('should validate figure 24 -- trust mark claims', () => { - assert.doesNotThrow(() => trustMarkClaimsSchema.parse(trustMarkClaimsFigure24)) + assert.doesNotThrow(() => + validate({ + schema: trustMarkClaimsSchema, + data: trustMarkClaimsFigure24, + }) + ) }) it('should validate figure 26 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure26)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure26, + }) + ) }) it('should validate figure 43 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure43)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure43, + }) + ) }) it('should validate figure 50 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure50)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure50, + }) + ) }) it('should validate figure 52 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure52)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure52, + }) + ) }) it('should validate figure 54 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure54)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure54, + }) + ) }) it('should validate figure 56 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure56)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure56, + }) + ) }) it('should validate figure 58 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure58)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure58, + }) + ) }) it('should validate figure 60 -- entity configutation', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure60)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure60, + }) + ) }) it('should validate figure 62 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure62)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure62, + }) + ) }) it('should validate figure 63 -- metadata', () => { - assert.doesNotThrow(() => metadataSchema.parse(metadataFigure63)) + assert.doesNotThrow(() => validate({ schema: metadataSchema, data: metadataFigure63 })) }) it('should validate figure 69 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure69)) + assert.doesNotThrow(() => + validate({ + schema: entityConfigurationClaimsSchema, + data: entityConfigurationFigure69, + }) + ) }) it('should validate figure 70 -- entity statement', () => { - assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure70)) + assert.doesNotThrow(() => + validate({ + schema: entityStatementClaimsSchema, + data: entityStatementFigure70, + }) + ) }) }) }) diff --git a/packages/core/src/entityConfiguration/createEntityConfiguration.ts b/packages/core/src/entityConfiguration/createEntityConfiguration.ts index 5c35dfa..bdaa307 100644 --- a/packages/core/src/entityConfiguration/createEntityConfiguration.ts +++ b/packages/core/src/entityConfiguration/createEntityConfiguration.ts @@ -1,12 +1,13 @@ import { createJsonWebToken, createJwtSignableInput } from '../jsonWeb' import { getUsedJsonWebKey } from '../jsonWeb' import type { SignCallback } from '../utils' -import { type EntityConfigurationClaims, entityConfigurationClaimsSchema } from './entityConfigurationClaims' -import { type EntityConfigurationHeader, entityConfigurationHeaderSchema } from './entityConfigurationHeader' +import { validate } from '../utils/validate' +import { type EntityConfigurationClaimsOptions, entityConfigurationClaimsSchema } from './entityConfigurationClaims' +import { type EntityConfigurationHeaderOptions, entityConfigurationHeaderSchema } from './entityConfigurationHeader' export type CreateEntityConfigurationOptions = { - claims: EntityConfigurationClaims - header: EntityConfigurationHeader + claims: EntityConfigurationClaimsOptions + header: EntityConfigurationHeaderOptions signJwtCallback: SignCallback } @@ -23,8 +24,17 @@ export const createEntityConfiguration = async ({ claims, }: CreateEntityConfigurationOptions) => { // Validate the input - const validatedClaims = entityConfigurationClaimsSchema.parse(claims) - const validatedHeader = entityConfigurationHeaderSchema.parse(header) + const validatedHeader = validate({ + schema: entityConfigurationHeaderSchema, + data: header, + errorMessage: 'invalid header claims provided', + }) + + const validatedClaims = validate({ + data: claims, + schema: entityConfigurationClaimsSchema, + errorMessage: 'invalid payload claims provided', + }) // Create a signable input based on the header and payload const toBeSigned = createJwtSignableInput(header, claims) diff --git a/packages/core/src/entityConfiguration/entityConfigurationClaims.ts b/packages/core/src/entityConfiguration/entityConfigurationClaims.ts index 96550db..b215ab7 100644 --- a/packages/core/src/entityConfiguration/entityConfigurationClaims.ts +++ b/packages/core/src/entityConfiguration/entityConfigurationClaims.ts @@ -6,8 +6,6 @@ export const entityConfigurationClaimsSchema = entityStatementClaimsSchema.refin path: ['iss', 'sub'], }) -type EntityConfigurationClaimsInput = z.input +export type EntityConfigurationClaimsOptions = z.input -type EntityConfigurationClaimsOutput = z.output - -export type EntityConfigurationClaims = EntityConfigurationClaimsInput | EntityConfigurationClaimsOutput +export type EntityConfigurationClaims = z.output diff --git a/packages/core/src/entityConfiguration/entityConfigurationHeader.ts b/packages/core/src/entityConfiguration/entityConfigurationHeader.ts index 79a8bc4..8b316d3 100644 --- a/packages/core/src/entityConfiguration/entityConfigurationHeader.ts +++ b/packages/core/src/entityConfiguration/entityConfigurationHeader.ts @@ -12,4 +12,6 @@ export const entityConfigurationHeaderSchema = z }) .passthrough() -export type EntityConfigurationHeader = z.input +export type EntityConfigurationHeaderOptions = z.input + +export type EntityConfigurationHeader = z.output diff --git a/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts b/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts index ec26240..a2c1685 100644 --- a/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts +++ b/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts @@ -1,5 +1,6 @@ -import { verifyJsonWebToken } from '../jsonWeb/verifyJsonWebToken' +import { verifyJwtSignature } from '../jsonWeb/verifyJsonWebToken' import { type VerifyCallback, addPaths, fetcher } from '../utils' +import { validate } from '../utils/validate' import { entityConfigurationJwtSchema } from './entityConfigurationJwt' export type FetchEntityConfigurationOptions = { @@ -22,10 +23,14 @@ export const fetchEntityConfiguration = async ({ entityId, verifyJwtCallback }: requiredContentType: 'application/entity-statement+jwt', }) - // Parse the JWT into its claims and header claims - const { claims, header, signature } = entityConfigurationJwtSchema.parse(entityConfigurationJwt) + // Parse the JWT into its claims + const { claims } = validate({ + schema: entityConfigurationJwtSchema, + data: entityConfigurationJwt, + errorMessage: 'fetched entity configuration JWT is invalid', + }) - await verifyJsonWebToken({ signature, verifyJwtCallback, header, claims }) + await verifyJwtSignature({ jwt: entityConfigurationJwt, verifyJwtCallback }) return claims } diff --git a/packages/core/src/entityStatement/createEntityStatement.ts b/packages/core/src/entityStatement/createEntityStatement.ts index 7dd7779..795e4c0 100644 --- a/packages/core/src/entityStatement/createEntityStatement.ts +++ b/packages/core/src/entityStatement/createEntityStatement.ts @@ -1,11 +1,12 @@ import { type JsonWebKey, createJsonWebToken, createJwtSignableInput } from '../jsonWeb' import type { SignCallback } from '../utils' -import { type EntityStatementClaims, entityStatementClaimsSchema } from './entityStatementClaims' -import { type EntityStatementHeader, entityStatementHeaderSchema } from './entityStatementHeader' +import { validate } from '../utils/validate' +import { type EntityStatementClaimsOptions, entityStatementClaimsSchema } from './entityStatementClaims' +import { type EntityStatementHeaderOptions, entityStatementHeaderSchema } from './entityStatementHeader' export type CreateEntityStatementOptions = { - claims: EntityStatementClaims - header?: EntityStatementHeader + claims: EntityStatementClaimsOptions + header?: EntityStatementHeaderOptions signJwtCallback: SignCallback /** * @@ -36,8 +37,16 @@ export const createEntityStatement = async ({ } // Validate the input - entityStatementClaimsSchema.parse(claims) - entityStatementHeaderSchema.parse(header) + validate({ + schema: entityStatementHeaderSchema, + data: header, + errorMessage: 'invalid header claims provided', + }) + validate({ + schema: entityStatementClaimsSchema, + data: claims, + errorMessage: 'invalid payload claims provided', + }) // Create a signable input based on the header and payload const toBeSigned = createJwtSignableInput(header, claims) diff --git a/packages/core/src/entityStatement/entityStatementClaims.ts b/packages/core/src/entityStatement/entityStatementClaims.ts index a3d33e0..14fa364 100644 --- a/packages/core/src/entityStatement/entityStatementClaims.ts +++ b/packages/core/src/entityStatement/entityStatementClaims.ts @@ -37,4 +37,6 @@ export const entityStatementClaimsSchema = z return data }) -export type EntityStatementClaims = z.input +export type EntityStatementClaimsOptions = z.input + +export type EntityStatementClaims = z.output diff --git a/packages/core/src/entityStatement/entityStatementHeader.ts b/packages/core/src/entityStatement/entityStatementHeader.ts index 08fe607..420ca8e 100644 --- a/packages/core/src/entityStatement/entityStatementHeader.ts +++ b/packages/core/src/entityStatement/entityStatementHeader.ts @@ -12,4 +12,6 @@ export const entityStatementHeaderSchema = z }) .passthrough() -export type EntityStatementHeader = z.input +export type EntityStatementHeaderOptions = z.input + +export type EntityStatementHeader = z.output diff --git a/packages/core/src/entityStatement/fetchEntityStatement.ts b/packages/core/src/entityStatement/fetchEntityStatement.ts index d2802dc..1d14e61 100644 --- a/packages/core/src/entityStatement/fetchEntityStatement.ts +++ b/packages/core/src/entityStatement/fetchEntityStatement.ts @@ -1,6 +1,7 @@ import { type EntityConfigurationClaims, fetchEntityConfiguration } from '../entityConfiguration' -import { verifyJsonWebToken } from '../jsonWeb/verifyJsonWebToken' +import { verifyJwtSignature } from '../jsonWeb/verifyJsonWebToken' import { type VerifyCallback, fetcher } from '../utils' +import { validate } from '../utils/validate' import { entityStatementJwtSchema } from './entityStatementJwt' export type FetchEntityStatementOptions = { @@ -48,14 +49,16 @@ export const fetchEntityStatement = async ({ }) // Parse the JWT into its claims and header claims - const { claims, header, signature } = entityStatementJwtSchema.parse(entityStatementJwt) + const { claims } = validate({ + schema: entityStatementJwtSchema, + data: entityStatementJwt, + errorMessage: 'fetched entity statement JWT is invalid', + }) - await verifyJsonWebToken({ - signature, + await verifyJwtSignature({ verifyJwtCallback, - header, - claims, - claimsThatContainTheKid: issEntityConfigurationClaims, + jwt: entityStatementJwt, + jwks: issEntityConfigurationClaims.jwks, }) return claims diff --git a/packages/core/src/entityStatement/index.ts b/packages/core/src/entityStatement/index.ts index 9ff3cec..76fd6f9 100644 --- a/packages/core/src/entityStatement/index.ts +++ b/packages/core/src/entityStatement/index.ts @@ -1 +1,3 @@ export * from './entityStatementClaims' +export * from './createEntityStatement' +export * from './fetchEntityStatement' diff --git a/packages/core/src/error/ErrorCode.ts b/packages/core/src/error/ErrorCode.ts new file mode 100644 index 0000000..fd8b04f --- /dev/null +++ b/packages/core/src/error/ErrorCode.ts @@ -0,0 +1,3 @@ +export enum ErrorCode { + Validation = 0, +} diff --git a/packages/core/src/error/OpenIdFederationError.ts b/packages/core/src/error/OpenIdFederationError.ts new file mode 100644 index 0000000..7cae0be --- /dev/null +++ b/packages/core/src/error/OpenIdFederationError.ts @@ -0,0 +1,17 @@ +import type { ZodError } from 'zod' +import { ErrorCode } from './ErrorCode' + +// TODO: Extend to get more properties on the error +export class OpenIdFederationError extends Error { + public constructor( + public errorCode: ErrorCode, + message?: string, + public context?: Error + ) { + super(message) + } + + public static fromZodError(zodError: ZodError) { + return new OpenIdFederationError(ErrorCode.Validation, undefined, zodError) + } +} diff --git a/packages/core/src/error/index.ts b/packages/core/src/error/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/src/jsonWeb/createJsonWebToken.ts b/packages/core/src/jsonWeb/createJsonWebToken.ts index 17f5019..b2a4005 100644 --- a/packages/core/src/jsonWeb/createJsonWebToken.ts +++ b/packages/core/src/jsonWeb/createJsonWebToken.ts @@ -1,4 +1,4 @@ -import { Buffer } from 'node:buffer' +import { Buffer } from 'buffer' /** * diff --git a/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts b/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts index f3f392f..cebde3b 100644 --- a/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts +++ b/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts @@ -1,4 +1,4 @@ -import { Buffer } from 'node:buffer' +import { Buffer } from 'buffer' /** * diff --git a/packages/core/src/jsonWeb/getUsedJsonWebKey.ts b/packages/core/src/jsonWeb/getUsedJsonWebKey.ts index a80a3e2..3933b77 100644 --- a/packages/core/src/jsonWeb/getUsedJsonWebKey.ts +++ b/packages/core/src/jsonWeb/getUsedJsonWebKey.ts @@ -1,20 +1,25 @@ import { z } from 'zod' +import { validate } from '../utils/validate' import { jsonWebKeySetSchema } from './jsonWebKeySet' -const headerSchema = z - .object({ - kid: z.string(), +export const getUsedJsonWebKey = (header: Record, claims: Record) => { + const validatedHeader = validate({ + schema: z + .object({ + kid: z.string(), + }) + .passthrough(), + data: header, + errorMessage: 'invalid header claims. Should contain a key id', + }) + const validatedClaims = validate({ + schema: z.object({ jwks: jsonWebKeySetSchema }).passthrough(), + data: claims, + errorMessage: 'Invalid payload claims. Should contain a json web key set', }) - .passthrough() - -const payloadSchema = z.object({ jwks: jsonWebKeySetSchema }).passthrough() - -export const getUsedJsonWebKey = (header: Record, payload: Record) => { - const validatedHeader = headerSchema.parse(header) - const validatedPayload = payloadSchema.parse(payload) // Get the key from the `claims.jwks` by the `header.kid` - const key = validatedPayload.jwks?.keys.find((key) => key.kid === validatedHeader.kid) + const key = validatedClaims.jwks?.keys.find((key) => key.kid === validatedHeader.kid) if (!key) { throw new Error(`key with id: '${header.kid}' could not be found in the claims`) diff --git a/packages/core/src/jsonWeb/jsonWebKey.ts b/packages/core/src/jsonWeb/jsonWebKey.ts index 99746bc..a5417f5 100644 --- a/packages/core/src/jsonWeb/jsonWebKey.ts +++ b/packages/core/src/jsonWeb/jsonWebKey.ts @@ -1,16 +1,18 @@ import { z } from 'zod' -export const jsonWebKeySchema = z.object({ - kty: z.string(), - // TODO: spec mentions kid may be undefined, but we always need a key id for open id federation - kid: z.string(), - use: z.string().optional(), - key_ops: z.string().optional(), - alg: z.string().optional(), - x5u: z.string().optional(), - x5c: z.string().optional(), - x5t: z.string().optional(), - 'x5t#S256': z.string().optional(), -}) +export const jsonWebKeySchema = z + .object({ + kty: z.string(), + // TODO: spec mentions kid may be undefined, but we always need a key id for open id federation + kid: z.string(), + use: z.string().optional(), + key_ops: z.array(z.string()).optional(), + alg: z.string().optional(), + x5u: z.string().optional(), + x5c: z.string().optional(), + x5t: z.string().optional(), + 'x5t#S256': z.string().optional(), + }) + .passthrough() export type JsonWebKey = z.input diff --git a/packages/core/src/jsonWeb/jsonWebToken.ts b/packages/core/src/jsonWeb/jsonWebToken.ts index 8dcfb06..c5ba771 100644 --- a/packages/core/src/jsonWeb/jsonWebToken.ts +++ b/packages/core/src/jsonWeb/jsonWebToken.ts @@ -1,5 +1,6 @@ -import { Buffer } from 'node:buffer' +import { Buffer } from 'buffer' import { z } from 'zod' +import { validate } from '../utils/validate' const defaultSchema = z.record(z.string().or(z.number()), z.unknown()) @@ -29,8 +30,16 @@ export const jsonWebTokenSchema = < const decodedClaims = Buffer.from(claims, 'base64url').toString() const decodedSignature = Buffer.from(signature, 'base64url') - const validatedHeader = headerSchema.parse(JSON.parse(decodedHeader)) as z.infer - const validatedClaims = claimsSchema.parse(JSON.parse(decodedClaims)) as z.infer + const validatedHeader = validate({ + schema: headerSchema, + data: JSON.parse(decodedHeader), + errorMessage: 'invalid header claims provided', + }) + const validatedClaims = validate({ + schema: claimsSchema, + data: JSON.parse(decodedClaims), + errorMessage: 'invalid payload claims provided', + }) return { header: validatedHeader, diff --git a/packages/core/src/jsonWeb/parseJsonWebToken.ts b/packages/core/src/jsonWeb/parseJsonWebToken.ts new file mode 100644 index 0000000..958a17d --- /dev/null +++ b/packages/core/src/jsonWeb/parseJsonWebToken.ts @@ -0,0 +1,35 @@ +import { Buffer } from 'buffer' + +type JsonWebTokenParts = { + header: Record + claims: Record + signature: Uint8Array + signableInput: Uint8Array +} + +export const parseJsonWebToken = (jwt: string): JsonWebTokenParts => { + const [encodedHeader, encodedClaims, encodedSignature] = jwt.split('.') + + if (!encodedHeader) { + throw new Error('could not find the header in the JWT') + } + + if (!encodedClaims) { + throw new Error('could not find the claims in the JWT') + } + + if (!encodedSignature) { + throw new Error('could not find the signature in the JWT') + } + + const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString()) + const claims = JSON.parse(Buffer.from(encodedClaims, 'base64url').toString()) + const signature = new Uint8Array(Buffer.from(encodedSignature, 'base64url')) + + return { + header, + claims, + signature, + signableInput: new Uint8Array(Buffer.from(`${encodedHeader}.${encodedClaims}`)), + } +} diff --git a/packages/core/src/jsonWeb/verifyJsonWebToken.ts b/packages/core/src/jsonWeb/verifyJsonWebToken.ts index 4e7b179..9e138df 100644 --- a/packages/core/src/jsonWeb/verifyJsonWebToken.ts +++ b/packages/core/src/jsonWeb/verifyJsonWebToken.ts @@ -1,32 +1,26 @@ import type { VerifyCallback } from '../utils' -import { createJwtSignableInput } from './createJsonWebTokenSignableInput' import { getUsedJsonWebKey } from './getUsedJsonWebKey' +import type { JsonWebKeySet } from './jsonWebKeySet' +import { parseJsonWebToken } from './parseJsonWebToken' type VerifyJsonWebTokenOptions = { verifyJwtCallback: VerifyCallback - header: Record - claims: Record - claimsThatContainTheKid?: Record - signature: Uint8Array + jwks?: JsonWebKeySet + jwt: string } -export const verifyJsonWebToken = async ({ - claims, - claimsThatContainTheKid = claims, - header, - signature, - verifyJwtCallback, -}: VerifyJsonWebTokenOptions) => { - const jwk = getUsedJsonWebKey(header, claimsThatContainTheKid) +export const verifyJwtSignature = async ({ jwt, jwks, verifyJwtCallback }: VerifyJsonWebTokenOptions) => { + const { header, signature, claims, signableInput } = parseJsonWebToken(jwt) - // Create a byte array of the data to be verified - const toBeVerified = createJwtSignableInput(header, claims) + const jsonWebKeySetClaims = jwks ? { jwks } : claims + + const jwk = getUsedJsonWebKey(header, jsonWebKeySetClaims) try { const isValid = await verifyJwtCallback({ signature, jwk, - data: toBeVerified, + data: signableInput, }) // TODO: better error message diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index cf304e8..3b7a206 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' import { addSearchParams } from './url' +import { validate } from './validate' const get = async ({ url, @@ -11,7 +12,7 @@ const get = async ({ searchParams?: Record responseValidationSchema?: T requiredContentType?: string -}): Promise : string> => { +}): Promise : string> => { // Fetch the url with the search params const urlSearchParams = new URLSearchParams(searchParams) const urlWithSearchParams = addSearchParams(url, urlSearchParams) @@ -31,7 +32,11 @@ const get = async ({ // If we pass in a validation schema, we expect JSON output if (responseValidationSchema) { const json = await response.json() - return responseValidationSchema.parse(json) + return validate({ + data: json, + schema: responseValidationSchema, + errorMessage: 'invalid response from the GET call', + }) } // If no validation schema is passed in, we expect a string as response @@ -61,7 +66,11 @@ const post = async ({ if (responseValidationSchema) { const json = await response.json() - return responseValidationSchema.parse(json) + return validate({ + data: json, + schema: responseValidationSchema, + errorMessage: 'invalid response from a POST call', + }) } const text = await response.text() diff --git a/packages/core/src/utils/validate.ts b/packages/core/src/utils/validate.ts new file mode 100644 index 0000000..82ee699 --- /dev/null +++ b/packages/core/src/utils/validate.ts @@ -0,0 +1,22 @@ +import type { z } from 'zod' +import { OpenIdFederationError } from '../error/OpenIdFederationError' + +type ValidateOptions = { + data: z.input | unknown + schema: Schema + errorMessage?: string +} + +export const validate = ({ + schema, + data, + errorMessage, +}: ValidateOptions): z.output => { + try { + return schema.parse(data) + } catch (e) { + const error = OpenIdFederationError.fromZodError(e as z.ZodError) + error.message = errorMessage ?? error.message + throw error + } +}