Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion auth-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@schemavaults/auth-server",
"version": "0.27.15",
"version": "0.27.16",
"private": true,
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-server-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/jwt/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
226 changes: 213 additions & 13 deletions packages/jwt/src/JwksAccessProofToken/JwksAccessProofToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<TestKeyPair> {
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<void> {
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<string> {
return await new SignJWT(claims)
.setProtectedHeader({ alg: sign_verify_alg })
.sign(privateKey);
}

async function expectVerificationToReject(
token: string,
api_server_id: ApiServerId,
publicKey: CryptoKey,
): Promise<void> {
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(
Expand Down Expand Up @@ -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);
});
});
});
18 changes: 18 additions & 0 deletions packages/jwt/src/JwksAccessProofToken/constants.ts
Original file line number Diff line number Diff line change
@@ -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",
];
Loading
Loading