From cd4c259f0f25fbea9674831312024ec2df0abe90 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 11 Oct 2024 17:09:24 -0700 Subject: [PATCH] JWKS endpoint now uses base64url --- apps/e2e/tests/backend/backend-helpers.ts | 5 ++-- .../backend/endpoints/api/v1/projects.test.ts | 20 +++++++++++++++- apps/e2e/tests/globals.d.ts | 10 ++++++++ apps/e2e/tests/setup.ts | 11 +++++++++ apps/e2e/vitest.config.ts | 5 +++- package.json | 1 + packages/stack-shared/src/utils/bytes.tsx | 23 +++++++++++++++++++ packages/stack-shared/src/utils/jwt.tsx | 8 +++---- 8 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 apps/e2e/tests/globals.d.ts create mode 100644 apps/e2e/tests/setup.ts diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 828d6e463..8a54e3d94 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -3,7 +3,6 @@ import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; -import * as jose from "jose"; import { expect } from "vitest"; import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, createMailbox, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers"; @@ -118,7 +117,7 @@ export async function niceBackendFetch(url: string | URL, options?: Omit { + const response = await niceBackendFetch("/api/v1/projects/internal/.well-known/jwks.json"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).includes("application/json"); + expect(response.body).toEqual({ + keys: [ + { + crv: "P-256", + kid: expect.any(String), + kty: "EC", + x: expect.toSatisfy(isBase64Url), + y: expect.toSatisfy(isBase64Url), + }, + ], + }); +}); diff --git a/apps/e2e/tests/globals.d.ts b/apps/e2e/tests/globals.d.ts new file mode 100644 index 000000000..f3ad23e39 --- /dev/null +++ b/apps/e2e/tests/globals.d.ts @@ -0,0 +1,10 @@ +import 'vitest'; + +interface CustomMatchers { + toSatisfy: (predicate: (value: string) => boolean) => R, +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/apps/e2e/tests/setup.ts b/apps/e2e/tests/setup.ts new file mode 100644 index 000000000..b65029ac4 --- /dev/null +++ b/apps/e2e/tests/setup.ts @@ -0,0 +1,11 @@ +import { expect } from "vitest"; + + +expect.extend({ + toSatisfy(received: string, predicate: (value: string) => boolean) { + return { + pass: predicate(received), + message: () => `${received} does not satisfy predicate`, + }; + }, +}); diff --git a/apps/e2e/vitest.config.ts b/apps/e2e/vitest.config.ts index 08573ca22..7d3364c05 100644 --- a/apps/e2e/vitest.config.ts +++ b/apps/e2e/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], @@ -7,6 +7,9 @@ export default defineConfig({ environment: 'node', testTimeout: 20_000, globalSetup: './tests/global-setup.ts', + setupFiles: [ + "./tests/setup.ts", + ], snapshotSerializers: ["./tests/snapshot-serializer.ts"], }, }) diff --git a/package.json b/package.json index caafe4e22..41f8bc445 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "start-deps:no-delay": "pnpm run deps-compose up --detach && sleep 5 && pnpm run init-db && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", "start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-20} pnpm run start-deps:no-delay", "restart-deps": "pnpm run stop-deps && pnpm run start-deps", + "restart-deps:no-delay": "pnpm run stop-deps && pnpm run start-deps:no-delay", "psql": "only-allow pnpm && pnpm run --filter=@stackframe/stack-backend psql", "prisma": "only-allow pnpm && pnpm run --filter=@stackframe/stack-backend prisma", "fern": "only-allow pnpm && pnpm run --filter=@stackframe/docs fern", diff --git a/packages/stack-shared/src/utils/bytes.tsx b/packages/stack-shared/src/utils/bytes.tsx index 933dddb69..e731aff93 100644 --- a/packages/stack-shared/src/utils/bytes.tsx +++ b/packages/stack-shared/src/utils/bytes.tsx @@ -79,6 +79,24 @@ export function decodeBase64(input: string): Uint8Array { return new Uint8Array(atob(input).split("").map((char) => char.charCodeAt(0))); } +export function encodeBase64Url(input: Uint8Array): string { + const res = encodeBase64(input).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + + // sanity check + if (!isBase64Url(res)) { + throw new StackAssertionError("Invalid base64url output; this should never happen"); + } + return res; +} + +export function decodeBase64Url(input: string): Uint8Array { + if (!isBase64Url(input)) { + throw new StackAssertionError("Invalid base64url string"); + } + + return decodeBase64(input.replace(/-/g, "+").replace(/_/g, "/") + "====".slice((input.length - 1) % 4 + 1)); +} + export function isBase32(input: string): boolean { for (const char of input) { if (char === " ") continue; @@ -93,3 +111,8 @@ export function isBase64(input: string): boolean { const regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; return regex.test(input); } + +export function isBase64Url(input: string): boolean { + const regex = /^[0-9a-zA-Z_-]+$/; + return regex.test(input); +} diff --git a/packages/stack-shared/src/utils/jwt.tsx b/packages/stack-shared/src/utils/jwt.tsx index 052f72e88..22e9cf44e 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -1,6 +1,6 @@ import elliptic from "elliptic"; import * as jose from "jose"; -import { encodeBase64 } from "./bytes"; +import { encodeBase64, encodeBase64Url } from "./bytes"; import { getEnvVariable } from "./env"; import { globalVar } from "./globals"; import { pick } from "./objects"; @@ -75,9 +75,9 @@ export async function getPrivateJwk(secret: string) { return { kty: 'EC', crv: 'P-256', - d: encodeBase64(priv), - x: encodeBase64(publicKey.getX().toBuffer()), - y: encodeBase64(publicKey.getY().toBuffer()), + d: encodeBase64Url(priv), + x: encodeBase64Url(publicKey.getX().toBuffer()), + y: encodeBase64Url(publicKey.getY().toBuffer()), }; }