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
112 changes: 96 additions & 16 deletions src/features/decoder/services/public-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ const safeCreateUrl = (url: string): Result<URL, string> => {
}
};

const safeCreateIssuerUrl = (issuer: string): Result<URL, string> => {
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);
Expand Down Expand Up @@ -140,27 +177,23 @@ const safeFetch = (url: string) => {
});
};

const safeFetchPublicKeyFromJwtIssuer = async (
payload: DecodedJwtPayloadModel,
const getPublicKeyFromMetadata = async (
issuer: string,
header: DecodedJwtHeaderModel,
metadataUrl: URL,
): Promise<Result<string, string>> => {
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);
Expand All @@ -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()) {
Expand All @@ -187,6 +235,38 @@ const safeFetchPublicKeyFromJwtIssuer = async (
return ok(getKeyFromJwkKeySetUrlResult.value);
};

const safeFetchPublicKeyFromJwtIssuer = async (
payload: DecodedJwtPayloadModel,
header: DecodedJwtHeaderModel,
): Promise<Result<string, string>> => {
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<Result<string, DebuggerErrorModel>> {
Expand Down Expand Up @@ -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);
Expand Down
176 changes: 176 additions & 0 deletions tests/public-key.service.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("jose")>();

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