diff --git a/auth-server/package.json b/auth-server/package.json index e3003cff..88629426 100644 --- a/auth-server/package.json +++ b/auth-server/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/auth-server", - "version": "0.27.15", + "version": "0.27.16", "private": true, "repository": { "type": "git", diff --git a/auth-server/src/app/api/jwks/[audience]/verifyJwksAccessAssertion.ts b/auth-server/src/app/api/jwks/[audience]/verifyJwksAccessAssertion.ts index 3369d11b..d13c9146 100644 --- a/auth-server/src/app/api/jwks/[audience]/verifyJwksAccessAssertion.ts +++ b/auth-server/src/app/api/jwks/[audience]/verifyJwksAccessAssertion.ts @@ -1,7 +1,19 @@ import type { AuthDatabase } from "@/lib/auth-db/auth-database-types"; import { JwksAccessKeysRegistry } from "@/lib/auth-db/jwks-access-keys"; +import { RedisCache } from "@/lib/redis"; +import { SCHEMAVAULTS_AUTH_APP_DEFINITION } from "@schemavaults/app-definitions"; import type { Kysely } from "@schemavaults/dbh"; -import { jwtVerify, importSPKI } from "@schemavaults/jwt"; +import { + jwtVerify, + importSPKI, + JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS, +} from "@schemavaults/jwt"; + +// Accepted jtis are remembered for twice the 60s acceptance window +// (maxTokenAge) so an assertion can never be replayed: by the time its jti +// record expires, the assertion itself is too old to verify. +const SEEN_JTI_TTL_SECONDS = 120; export default async function verifyJwksAccessAssertion( assertion: string, @@ -20,6 +32,10 @@ export default async function verifyJwksAccessAssertion( const publicKey = await importSPKI(activeKey.public_key, "RS256"); const { payload } = await jwtVerify(assertion, publicKey, { algorithms: ["RS256"], + audience: SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id, + issuer: audience, + maxTokenAge: JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + requiredClaims: [...JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS], }); // Verify the assertion is for this specific audience @@ -28,6 +44,29 @@ export default async function verifyJwksAccessAssertion( return false; } + // jose only checks that required claims are present, so pin down the + // jti's shape before using it as a Redis key component. + const jti = payload.jti; + if (typeof jti !== "string" || jti.length === 0 || jti.length > 255) { + console.warn(`Assertion for audience "${audience}" carries an invalid jti claim`); + return false; + } + + // Each assertion is single-use: SET NX fails if this jti was already + // accepted, which rejects replays of captured assertions. + await using redis = RedisCache.createConnection(); + const claimed = await redis.client.set( + `jwks-access-assertion:seen-jti:${audience}:${jti}`, + "1", + "EX", + SEEN_JTI_TTL_SECONDS, + "NX", + ); + if (claimed !== "OK") { + console.warn(`Replayed JWKS access assertion (jti "${jti}") for audience "${audience}"`); + return false; + } + return true; } catch (e: unknown) { console.error("Failed to verify JWKS access assertion:", e); diff --git a/packages/auth-server-sdk/package.json b/packages/auth-server-sdk/package.json index 6e863e01..89e0265a 100644 --- a/packages/auth-server-sdk/package.json +++ b/packages/auth-server-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/auth-server-sdk", "description": "TypeScript SDK for building authenticated endpoints/middlewares for the Auth Server and Resource Servers", - "version": "0.22.81", + "version": "0.22.82", "license": "UNLICENSED", "private": false, "repository": { diff --git a/packages/jwt/package.json b/packages/jwt/package.json index bf74f483..c83ada98 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/jwt", "description": "Utility functions for authentication and authorization for use from the auth server or a resource server", - "version": "0.7.32", + "version": "0.7.33", "license": "UNLICENSED", "private": false, "repository": { diff --git a/packages/jwt/src/JwksAccessProofToken/JwksAccessProofToken.test.ts b/packages/jwt/src/JwksAccessProofToken/JwksAccessProofToken.test.ts index a3e14b89..309ca6ea 100644 --- a/packages/jwt/src/JwksAccessProofToken/JwksAccessProofToken.test.ts +++ b/packages/jwt/src/JwksAccessProofToken/JwksAccessProofToken.test.ts @@ -2,47 +2,107 @@ import { describe, expect, test } from "bun:test"; import createJwksAccessProofToken from "./createJwksAccessProofToken"; import { ApiServerId, + SCHEMAVAULTS_AUTH_APP_DEFINITION, SCHEMAVAULTS_AUTH_SERVER, SCHEMAVAULTS_MAIL_SERVER, SCHEMAVAULTS_REGISTRY_SERVER, } from "@schemavaults/app-definitions"; import { PEMFormat, SigningKeyPairFactory } from "@/jwt/jwt_keys"; -import { importPKCS8, importSPKI } from "jose"; +import { decodeJwt, importPKCS8, importSPKI, SignJWT } from "jose"; +import type { JWTPayload } from "jose"; import { sign_verify_alg } from "@/jwt/sign_verify_alg"; import verifyJwksAccessProofToken from "./verifyJwksAccessProofToken"; const DEBUG: boolean = false; -async function testCreateAndVerifyForApiServerId( - api_server_id: ApiServerId, -): Promise { - const [privateKey, publicKey] = await new SigningKeyPairFactory().generate( - "pem", - ); - expect(PEMFormat.isPemFormat(privateKey, "PRIVATE")).toBeTrue(); - expect(PEMFormat.isPemFormat(publicKey, "PUBLIC")).toBeTrue(); +interface TestKeyPair { + privateKey: CryptoKey; + publicKey: CryptoKey; +} + +async function generateTestKeyPair(): Promise { + const [privateKeyPem, publicKeyPem] = + await new SigningKeyPairFactory().generate("pem"); + expect(PEMFormat.isPemFormat(privateKeyPem, "PRIVATE")).toBeTrue(); + expect(PEMFormat.isPemFormat(publicKeyPem, "PUBLIC")).toBeTrue(); if (DEBUG) { console.log("Private Key:\n"); - console.log(privateKey); + console.log(privateKeyPem); console.log("Public Key:\n"); - console.log(publicKey); + console.log(publicKeyPem); } + return { + privateKey: await importPKCS8(privateKeyPem, sign_verify_alg), + publicKey: await importSPKI(publicKeyPem, sign_verify_alg), + }; +} + +async function testCreateAndVerifyForApiServerId( + api_server_id: ApiServerId, +): Promise { + const { privateKey, publicKey } = await generateTestKeyPair(); + const token = await createJwksAccessProofToken({ api_server_id, - private_key: await importPKCS8(privateKey, sign_verify_alg), + private_key: privateKey, }); expect(token).toBeString(); const result = await verifyJwksAccessProofToken({ api_server_id, - public_key: await importSPKI(publicKey, sign_verify_alg), + public_key: publicKey, token, }); expect(result).toBeTrue(); } +// Complete claim set matching what createJwksAccessProofToken emits; the +// negative tests below remove or corrupt one claim at a time. +function baselineClaims(api_server_id: ApiServerId): JWTPayload { + const now = Math.floor(Date.now() / 1000); + return { + api_server_id, + sub: api_server_id, + iss: api_server_id, + aud: SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id, + iat: now, + nbf: now - 1, + exp: now + 60, + jti: crypto.randomUUID(), + }; +} + +async function signClaims( + claims: JWTPayload, + privateKey: CryptoKey, +): Promise { + return await new SignJWT(claims) + .setProtectedHeader({ alg: sign_verify_alg }) + .sign(privateKey); +} + +async function expectVerificationToReject( + token: string, + api_server_id: ApiServerId, + publicKey: CryptoKey, +): Promise { + let rejected: boolean = false; + try { + const verified = await verifyJwksAccessProofToken({ + token, + api_server_id, + public_key: publicKey, + }); + rejected = verified !== true; + } catch (e: unknown) { + void e; + rejected = true; + } + expect(rejected).toBeTrue(); +} + describe("JwksAccessProofToken", async () => { test("can create and verify a Jwks Access Proof Token for hardcoded API servers", async () => { await testCreateAndVerifyForApiServerId( @@ -71,4 +131,144 @@ describe("JwksAccessProofToken", async () => { await testCreateAndVerifyForApiServerId(crypto.randomUUID()); } }); + + test("emits aud, iss, sub, exp, iat, and jti claims", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey } = await generateTestKeyPair(); + + const before = Math.floor(Date.now() / 1000); + const token = await createJwksAccessProofToken({ + api_server_id, + private_key: privateKey, + }); + const after = Math.ceil(Date.now() / 1000); + + const claims = decodeJwt(token); + expect(claims.aud).toBe(SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id); + expect(claims.iss).toBe(api_server_id); + expect(claims.sub).toBe(api_server_id); + expect(claims.jti).toBeString(); + expect((claims.jti as string).length).toBeGreaterThan(0); + expect(claims.iat).toBeNumber(); + expect(claims.iat!).toBeGreaterThanOrEqual(before); + expect(claims.iat!).toBeLessThanOrEqual(after); + expect(claims.exp).toBeNumber(); + expect(claims.exp! - claims.iat!).toBe(60); + }); + + test("mints a unique jti for every assertion", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey } = await generateTestKeyPair(); + + const first = decodeJwt( + await createJwksAccessProofToken({ + api_server_id, + private_key: privateKey, + }), + ); + const second = decodeJwt( + await createJwksAccessProofToken({ + api_server_id, + private_key: privateKey, + }), + ); + expect(first.jti).toBeString(); + expect(second.jti).toBeString(); + expect(first.jti).not.toBe(second.jti); + }); + + describe("hardened claim validation", () => { + test("accepts a hand-signed assertion with the complete baseline claim set", async () => { + // Guards the helpers used by the rejection tests below: if this + // baseline did not verify, the negative tests would pass vacuously. + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const token = await signClaims(baselineClaims(api_server_id), privateKey); + const verified = await verifyJwksAccessProofToken({ + token, + api_server_id, + public_key: publicKey, + }); + expect(verified).toBeTrue(); + }); + + test("rejects an assertion missing the exp claim", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const claims = baselineClaims(api_server_id); + delete claims.exp; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects an assertion missing the iat claim", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const claims = baselineClaims(api_server_id); + delete claims.iat; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects an assertion missing the jti claim", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const claims = baselineClaims(api_server_id); + delete claims.jti; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects an expired assertion", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const now = Math.floor(Date.now() / 1000); + const claims: JWTPayload = { + ...baselineClaims(api_server_id), + iat: now - 120, + nbf: now - 120, + exp: now - 60, + }; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects a stale assertion even when its exp claim is in the future", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const now = Math.floor(Date.now() / 1000); + const claims: JWTPayload = { + ...baselineClaims(api_server_id), + // Older than the 60s maxTokenAge window, but with a generous exp: + // maxTokenAge must reject it regardless of the self-declared exp. + iat: now - 120, + nbf: now - 120, + exp: now + 300, + }; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects an assertion with a mismatched aud claim", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const claims: JWTPayload = { + ...baselineClaims(api_server_id), + aud: "not-the-schemavaults-auth-server", + }; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + + test("rejects an assertion with a mismatched iss claim", async () => { + const api_server_id: ApiServerId = crypto.randomUUID(); + const { privateKey, publicKey } = await generateTestKeyPair(); + const claims: JWTPayload = { + ...baselineClaims(api_server_id), + iss: crypto.randomUUID(), + }; + const token = await signClaims(claims, privateKey); + await expectVerificationToReject(token, api_server_id, publicKey); + }); + }); }); diff --git a/packages/jwt/src/JwksAccessProofToken/constants.ts b/packages/jwt/src/JwksAccessProofToken/constants.ts new file mode 100644 index 00000000..65e5861e --- /dev/null +++ b/packages/jwt/src/JwksAccessProofToken/constants.ts @@ -0,0 +1,18 @@ +// JWKS access assertions are short-lived, single-use credentials: resource +// servers mint a fresh one immediately before each request to the auth +// server. Verification rejects assertions whose `iat` is older than this +// window (jose `maxTokenAge`), and the mint side uses the same value for +// `exp` so a token's self-declared lifetime matches the acceptance window. +export const JWKS_ACCESS_PROOF_TOKEN_MAX_AGE = "60s"; + +// Claims every JWKS access assertion must carry. Verification rejects +// tokens missing any of these (jose `requiredClaims`), so assertions minted +// without `jti`/`iat` cannot be accepted — and therefore cannot be replayed +// past the auth server's jti tracking. +export const JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS: readonly string[] = [ + "exp", + "iat", + "jti", + "aud", + "iss", +]; diff --git a/packages/jwt/src/JwksAccessProofToken/createJwksAccessProofToken.ts b/packages/jwt/src/JwksAccessProofToken/createJwksAccessProofToken.ts index e7651418..f86c91a6 100644 --- a/packages/jwt/src/JwksAccessProofToken/createJwksAccessProofToken.ts +++ b/packages/jwt/src/JwksAccessProofToken/createJwksAccessProofToken.ts @@ -6,6 +6,7 @@ import { SCHEMAVAULTS_AUTH_SERVER, } from "@schemavaults/app-definitions"; import { SignJWT } from "jose"; +import { JWKS_ACCESS_PROOF_TOKEN_MAX_AGE } from "./constants"; export interface ICreateJwksAccessProofToken { api_server_id: ApiServerId; @@ -16,7 +17,7 @@ export async function createJwksAccessProofToken({ api_server_id, private_key, }: ICreateJwksAccessProofToken): Promise { - if (!apiServerIdSchema.safeParse(api_server_id)) { + if (!apiServerIdSchema.safeParse(api_server_id).success) { throw new TypeError("Invalid API server ID!"); } @@ -26,14 +27,18 @@ export async function createJwksAccessProofToken({ ); } + // The auth server requires exp, iat, jti, aud, and iss claims, and treats + // each jti as single-use, so mint a fresh assertion for every request. const token_builder = new SignJWT({ api_server_id, }) .setSubject(api_server_id) .setIssuer(api_server_id) .setAudience(SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id) + .setJti(crypto.randomUUID()) + .setIssuedAt() .setNotBefore(new Date(Date.now() - 1)) - .setExpirationTime("2 min") + .setExpirationTime(JWKS_ACCESS_PROOF_TOKEN_MAX_AGE) .setProtectedHeader({ alg: sign_verify_alg, }); diff --git a/packages/jwt/src/JwksAccessProofToken/index.ts b/packages/jwt/src/JwksAccessProofToken/index.ts index f524b103..64304a7d 100644 --- a/packages/jwt/src/JwksAccessProofToken/index.ts +++ b/packages/jwt/src/JwksAccessProofToken/index.ts @@ -1,2 +1,6 @@ export { verifyJwksAccessProofToken } from "./verifyJwksAccessProofToken"; export { createJwksAccessProofToken } from "./createJwksAccessProofToken"; +export { + JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS, +} from "./constants"; diff --git a/packages/jwt/src/JwksAccessProofToken/verifyJwksAccessProofToken.ts b/packages/jwt/src/JwksAccessProofToken/verifyJwksAccessProofToken.ts index 64a93ea3..35cf16af 100644 --- a/packages/jwt/src/JwksAccessProofToken/verifyJwksAccessProofToken.ts +++ b/packages/jwt/src/JwksAccessProofToken/verifyJwksAccessProofToken.ts @@ -6,6 +6,10 @@ import { } from "@schemavaults/app-definitions"; import { jwtVerify } from "jose"; import signVerifyAlg, { sign_verify_alg } from "@/jwt/sign_verify_alg"; +import { + JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS, +} from "./constants"; export interface IVerifyJwksAccessProofToken { token: string; @@ -22,7 +26,7 @@ export async function verifyJwksAccessProofToken({ throw new TypeError("Expected token to verify to be a string!"); } - if (!apiServerIdSchema.safeParse(api_server_id)) { + if (!apiServerIdSchema.safeParse(api_server_id).success) { throw new TypeError("Invalid API server ID!"); } @@ -37,6 +41,8 @@ export async function verifyJwksAccessProofToken({ issuer: api_server_id, subject: api_server_id, algorithms: [signVerifyAlg], + maxTokenAge: JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + requiredClaims: [...JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS], }); if (payload.payload.aud !== SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id) { diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index d6278728..e7623da4 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -5,6 +5,8 @@ export type * from "@/jwt"; export { createJwksAccessProofToken, verifyJwksAccessProofToken, + JWKS_ACCESS_PROOF_TOKEN_MAX_AGE, + JWKS_ACCESS_PROOF_TOKEN_REQUIRED_CLAIMS, } from "@/JwksAccessProofToken"; export { isValidBase64UrlEncoding } from "@/utils/isValidBase64UrlEncoding"; diff --git a/packages/trpc-backend-init/package.json b/packages/trpc-backend-init/package.json index 0b10e818..af599070 100644 --- a/packages/trpc-backend-init/package.json +++ b/packages/trpc-backend-init/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/trpc-backend-init", "description": "tRPC Server-side Router Factory", - "version": "0.8.36", + "version": "0.8.37", "private": false, "license": "UNLICENSED", "repository": { diff --git a/tests/e2e-auth-tests/cypress.config.ts b/tests/e2e-auth-tests/cypress.config.ts index d6945d47..c384b77b 100644 --- a/tests/e2e-auth-tests/cypress.config.ts +++ b/tests/e2e-auth-tests/cypress.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from "cypress"; -import { createJwksAccessProofToken, importPKCS8 } from "@schemavaults/jwt"; +import { + createJwksAccessProofToken, + importPKCS8, + SignJWT, + type JWTPayload, +} from "@schemavaults/jwt"; import { NobleCryptoPlugin, ScureBase32Plugin, @@ -43,6 +48,23 @@ export default defineConfig({ private_key: privateKey, }); }, + // Sign a JWKS access assertion with full control over the claim set + // so specs can exercise the auth server's hardened claim validation + // (missing exp, expired, mismatched aud/iss, ...). The production + // mint path (createJwksAccessProofToken) refuses to emit malformed + // assertions, so the bad claims have to be signed here directly. + async signCustomJwksAccessAssertion({ + private_key_pem, + claims, + }: { + private_key_pem: string; + claims: Record; + }): Promise { + const privateKey = await importPKCS8(private_key_pem, "RS256"); + return await new SignJWT(claims as JWTPayload) + .setProtectedHeader({ alg: "RS256" }) + .sign(privateKey); + }, // otplib's HMAC implementation depends on Node's `crypto.createHmac`, // which doesn't exist inside Cypress's browser context. Compute the // TOTP here on the Node side and hand the string back to the spec. diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExternalJwksLoad.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExternalJwksLoad.cy.ts index 0bc9ebcd..5a42dfb3 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExternalJwksLoad.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExternalJwksLoad.cy.ts @@ -1,3 +1,30 @@ +import { SCHEMAVAULTS_AUTH_APP_DEFINITION } from "@schemavaults/app-definitions"; + +// crypto.randomUUID() is unavailable in the spec's browser context (the +// auth server is not served from a secure context in CI), so derive test +// jtis from the clock + Math.random instead. +function freshJti(): string { + return `e2e-jti-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +// Complete claim set matching what createJwksAccessProofToken emits; the +// hardened-validation tests remove or corrupt one claim at a time. +function baselineAssertionClaims( + api_server_id: string, +): Record { + const now = Math.floor(Date.now() / 1000); + return { + api_server_id, + sub: api_server_id, + iss: api_server_id, + aud: SCHEMAVAULTS_AUTH_APP_DEFINITION.app_id, + iat: now, + nbf: now - 1, + exp: now + 60, + jti: freshJti(), + }; +} + describe("External JWKS Load", () => { describe("Authenticated JWKS Access", () => { it("external resource server can load JWKS with valid access token", () => { @@ -162,4 +189,119 @@ describe("External JWKS Load", () => { }); }); }); + + describe("Hardened Assertion Validation", () => { + let api_server_id: string; + let private_key: string; + + // The assertions under test are sent via cy.request (no browser + // session needed), so one API server + access key can be shared by + // every test in this block. + before(() => { + cy.create_and_login_as_superuser().then((success) => { + if (!success) { + throw new Error("Failed to create and login as superuser"); + } + + cy.generate_random_code(8).then((code: string) => { + cy.create_api_server({ + api_server_name: `Test JWKS Hardening ${code}`, + api_server_description: `E2E test for hardened JWKS assertion validation ${code}`, + }).then(({ success, api_server_id: created_api_server_id }) => { + if (!success || !created_api_server_id) { + throw new Error("Failed to create API server"); + } + api_server_id = created_api_server_id; + + cy.generate_jwks_access_key(api_server_id).then( + ({ success, private_key: generated_private_key }) => { + if (!success || !generated_private_key) { + throw new Error("Failed to generate JWKS access key"); + } + private_key = generated_private_key; + }, + ); + }); + }); + }); + }); + + function requestJwks(token: string): Cypress.Chainable> { + return cy.request({ + method: "GET", + url: `/api/jwks/${api_server_id}`, + headers: { + Authorization: `Bearer ${token}`, + }, + failOnStatusCode: false, + }); + } + + function expect401ForClaims(claims: Record): void { + cy.task("signCustomJwksAccessAssertion", { + private_key_pem: private_key, + claims, + }).then((token) => { + if (typeof token !== "string") { + throw new TypeError( + "Expected result of signCustomJwksAccessAssertion task to be a string!", + ); + } + requestJwks(token).then((response) => { + expect(response.status).to.eq(401); + }); + }); + } + + it("returns 401 when a previously-used assertion is replayed", () => { + cy.task("createJwksAccessProofToken", { + api_server_id, + private_key_pem: private_key, + }).then((token) => { + if (typeof token !== "string") { + throw new TypeError( + "Expected result of createJwksAccessProofToken task to be a string!", + ); + } + + requestJwks(token).then((first) => { + expect(first.status, "first use of the assertion").to.eq(200); + + requestJwks(token).then((second) => { + expect(second.status, "replay of the same assertion").to.eq(401); + }); + }); + }); + }); + + it("returns 401 for an assertion missing the exp claim", () => { + const claims = baselineAssertionClaims(api_server_id); + delete claims.exp; + expect401ForClaims(claims); + }); + + it("returns 401 for an expired assertion", () => { + const now = Math.floor(Date.now() / 1000); + expect401ForClaims({ + ...baselineAssertionClaims(api_server_id), + iat: now - 120, + nbf: now - 120, + exp: now - 60, + }); + }); + + it("returns 401 for an assertion with a mismatched aud claim", () => { + expect401ForClaims({ + ...baselineAssertionClaims(api_server_id), + aud: "not-the-schemavaults-auth-server", + }); + }); + + it("returns 401 for an assertion with a mismatched iss claim", () => { + expect401ForClaims({ + ...baselineAssertionClaims(api_server_id), + iss: "00000000-1111-2222-3333-444444444444", + }); + }); + }); }); diff --git a/tests/e2e-auth-tests/package.json b/tests/e2e-auth-tests/package.json index 27a83717..bfcbe0dd 100644 --- a/tests/e2e-auth-tests/package.json +++ b/tests/e2e-auth-tests/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/e2e-auth-tests", - "version": "0.4.6", + "version": "0.4.7", "repository": { "type": "git", "url": "git+https://github.com/schemavaults/auth.git",