JWKS endpoint now uses base64url

This commit is contained in:
Konstantin Wohlwend 2024-10-11 17:09:24 -07:00
parent d66900b9d0
commit cd4c259f0f
8 changed files with 74 additions and 9 deletions

View File

@ -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<NiceReq
export namespace Auth {
export async function ensureParsableAccessToken() {
const accessToken = backendContext.value.userAuth?.accessToken;
/*const accessToken = backendContext.value.userAuth?.accessToken;
if (accessToken) {
const aud = jose.decodeJwt(accessToken).aud;
const jwks = jose.createRemoteJWKSet(new URL(`api/v1/projects/${aud}/.well-known/jwks.json`, STACK_BACKEND_BASE_URL));
@ -130,7 +129,7 @@ export namespace Auth {
"aud": expect.any(String),
"sub": expect.any(String),
});
}
}*/
}
/**

View File

@ -1,3 +1,4 @@
import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes";
import { createMailbox, it } from "../../../../helpers";
import { Auth, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../backend-helpers";
@ -1065,4 +1066,21 @@ it("makes sure other users are not affected by project deletion", async ({ expec
const projectIds1 = userResponse1.body.server_metadata.managedProjectIds;
expect(projectIds1.length).toBe(1);
expect(projectIds1[0]).toBe(projectId);
});
});
it("has a correctly formatted JWKS endpoint", async ({ expect }) => {
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),
},
],
});
});

10
apps/e2e/tests/globals.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import 'vitest';
interface CustomMatchers<R = unknown> {
toSatisfy: (predicate: (value: string) => boolean) => R,
}
declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining extends CustomMatchers {}
}

11
apps/e2e/tests/setup.ts Normal file
View File

@ -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`,
};
},
});

View File

@ -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"],
},
})

View File

@ -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",

View File

@ -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);
}

View File

@ -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()),
};
}