diff --git a/packages/core/__tests__/createEntityConfiguration.test.ts b/packages/core/__tests__/createEntityConfiguration.test.ts new file mode 100644 index 0000000..38384e2 --- /dev/null +++ b/packages/core/__tests__/createEntityConfiguration.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { createEntityConfiguration } from '../src/entityConfiguration' +import type { SignCallback } from '../src/utils' + +describe('create entity configuration', () => { + const signCallback: SignCallback = () => Promise.resolve(new Uint8Array(42).fill(8)) + + it('should create a basic entity configuration', async () => { + const entityConfiguration = await createEntityConfiguration({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://example.org', + sub: 'https://example.org', + jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, + }, + header: { kid: 'a', typ: 'entity-statement+jwt' }, + }) + + assert( + entityConfiguration, + 'eyJraWQiOiJhIiwidHlwIjoiZW50aXR5LXN0YXRlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnIiwic3ViIjoiaHR0cHM6Ly9leGFtcGxlLm9yZyIsImlhdCI6IjE5NzAtMDEtMDFUMDA6MDA6MDEuMDAwWiIsImV4cCI6IjE5NzAtMDEtMDFUMDA6MDA6MDEuMDAwWiIsImp3a3MiOnsia2V5cyI6W3sia3R5IjoiRUMiLCJraWQiOiJhIn1dfX0.CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI' + ) + }) + + it('should create a more complex entity configuration', async () => { + const entityConfiguration = await createEntityConfiguration({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://example.org', + sub: 'https://example.org', + jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, + authority_hints: ['https://foo.com'], + }, + header: { kid: 'a', typ: 'entity-statement+jwt' }, + }) + + assert( + entityConfiguration, + 'eyJraWQiOiJhIiwidHlwIjoiZW50aXR5LXN0YXRlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnIiwic3ViIjoiaHR0cHM6Ly9leGFtcGxlLm9yZyIsImlhdCI6IjE5NzAtMDEtMDFUMDA6MDA6MDEuMDAwWiIsImV4cCI6IjE5NzAtMDEtMDFUMDA6MDA6MDEuMDAwWiIsImp3a3MiOnsia2V5cyI6W3sia3R5IjoiRUMiLCJraWQiOiJhIn1dfSwiYXV0aG9yaXR5X2hpbnRzIjpbImh0dHBzOi8vZm9vLmNvbSJdfQ.CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI' + ) + }) + + it('should not create a entity configuration when iss and sub are not equal', async () => { + await assert.rejects( + createEntityConfiguration({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://some-other.url', + sub: 'https://example.org', + 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({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://example.org', + sub: 'https://example.org', + 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({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://example.org', + sub: 'https://example.org', + jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, + }, + // @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({ + signCallback, + claims: { + exp: 1, + iat: 1, + iss: 'https://example.org', + sub: 'https://example.org', + jwks: { + keys: [ + { kid: 'a', kty: 'EC' }, + { kid: 'a', kty: 'EC' }, + ], + }, + }, + header: { kid: 'a', typ: 'entity-statement+jwt' }, + }), + { + name: 'ZodError', + } + ) + }) +}) diff --git a/packages/core/__tests__/fetchEntityConfiguration.test.ts b/packages/core/__tests__/fetchEntityConfiguration.test.ts new file mode 100644 index 0000000..4e50a0e --- /dev/null +++ b/packages/core/__tests__/fetchEntityConfiguration.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from 'node:test' +import { createEntityConfiguration, fetchEntityConfiguration } from '../src/entityConfiguration' +import type { SignCallback, VerifyCallback } from '../src/utils' + +import assert from 'node:assert/strict' +import nock from 'nock' + +describe('fetch entity configuration', () => { + const verifyJwtCallback: VerifyCallback = () => Promise.resolve(true) + + const signCallback: SignCallback = () => Promise.resolve(new Uint8Array(10).fill(42)) + + it('should fetch a simple entity configuration', async () => { + const entityId = 'https://example.org' + + const claims = { + iss: entityId, + sub: entityId, + iat: new Date(), + exp: new Date(), + jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, + } + + const entityConfiguration = await createEntityConfiguration({ + header: { kid: 'a', typ: 'entity-statement+jwt' }, + claims, + signCallback, + }) + + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, entityConfiguration, { + 'content-type': 'application/entity-statement+jwt', + }) + + const fetchedEntityConfiguration = await fetchEntityConfiguration({ + entityId, + verifyJwtCallback, + }) + + assert.deepStrictEqual(fetchedEntityConfiguration, claims) + + scope.done() + }) + + it('should not fetch an entity configuration when the content-type is invalid', async () => { + const entityId = 'https://exampletwo.org' + + const claims = { + iss: entityId, + sub: entityId, + iat: new Date(), + exp: new Date(), + jwks: { keys: [{ kid: 'a', kty: 'EC' }] }, + } + + const entityConfiguration = await createEntityConfiguration({ + header: { kid: 'a', typ: 'entity-statement+jwt' }, + claims, + signCallback, + }) + + const scope = nock(entityId).get('/.well-known/openid-federation').reply(200, entityConfiguration, { + 'content-type': 'invalid-type', + }) + + await assert.rejects(fetchEntityConfiguration({ entityId, verifyJwtCallback }), { name: 'Error' }) + + scope.done() + }) + + 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' }) + }) +}) diff --git a/packages/core/__tests__/schemas.test.ts b/packages/core/__tests__/schemas.test.ts index 1a971b2..543b904 100644 --- a/packages/core/__tests__/schemas.test.ts +++ b/packages/core/__tests__/schemas.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { constraintSchema } from '../src/constraints' -import { entityConfigurationSchema } from '../src/entityConfiguration' +import { entityConfigurationClaimsSchema } from '../src/entityConfiguration' import { entityStatementClaimsSchema } from '../src/entityStatement' import { metadataSchema } from '../src/metadata/metadata' import { trustMarkClaimsSchema } from '../src/trustMark' @@ -41,28 +41,28 @@ import { trustMarkClaimsFigure20 } from './fixtures/trustmarkClaimsFigure20' describe('zod validation schemas', () => { describe('validate valid test vectors', () => { - it('should validate figure 2 -- entity statement', () => { + it('should validate figure 2 -- entity statement', () => { assert.doesNotThrow(() => entityStatementClaimsSchema.parse(entityStatementFigure2)) }) - it('should validate figure 3 -- trust mark owners', () => { + it('should validate figure 3 -- trust mark owners', () => { assert.doesNotThrow(() => trustMarkOwnerSchema.parse(trustMarkOwnersFigure3)) }) - it('should validate figure 4 -- trust mark issuers', () => { + it('should validate figure 4 -- trust mark issuers', () => { assert.doesNotThrow(() => trustMarkIssuerSchema.parse(trustMarkIssuersFigure4)) }) - it('should validate figure 7 -- federation entity metadata', () => { + it('should validate figure 7 -- federation entity metadata', () => { assert.doesNotThrow(() => federationEntityMetadata.schema.parse(federationEntityMetadataFigure7)) }) - it('should validate figure 8 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure8)) + it('should validate figure 8 -- entity configuration', () => { + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure8)) }) - it('should validate figure 9 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure9)) + it('should validate figure 9 -- entity configuration', () => { + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure9)) }) it('should validate figure 12 -- metadata policy', () => { @@ -74,7 +74,7 @@ describe('zod validation schemas', () => { }) it('should validate figure 18 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure18)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure18)) }) it('should validate figure 19 -- trust mark claims', () => { @@ -106,15 +106,15 @@ describe('zod validation schemas', () => { }) it('should validate figure 43 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure43)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure43)) }) it('should validate figure 50 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure50)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure50)) }) it('should validate figure 52 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure52)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure52)) }) it('should validate figure 54 -- entity statement', () => { @@ -122,7 +122,7 @@ describe('zod validation schemas', () => { }) it('should validate figure 56 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure56)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure56)) }) it('should validate figure 58 -- entity statement', () => { @@ -130,7 +130,7 @@ describe('zod validation schemas', () => { }) it('should validate figure 60 -- entity configutation', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure60)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure60)) }) it('should validate figure 62 -- entity statement', () => { @@ -142,7 +142,7 @@ describe('zod validation schemas', () => { }) it('should validate figure 69 -- entity configuration', () => { - assert.doesNotThrow(() => entityConfigurationSchema.parse(entityConfigurationFigure69)) + assert.doesNotThrow(() => entityConfigurationClaimsSchema.parse(entityConfigurationFigure69)) }) it('should validate figure 70 -- entity statement', () => { diff --git a/packages/core/__tests__/url.test.ts b/packages/core/__tests__/url.test.ts new file mode 100644 index 0000000..b97f7f5 --- /dev/null +++ b/packages/core/__tests__/url.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { addPaths, addSearchParams } from '../src/utils' + +const addUrlFixtures = [ + { + baseUrl: 'https://example.org', + paths: ['one', 'two'], + expected: 'https://example.org/one/two', + }, + { + baseUrl: 'https://example.org/', + paths: ['one', 'two'], + expected: 'https://example.org/one/two', + }, + { + baseUrl: 'https://example.org/////', + paths: ['one', 'two///'], + expected: 'https://example.org/one/two', + }, + { + baseUrl: 'https://example.org/zero', + paths: ['one', 'two/'], + expected: 'https://example.org/zero/one/two', + }, + { + baseUrl: 'https://example.org/zero', + paths: ['/one/', 'two/'], + expected: 'https://example.org/zero/one/two', + }, +] + +const addSearchParamsFixtures: Array<{ + baseUrl: string + searchParams: Record + expected: string +}> = [ + { + baseUrl: 'https://example.org', + searchParams: { one: 'two' }, + expected: 'https://example.org?one=two', + }, + { + baseUrl: 'https://example.org?', + searchParams: { one: 'two' }, + expected: 'https://example.org?one=two', + }, + { + baseUrl: 'https://example.org', + searchParams: { foo: 'bar', baz: 'foo' }, + expected: 'https://example.org?foo=bar&baz=foo', + }, +] + +describe('url parsing', () => { + describe('append path to url', () => { + addUrlFixtures.map(({ paths, expected, baseUrl }) => { + it(`should correctly correctly turn '${baseUrl}' into ${expected}`, () => { + assert.strictEqual(addPaths(baseUrl, ...paths), expected) + }) + }) + }) + + describe('append search params to url', () => { + addSearchParamsFixtures.map(({ searchParams, expected, baseUrl }) => { + it(`should correctly correctly turn '${baseUrl}' into ${expected}`, () => { + assert.strictEqual(addSearchParams(baseUrl, searchParams), expected) + }) + }) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 4953b71..67faa92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@types/node": "*", + "nock": "14.0.0-beta.7", "ts-node": "*", "typescript": "*" } diff --git a/packages/core/src/entityConfiguration.ts b/packages/core/src/entityConfiguration.ts deleted file mode 100644 index a1941e7..0000000 --- a/packages/core/src/entityConfiguration.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { z } from 'zod' -import { entityStatementClaimsSchema } from './entityStatement' - -export const entityConfigurationSchema = entityStatementClaimsSchema.refine((data) => data.iss === data.sub, { - message: 'iss and sub must be equal', - path: ['iss', 'sub'], -}) - -export type EntityConfiguration = z.input diff --git a/packages/core/src/entityConfiguration/createEntityConfiguration.ts b/packages/core/src/entityConfiguration/createEntityConfiguration.ts new file mode 100644 index 0000000..44ec6d4 --- /dev/null +++ b/packages/core/src/entityConfiguration/createEntityConfiguration.ts @@ -0,0 +1,36 @@ +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' + +export type CreateEntityConfigurationOptions = { + claims: EntityConfigurationClaims + header: EntityConfigurationHeader + signCallback: SignCallback +} + +/** + * + * Create an entity configuration + * + * The signing callback will be called with the `header.kid` value in the `claims.jwks.keys` and a signed JWT will be returned + * + */ +export const createEntityConfiguration = async ({ header, signCallback, claims }: CreateEntityConfigurationOptions) => { + // Validate the input + const validatedClaims = entityConfigurationClaimsSchema.parse(claims) + const validatedHeader = entityConfigurationHeaderSchema.parse(header) + + // Create a signable input based on the header and payload + const toBeSigned = createJwtSignableInput(header, claims) + + // Fetch the key that will be used for signing + const jwk = getUsedJsonWebKey(validatedHeader, validatedClaims) + + // Call the signing callback so the user has to handle the crypto part + const signature = await signCallback({ toBeSigned, jwk }) + + // return a json web token based on the header, claims and associated signature + return createJsonWebToken(header, claims, signature) +} diff --git a/packages/core/src/entityConfiguration/entityConfigurationClaims.ts b/packages/core/src/entityConfiguration/entityConfigurationClaims.ts new file mode 100644 index 0000000..23bb07b --- /dev/null +++ b/packages/core/src/entityConfiguration/entityConfigurationClaims.ts @@ -0,0 +1,9 @@ +import type { z } from 'zod' +import { entityStatementClaimsSchema } from '../entityStatement' + +export const entityConfigurationClaimsSchema = entityStatementClaimsSchema.refine((data) => data.iss === data.sub, { + message: 'iss and sub must be equal', + path: ['iss', 'sub'], +}) + +export type EntityConfigurationClaims = z.input diff --git a/packages/core/src/entityConfiguration/entityConfigurationHeader.ts b/packages/core/src/entityConfiguration/entityConfigurationHeader.ts new file mode 100644 index 0000000..79a8bc4 --- /dev/null +++ b/packages/core/src/entityConfiguration/entityConfigurationHeader.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +/** + * + * @todo define exact scheme + * + */ +export const entityConfigurationHeaderSchema = z + .object({ + kid: z.string(), + typ: z.literal('entity-statement+jwt'), + }) + .passthrough() + +export type EntityConfigurationHeader = z.input diff --git a/packages/core/src/entityConfiguration/entityConfigurationJwt.ts b/packages/core/src/entityConfiguration/entityConfigurationJwt.ts new file mode 100644 index 0000000..4ebb53d --- /dev/null +++ b/packages/core/src/entityConfiguration/entityConfigurationJwt.ts @@ -0,0 +1,8 @@ +import { jsonWebTokenSchema } from '../jsonWeb' +import { entityConfigurationClaimsSchema } from './entityConfigurationClaims' +import { entityConfigurationHeaderSchema } from './entityConfigurationHeader' + +export const entityConfigurationJwtSchema = jsonWebTokenSchema({ + headerSchema: entityConfigurationHeaderSchema, + claimsSchema: entityConfigurationClaimsSchema, +}) diff --git a/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts b/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts new file mode 100644 index 0000000..794696f --- /dev/null +++ b/packages/core/src/entityConfiguration/fetchEntityConfiguration.ts @@ -0,0 +1,53 @@ +import { createJwtSignableInput } from '../jsonWeb' +import { getUsedJsonWebKey } from '../jsonWeb' +import { type VerifyCallback, addPaths, fetcher } from '../utils' +import { entityConfigurationJwtSchema } from './entityConfigurationJwt' + +export type FetchEntityConfigurationOptions = { + entityId: string + verifyJwtCallback: VerifyCallback +} + +/** + * + * {@link https://openid.net/specs/openid-federation-1_0.html#section-9.1 | Federation Entity Request} + * + */ +export const fetchEntityConfiguration = async ({ entityId, verifyJwtCallback }: FetchEntityConfigurationOptions) => { + // Create the entity URL + const federationUrl = addPaths(entityId, '.well-known', 'openid-federation') + + // Fetch the JWT entity configuration + const entityConfigurationJwt = await fetcher.get({ + url: federationUrl, + requiredContentType: 'application/entity-statement+jwt', + }) + + // Parse the JWT into its claims and header claims + const { claims, header, signature } = entityConfigurationJwtSchema.parse(entityConfigurationJwt) + + const jwk = getUsedJsonWebKey(header, claims) + + // TODO: create byte array of the JWT that has to be verified + const toBeVerified = createJwtSignableInput(header, claims) + + try { + const isValid = await verifyJwtCallback({ + signature, + jwk, + data: toBeVerified, + }) + // TODO: better error message + if (!isValid) { + throw new Error('Signature in the JWT is invalid') + } + } catch (e: unknown) { + if (typeof e === 'string') { + throw new Error(e) + } + + throw e + } + + return claims +} diff --git a/packages/core/src/entityConfiguration/index.ts b/packages/core/src/entityConfiguration/index.ts new file mode 100644 index 0000000..99b0b38 --- /dev/null +++ b/packages/core/src/entityConfiguration/index.ts @@ -0,0 +1,5 @@ +export * from './entityConfigurationClaims' +export * from './entityConfigurationHeader' + +export * from './fetchEntityConfiguration' +export * from './createEntityConfiguration' diff --git a/packages/core/src/entityStatement.ts b/packages/core/src/entityStatement.ts deleted file mode 100644 index 27241fc..0000000 --- a/packages/core/src/entityStatement.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { constraintSchema } from './constraints' -import { jsonWebKeySetSchema } from './jsonWeb' -import { metadataPolicySchema, metadataSchema } from './metadata' -import { trustMarkIssuerSchema, trustMarkOwnerSchema, trustMarkSchema } from './trustMark' -import { dateSchema } from './utils' - -import { z } from 'zod' - -export const entityStatementClaimsSchema = z.object({ - iss: z.string(), - sub: z.string(), - iat: dateSchema, - exp: dateSchema, - jwks: jsonWebKeySetSchema, - authority_hints: z.array(z.string().url()).optional(), - metadata: metadataSchema.optional(), - metadata_policy: metadataPolicySchema.optional(), - constraints: constraintSchema.optional(), - crit: z.array(z.string()).optional(), - metadata_policy_crit: z.array(z.string()).optional(), - trust_marks: z.array(trustMarkSchema).optional(), - trust_mark_issuers: trustMarkIssuerSchema.optional(), - trust_mark_owners: trustMarkOwnerSchema.optional(), - source_endpoint: z.string().url().optional(), -}) - -export type EntityStatementClaims = z.input diff --git a/packages/core/src/entityStatement/entityStatementClaims.ts b/packages/core/src/entityStatement/entityStatementClaims.ts new file mode 100644 index 0000000..a3d33e0 --- /dev/null +++ b/packages/core/src/entityStatement/entityStatementClaims.ts @@ -0,0 +1,40 @@ +import { constraintSchema } from '../constraints' +import { jsonWebKeySetSchema } from '../jsonWeb' +import { metadataPolicySchema, metadataSchema } from '../metadata' +import { trustMarkIssuerSchema, trustMarkOwnerSchema, trustMarkSchema } from '../trustMark' +import { dateSchema } from '../utils' + +import { z } from 'zod' + +export const entityStatementClaimsSchema = z + .object({ + iss: z.string(), + sub: z.string(), + iat: dateSchema, + exp: dateSchema, + jwks: jsonWebKeySetSchema, + authority_hints: z.array(z.string().url()).optional(), + metadata: metadataSchema.optional(), + metadata_policy: metadataPolicySchema.optional(), + constraints: constraintSchema.optional(), + crit: z.array(z.string()).optional(), + metadata_policy_crit: z.array(z.string()).optional(), + trust_marks: z.array(trustMarkSchema).optional(), + trust_mark_issuers: trustMarkIssuerSchema.optional(), + trust_mark_owners: trustMarkOwnerSchema.optional(), + source_endpoint: z.string().url().optional(), + }) + .superRefine((data, ctx) => { + const keyIds = data.jwks.keys.map((key) => key.kid) + if (keyIds.some((key, i) => keyIds.indexOf(key) !== i)) { + ctx.addIssue({ + code: 'custom', + message: 'keys include duplicate key ids', + path: ['jwks', 'keys'], + }) + } + + return data + }) + +export type EntityStatementClaims = z.input diff --git a/packages/core/src/entityStatement/entityStatementHeader.ts b/packages/core/src/entityStatement/entityStatementHeader.ts new file mode 100644 index 0000000..c8f6529 --- /dev/null +++ b/packages/core/src/entityStatement/entityStatementHeader.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +/** + * + * @todo define extact scheme + * + */ +export const entityStatementHeaderSchema = z + .object({ + kid: z.string(), + typ: z.literal('entity-statement+jwt'), + }) + .passthrough() diff --git a/packages/core/src/entityStatement/index.ts b/packages/core/src/entityStatement/index.ts new file mode 100644 index 0000000..9ff3cec --- /dev/null +++ b/packages/core/src/entityStatement/index.ts @@ -0,0 +1 @@ +export * from './entityStatementClaims' diff --git a/packages/core/src/jsonWeb/createJsonWebToken.ts b/packages/core/src/jsonWeb/createJsonWebToken.ts new file mode 100644 index 0000000..17f5019 --- /dev/null +++ b/packages/core/src/jsonWeb/createJsonWebToken.ts @@ -0,0 +1,19 @@ +import { Buffer } from 'node:buffer' + +/** + * + * Create a json web token according to {@link https://datatracker.ietf.org/doc/html/rfc7519 | RFC7519 } + * + */ +export const createJsonWebToken = ( + header: Record, + payload: Record, + signature: Uint8Array +) => { + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url') + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + + const encodedSignature = Buffer.from(signature).toString('base64url') + + return `${encodedHeader}.${encodedPayload}.${encodedSignature}` +} diff --git a/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts b/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts new file mode 100644 index 0000000..f3f392f --- /dev/null +++ b/packages/core/src/jsonWeb/createJsonWebTokenSignableInput.ts @@ -0,0 +1,23 @@ +import { Buffer } from 'node:buffer' + +/** + * + * Converts a JSON header and payload into a byte array which can be used to verify and sign a JWT + * + */ +export const createJwtSignableInput = (header: Record, payload: Record) => { + if (Object.keys(header).length === 0) { + throw new Error('Can not create JWT with an empty header') + } + + if (Object.keys(payload).length === 0) { + throw new Error('Can not create JWT with an empty payload') + } + + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url') + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + + const toBeSignedString = `${encodedHeader}.${encodedPayload}` + + return new Uint8Array(Buffer.from(toBeSignedString)) +} diff --git a/packages/core/src/jsonWeb/getUsedJsonWebKey.ts b/packages/core/src/jsonWeb/getUsedJsonWebKey.ts new file mode 100644 index 0000000..a80a3e2 --- /dev/null +++ b/packages/core/src/jsonWeb/getUsedJsonWebKey.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { jsonWebKeySetSchema } from './jsonWebKeySet' + +const headerSchema = z + .object({ + kid: z.string(), + }) + .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) + + if (!key) { + throw new Error(`key with id: '${header.kid}' could not be found in the claims`) + } + + return key +} diff --git a/packages/core/src/jsonWeb/index.ts b/packages/core/src/jsonWeb/index.ts index b3f1108..be3b2e6 100644 --- a/packages/core/src/jsonWeb/index.ts +++ b/packages/core/src/jsonWeb/index.ts @@ -1,3 +1,7 @@ export * from './jsonWebKey' export * from './jsonWebKeySet' export * from './jsonWebToken' + +export * from './createJsonWebToken' +export * from './getUsedJsonWebKey' +export * from './createJsonWebTokenSignableInput' diff --git a/packages/core/src/jsonWeb/jsonWebKey.ts b/packages/core/src/jsonWeb/jsonWebKey.ts index b1a6ada..7cc141a 100644 --- a/packages/core/src/jsonWeb/jsonWebKey.ts +++ b/packages/core/src/jsonWeb/jsonWebKey.ts @@ -11,3 +11,5 @@ export const jsonWebKeySchema = z.object({ x5t: z.string().optional(), 'x5t#S256': z.string().optional(), }) + +export type JsonWebKey = z.input diff --git a/packages/core/src/jsonWeb/jsonWebToken.ts b/packages/core/src/jsonWeb/jsonWebToken.ts index 0fc7aab..8dcfb06 100644 --- a/packages/core/src/jsonWeb/jsonWebToken.ts +++ b/packages/core/src/jsonWeb/jsonWebToken.ts @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer' -import { type ZodSchema, z } from 'zod' +import { z } from 'zod' const defaultSchema = z.record(z.string().or(z.number()), z.unknown()) @@ -8,22 +8,33 @@ const defaultSchema = z.record(z.string().or(z.number()), z.unknown()) * @todo better jwt validation * */ -export const jsonWebTokenSchema = ( +export const jsonWebTokenSchema = < + CS extends z.ZodSchema = typeof defaultSchema, + HS extends z.ZodSchema = typeof defaultSchema, +>( { - claimsSchema = defaultSchema, - headerSchema = defaultSchema, + claimsSchema = defaultSchema as unknown as CS, + headerSchema = defaultSchema as unknown as HS, }: { - claimsSchema?: ZodSchema - headerSchema?: ZodSchema - } = { claimsSchema: defaultSchema, headerSchema: defaultSchema } + claimsSchema?: CS + headerSchema?: HS + } = { + claimsSchema: defaultSchema as unknown as CS, + headerSchema: defaultSchema as unknown as HS, + } ) => - z.string().refine((s) => { - const [header, claims] = s.split('.') + z.string().transform((s) => { + const [header, claims, signature] = s.split('.') const decodedHeader = Buffer.from(header, 'base64url').toString() const decodedClaims = Buffer.from(claims, 'base64url').toString() + const decodedSignature = Buffer.from(signature, 'base64url') - const validatedHeader = headerSchema.parse(JSON.parse(decodedHeader)) - const validatedClaims = claimsSchema.parse(JSON.parse(decodedClaims)) + const validatedHeader = headerSchema.parse(JSON.parse(decodedHeader)) as z.infer + const validatedClaims = claimsSchema.parse(JSON.parse(decodedClaims)) as z.infer - return { header: validatedHeader, claims: validatedClaims } + return { + header: validatedHeader, + claims: validatedClaims, + signature: new Uint8Array(decodedSignature), + } }) diff --git a/packages/core/src/utils/dateSchema.ts b/packages/core/src/utils/dateSchema.ts index 242ab40..7c7c2da 100644 --- a/packages/core/src/utils/dateSchema.ts +++ b/packages/core/src/utils/dateSchema.ts @@ -1,3 +1,10 @@ import { z } from 'zod' -export const dateSchema = z.number().refine((date) => new Date(date * 1000)) +/** + * + * Date schema that parses an unix timestamp in seconds since EPOCH to js date object + * + */ +export const dateSchema = z + .union([z.date(), z.number(), z.string().transform((x) => (x.includes('T') ? new Date(x) : Number(x)))]) + .transform((d) => (d instanceof Date ? d : new Date(d * 1000))) diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts new file mode 100644 index 0000000..cf304e8 --- /dev/null +++ b/packages/core/src/utils/fetch.ts @@ -0,0 +1,75 @@ +import type { z } from 'zod' +import { addSearchParams } from './url' + +const get = async ({ + url, + searchParams, + responseValidationSchema, + requiredContentType, +}: { + url: string + searchParams?: Record + responseValidationSchema?: T + requiredContentType?: string +}): Promise : string> => { + // Fetch the url with the search params + const urlSearchParams = new URLSearchParams(searchParams) + const urlWithSearchParams = addSearchParams(url, urlSearchParams) + const response = await global.fetch(urlWithSearchParams, { + method: 'GET', + }) + + // validate the expected content-type + if (requiredContentType && response.headers.get('content-type') !== requiredContentType) { + throw new Error( + `received content-type '${response.headers.get( + 'content-type' + )}' does not equal expected content-type '${requiredContentType}'` + ) + } + + // If we pass in a validation schema, we expect JSON output + if (responseValidationSchema) { + const json = await response.json() + return responseValidationSchema.parse(json) + } + + // If no validation schema is passed in, we expect a string as response + const text = await response.text() + return text +} + +/** + * + * @todo make get/post use the same method private internally as changes with apply to both + * + */ +const post = async ({ + url, + body, + responseValidationSchema, +}: { + url: string + body?: Record + responseValidationSchema?: z.ZodSchema +}) => { + const response = await global.fetch(url, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + headers: { 'Content-Type': 'application/json' }, + }) + + if (responseValidationSchema) { + const json = await response.json() + return responseValidationSchema.parse(json) + } + + const text = await response.text() + + return text +} + +export const fetcher = { + get, + post, +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 17f57bd..b8610de 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1 +1,4 @@ export * from './dateSchema' +export * from './url' +export * from './fetch' +export * from './types' diff --git a/packages/core/src/utils/types.ts b/packages/core/src/utils/types.ts new file mode 100644 index 0000000..fd86c73 --- /dev/null +++ b/packages/core/src/utils/types.ts @@ -0,0 +1,14 @@ +import type { JsonWebKey } from '../jsonWeb' + +export type Optional = Pick, K> & Omit + +export type SignCallback = (options: { + toBeSigned: Uint8Array + jwk: JsonWebKey +}) => Promise + +export type VerifyCallback = (options: { + data: Uint8Array + signature: Uint8Array + jwk: JsonWebKey +}) => Promise diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts new file mode 100644 index 0000000..8073321 --- /dev/null +++ b/packages/core/src/utils/url.ts @@ -0,0 +1,37 @@ +/** + * + * Add paths to a url + * + * ## example + * + * ```typescript + * const url = addPaths('https://example.org','path', 'two') + * assert(url, "https://example.org/path/two") + * + * const url = addPaths('https://example.org/','path', 'two') + * assert(url, "https://example.org/path/two") + * + * const url = addPaths('https://example.org/','path/', '/two/') + * assert(url, "https://example.org/path/two") + * ``` + * + */ +export const addPaths = (baseUrl: string, ...paths: Array) => { + const [scheme, rest] = baseUrl.split('://') + const urlWithoutScheme = rest + // Get all base the parts + .split('/') + // Add the paths + .concat(...paths.map((p) => p.split('/'))) + // Filter out empty paths (i.e. '///path///' to 'path') + .filter((s) => s.length > 0) + // Create the full url + .join('/') + + return `${scheme}://${urlWithoutScheme}` +} + +export const addSearchParams = (baseUrl: string, searchParams: URLSearchParams | Record) => + baseUrl.endsWith('?') + ? `${baseUrl}${searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams(searchParams)}` + : `${baseUrl}?${searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams(searchParams)}` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4d2afe..c78208a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@types/node': specifier: ^20.11.1 version: 20.14.10 + nock: + specifier: 14.0.0-beta.7 + version: 14.0.0-beta.7 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.14.10)(typescript@5.3.3) @@ -221,6 +224,9 @@ packages: resolution: {integrity: sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==} engines: {node: 14 >=14.21 || 16 >=16.20 || >=18} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + lru-cache@10.4.2: resolution: {integrity: sha512-voV4dDrdVZVNz84n39LFKDaRzfwhdzJ7akpyXfTMxCgRUp07U3lcJUXRlhTKP17rgt09sUzLi5iCitpEAr+6ug==} engines: {node: 14 || 16 || 18 || 20 || >=22} @@ -236,6 +242,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + nock@14.0.0-beta.7: + resolution: {integrity: sha512-+EQMm5W9K8YnBE2Ceg4hnJynaCZmvK8ZlFXQ2fxGwtkOkBUq8GpQLTks2m1jpvse9XDxMDDOHgOWpiznFuh0bA==} + engines: {node: '>= 18'} + package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} @@ -247,6 +257,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + propagate@2.0.1: + resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} + engines: {node: '>= 8'} + rimraf@5.0.9: resolution: {integrity: sha512-3i7b8OcswU6CpU8Ej89quJD4O98id7TtVM5U4Mybh84zQXdrFmDLouWBEEaD/QfO3gDDfH+AGFCGsR7kngzQnA==} engines: {node: 14 >=14.20 || 16 >=16.20 || >=18} @@ -478,6 +492,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + json-stringify-safe@5.0.1: {} + lru-cache@10.4.2: {} make-error@1.3.6: {} @@ -488,6 +504,11 @@ snapshots: minipass@7.1.2: {} + nock@14.0.0-beta.7: + dependencies: + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + package-json-from-dist@1.0.0: {} path-key@3.1.1: {} @@ -497,6 +518,8 @@ snapshots: lru-cache: 10.4.2 minipass: 7.1.2 + propagate@2.0.1: {} + rimraf@5.0.9: dependencies: glob: 10.4.4