From 1b9f1c075500f1ee1a91697ba6d13513bfadaa50 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 31 Jul 2026 14:11:39 +0200 Subject: [PATCH] feat: add RFC 8414 issuer discovery Signed-off-by: Filip Skokan --- .../decoder/services/public-key.service.ts | 112 +++++++++-- tests/public-key.service.test.ts | 176 ++++++++++++++++++ 2 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 tests/public-key.service.test.ts diff --git a/src/features/decoder/services/public-key.service.ts b/src/features/decoder/services/public-key.service.ts index c5797adb..ac8c9269 100644 --- a/src/features/decoder/services/public-key.service.ts +++ b/src/features/decoder/services/public-key.service.ts @@ -79,6 +79,43 @@ const safeCreateUrl = (url: string): Result => { } }; +const safeCreateIssuerUrl = (issuer: string): Result => { + const safeCreateUrlResult = safeCreateUrl(issuer); + + if (safeCreateUrlResult.isErr()) { + return err(safeCreateUrlResult.error); + } + + const issuerUrl = safeCreateUrlResult.value; + + if (issuerUrl.protocol !== "https:" || issuerUrl.search || issuerUrl.hash) { + return err("Invalid issuer URL"); + } + + return ok(issuerUrl); +}; + +const getAuthorizationServerMetadataUrl = (issuerUrl: URL): URL => { + const metadataUrl = new URL(issuerUrl.origin); + const issuerPath = + issuerUrl.pathname === "/" ? "" : issuerUrl.pathname.replace(/\/+$/, ""); + + metadataUrl.pathname = `/.well-known/oauth-authorization-server${issuerPath}`; + + return metadataUrl; +}; + +const getOpenIdConfigurationUrl = (issuerUrl: URL): URL => { + const metadataUrl = new URL(issuerUrl); + + metadataUrl.pathname = `${metadataUrl.pathname.replace( + /\/+$/, + "", + )}/.well-known/openid-configuration`; + + return metadataUrl; +}; + const safeExportJWK = (key: CryptoKey | Uint8Array) => { return fromPromise(exportJWK(key), (e) => { console.error(e); @@ -140,27 +177,23 @@ const safeFetch = (url: string) => { }); }; -const safeFetchPublicKeyFromJwtIssuer = async ( - payload: DecodedJwtPayloadModel, +const getPublicKeyFromMetadata = async ( + issuer: string, header: DecodedJwtHeaderModel, + metadataUrl: URL, ): Promise> => { - if (!payload.iss) { - return err(`Payload does not have an "iss" claim.`); - } - - const url = - payload.iss + - (payload.iss.charAt(payload.iss.length - 1) === "/" - ? ".well-known/openid-configuration" - : "/.well-known/openid-configuration"); - - const fetchResult = await safeFetch(url); + const fetchResult = await safeFetch(metadataUrl.href); if (fetchResult.isErr()) { return err(fetchResult.error); } const response = fetchResult.value; + + if (response.status !== 200) { + return err(`Unable to fetch metadata from URL: ${metadataUrl.href}`); + } + const text = await response.text(); const safeJsonParseResult = safeJsonParse(text); @@ -171,13 +204,28 @@ const safeFetchPublicKeyFromJwtIssuer = async ( const data = safeJsonParseResult.value; + if (data?.issuer !== issuer) { + return err( + `Issuer metadata from URL ${metadataUrl.href} does not match ${issuer}`, + ); + } + if (!data || !data.jwks_uri || typeof data.jwks_uri !== "string") { - return err(`Could not get jwks_uri from URL: ${url}`); + return err(`Could not get jwks_uri from URL: ${metadataUrl.href}`); + } + + const safeCreateJwksUrlResult = safeCreateUrl(data.jwks_uri); + + if ( + safeCreateJwksUrlResult.isErr() || + safeCreateJwksUrlResult.value.protocol !== "https:" + ) { + return err(`Invalid jwks_uri from URL: ${metadataUrl.href}`); } const getKeyFromJwkKeySetUrlResult = await getKeyFromJwkKeySetUrl( header, - data.jwks_uri, + safeCreateJwksUrlResult.value.href, ); if (getKeyFromJwkKeySetUrlResult.isErr()) { @@ -187,6 +235,38 @@ const safeFetchPublicKeyFromJwtIssuer = async ( return ok(getKeyFromJwkKeySetUrlResult.value); }; +const safeFetchPublicKeyFromJwtIssuer = async ( + payload: DecodedJwtPayloadModel, + header: DecodedJwtHeaderModel, +): Promise> => { + if (!payload.iss) { + return err(`Payload does not have an "iss" claim.`); + } + + const safeCreateIssuerUrlResult = safeCreateIssuerUrl(payload.iss); + + if (safeCreateIssuerUrlResult.isErr()) { + return err(safeCreateIssuerUrlResult.error); + } + + const issuerUrl = safeCreateIssuerUrlResult.value; + const authorizationServerMetadataResult = await getPublicKeyFromMetadata( + payload.iss, + header, + getAuthorizationServerMetadataUrl(issuerUrl), + ); + + if (authorizationServerMetadataResult.isOk()) { + return ok(authorizationServerMetadataResult.value); + } + + return getPublicKeyFromMetadata( + payload.iss, + header, + getOpenIdConfigurationUrl(issuerUrl), + ); +}; + export async function downloadPublicKeyIfPossible( decodedToken: DecodedTokenModel, ): Promise> { @@ -257,7 +337,7 @@ export async function downloadPublicKeyIfPossible( if (typeof payload === "object" && "iss" in payload && payload.iss) { const invalidIssuerUrlErrorMessage = `Unable to retrieve public key from issuer (iss) '${payload.iss}'. Expected a valid HTTPS URL. Please enter public key manually to verify the JWT signature.`; - const safeCreateUrlResult = safeCreateUrl(payload.iss); + const safeCreateUrlResult = safeCreateIssuerUrl(payload.iss); if (safeCreateUrlResult.isErr()) { console.error(safeCreateUrlResult.error); diff --git a/tests/public-key.service.test.ts b/tests/public-key.service.test.ts new file mode 100644 index 00000000..1ccbe316 --- /dev/null +++ b/tests/public-key.service.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { downloadPublicKeyIfPossible } from "@/features/decoder/services/public-key.service"; +import { DebuggerInputValues } from "@/features/common/values/debugger-input.values"; + +const joseMocks = vi.hoisted(() => { + const resolveRemoteJwk = vi.fn(async () => ({})); + + return { + createRemoteJWKSet: vi.fn(() => resolveRemoteJwk), + exportJWK: vi.fn(async () => ({ + kty: "RSA", + n: "public-key", + e: "AQAB", + })), + resolveRemoteJwk, + }; +}); + +vi.mock("jose", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + createRemoteJWKSet: joseMocks.createRemoteJWKSet, + exportJWK: joseMocks.exportJWK, + }; +}); + +const response = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }) as Response; + +describe("downloadPublicKeyIfPossible issuer discovery", () => { + const header = { alg: "RS256", kid: "signing-key" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("uses RFC 8414 discovery and inserts it before the issuer path", async () => { + const issuer = "https://issuer.example/tenant/"; + const fetchMock = vi.fn(async () => + response({ + issuer, + jwks_uri: "https://issuer.example/tenant/jwks", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await downloadPublicKeyIfPossible({ + header, + payload: { iss: issuer }, + errors: false, + warnings: [], + }); + + expect(result.isOk()).toBe(true); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://issuer.example/.well-known/oauth-authorization-server/tenant", + ); + expect(joseMocks.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://issuer.example/tenant/jwks"), + ); + expect(joseMocks.resolveRemoteJwk).toHaveBeenCalledWith(header); + }); + + test("falls back to OpenID discovery after RFC 8414 discovery fails", async () => { + const issuer = "https://issuer.example/tenant"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(response("Not Found", 404)) + .mockResolvedValueOnce( + response({ + issuer, + jwks_uri: "https://issuer.example/openid/jwks", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await downloadPublicKeyIfPossible({ + header, + payload: { iss: issuer }, + errors: false, + warnings: [], + }); + + expect(result.isOk()).toBe(true); + expect(fetchMock.mock.calls).toEqual([ + ["https://issuer.example/.well-known/oauth-authorization-server/tenant"], + ["https://issuer.example/tenant/.well-known/openid-configuration"], + ]); + expect(joseMocks.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://issuer.example/openid/jwks"), + ); + }); + + test("does not use RFC 8414 metadata with a mismatched issuer", async () => { + const issuer = "https://issuer.example"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + response({ + issuer: "https://attacker.example", + jwks_uri: "https://attacker.example/jwks", + }), + ) + .mockResolvedValueOnce( + response({ + issuer, + jwks_uri: "https://issuer.example/jwks", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await downloadPublicKeyIfPossible({ + header, + payload: { iss: issuer }, + errors: false, + warnings: [], + }); + + expect(result.isOk()).toBe(true); + expect(joseMocks.createRemoteJWKSet).toHaveBeenCalledOnce(); + expect(joseMocks.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://issuer.example/jwks"), + ); + }); + + test("does not use OpenID metadata with a mismatched issuer", async () => { + const issuer = "https://issuer.example"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(response("Not Found", 404)) + .mockResolvedValueOnce( + response({ + issuer: "https://attacker.example", + jwks_uri: "https://attacker.example/jwks", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await downloadPublicKeyIfPossible({ + header, + payload: { iss: issuer }, + errors: false, + warnings: [], + }); + + expect(result.isErr()).toBe(true); + expect(joseMocks.createRemoteJWKSet).not.toHaveBeenCalled(); + }); + + test("requires an HTTPS issuer URL", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await downloadPublicKeyIfPossible({ + header, + payload: { iss: "http://issuer.example" }, + errors: false, + warnings: [], + }); + + expect(result.isErr()).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result._unsafeUnwrapErr().input).toBe(DebuggerInputValues.JWT); + }); +});