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
9 changes: 7 additions & 2 deletions pm-stack/dispatch/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,17 @@ function b64urlDecode(s: string): Uint8Array {
return new Uint8Array(Buffer.from(b64, "base64"));
}

function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function parseJwt(token: string): JwtParts | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0]!)));
const payload = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1]!)));
const header: unknown = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0]!)));
const payload: unknown = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1]!)));
if (!isJsonObject(header) || !isJsonObject(payload)) return null;
return {
header,
payload,
Expand Down
27 changes: 27 additions & 0 deletions pm-stack/dispatch/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AuthError,
assertCallerRepo,
authenticate,
parseJwt,
timingSafeStringEqual,
verifyOidcToken,
type Jwks,
Expand All @@ -30,6 +31,10 @@ function signJwt(claims: Record<string, unknown>): string {
return `${header}.${payload}.${b64url(signer.sign(privateKey))}`;
}

function jwtWithSegments(header: unknown, payload: unknown): string {
return `${b64url(JSON.stringify(header)!)}.${b64url(JSON.stringify(payload)!)}.signature`;
}

const NOW = 1_800_000_000_000; // fixed "now" for claim checks
const baseClaims = {
iss: "https://token.actions.githubusercontent.com",
Expand Down Expand Up @@ -77,6 +82,28 @@ describe("bearer auth via authenticate", () => {
});

describe("OIDC verification", () => {
test("rejects non-object JWT header and payload segments as malformed credentials", async () => {
const header = { alg: "RS256", typ: "JWT", kid: "test-kid-1" };
const cases: Array<[string, unknown, unknown]> = [
["null header", null, baseClaims],
["array header", [], baseClaims],
["number header", 42, baseClaims],
["string header", "header", baseClaims],
["boolean header", true, baseClaims],
["null payload", header, null],
["array payload", header, [baseClaims]],
["number payload", header, 42],
["string payload", header, "payload"],
["boolean payload", header, true],
];

for (const [name, tokenHeader, payload] of cases) {
const token = jwtWithSegments(tokenHeader, payload);
expect(parseJwt(token), name).toBeNull();
await expect(verifyOidcToken(token, oidcOpts), name).rejects.toBeInstanceOf(AuthError);
}
});

test("accepts a well-formed token and returns the identity", async () => {
const id = await verifyOidcToken(signJwt(baseClaims), oidcOpts);
expect(id.type).toBe("oidc");
Expand Down
Loading