Skip to content
Merged
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
147 changes: 147 additions & 0 deletions identity-webhook/verifiers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@ import {
normalizeTokenExchangeBaseUrl,
splitCompositeApiKey,
} from "./verifiers.mjs";
import { handleAuthorize } from "./protocol.mjs";

const ISSUER = "http://identity-webhook:8090";

function stripTrailingSlashes(value) {
let end = value.length;
while (end > 0 && value[end - 1] === "/") {
end -= 1;
}
return value.slice(0, end);
}

describe("discoverJwksUri", () => {
it("reads jwks_uri from issuer-relative openid-configuration", async () => {
const seen = [];
Expand Down Expand Up @@ -462,3 +471,141 @@ describe("createOidcVerifier composite API key exchange", () => {
);
});
});

describe("embedded OIDC issuer flow (handleAuthorize + createOidcVerifier)", () => {
const ISSUER_URL = "https://issuer.example/api/v1/oidc";
const SECRET = "dev-webhook-secret";

async function setupIssuerJwt() {
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.kid = "issuer-test";
jwk.alg = "RS256";
jwk.use = "sig";
const jwks = createLocalJWKSet({ keys: [jwk] });
const audience = stripTrailingSlashes(ISSUER_URL);
const token = await new SignJWT({
client_id: "app_3b386c81a1db1169fd2c3986",
external_user_id: "user-456",
scope: "sign:job",
})
.setProtectedHeader({ alg: "RS256", kid: "issuer-test" })
.setIssuer(ISSUER_URL)
.setAudience(audience)
.setIssuedAt()
.setExpirationTime("5m")
.sign(privateKey);
return { token, jwks, audience };
}

function verifierFor(jwks, audience, extras = {}) {
return createOidcVerifier({
jwtIssuer: ISSUER_URL,
jwtAudience: audience,
issuer: ISSUER_URL,
jwks,
clientClaim: "client_id",
subjectClaim: "external_user_id",
subjectTypeValue: "external_user_id",
requiredScopes: ["sign:job"],
...extras,
});
}

function authorizeRequestFor(token) {
return new Request("http://localhost/webhooks/remote-signer", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "application/json",
},
body: JSON.stringify({ headers: { Authorization: [`Bearer ${token}`] } }),
});
}

it("authorizes an issuer JWT end-to-end", async () => {
const { token, jwks, audience } = await setupIssuerJwt();
const config = {
webhookSecret: SECRET,
endUserAuth: verifierFor(jwks, audience),
};

const response = await handleAuthorize(authorizeRequestFor(token), config);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 200);
assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456");
assert.equal(body.identity.client_id, "app_3b386c81a1db1169fd2c3986");
assert.equal(body.identity.usage_subject, "user-456");
assert.equal(body.identity.usage_subject_type, "external_user_id");
});

it("authorizes a composite app_*_* via mocked exchange", async () => {
const { token, jwks, audience } = await setupIssuerJwt();
const clientId = "app_3b386c81a1db1169fd2c3986";
const fetchImpl = async (input) => {
const url = String(input);
assert.ok(url.includes(`/api/v1/apps/${clientId}/oidc/token`));
return Response.json({ access_token: token, expires_in: 300 });
};
const config = {
webhookSecret: SECRET,
endUserAuth: verifierFor(jwks, audience, {
tokenExchangeBaseUrl: "http://localhost:3000",
fetchImpl,
}),
};

const response = await handleAuthorize(
authorizeRequestFor(`${clientId}_deadbeef`),
config,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 200);
assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456");
});

it("rejects a verified JWT with 483 when the live balance gate sees zero credit", async () => {
const { token, jwks, audience } = await setupIssuerJwt();
const { createBalanceGate } = await import("./balance-gate.mjs");
const config = {
webhookSecret: SECRET,
endUserAuth: verifierFor(jwks, audience),
checkBalance: createBalanceGate({ getBalanceUsdMicros: async () => 0n }),
};

const response = await handleAuthorize(authorizeRequestFor(token), config);
assert.equal(response.status, 200); // HTTP 200 per go-livepeer contract
const body = await response.json();
assert.equal(body.status, 483);
assert.equal(body.code, "insufficient_balance");
});

it("authorizes a verified JWT and caps expiry when the live balance gate sees credit", async () => {
const { token, jwks, audience } = await setupIssuerJwt();
const { createBalanceGate } = await import("./balance-gate.mjs");
let seenIdentity;
const config = {
webhookSecret: SECRET,
endUserAuth: verifierFor(jwks, audience),
checkBalance: createBalanceGate({
getBalanceUsdMicros: async (identity) => {
seenIdentity = identity;
return 5_000_000n;
},
reauthTtlSeconds: 30,
}),
};

const before = Math.floor(Date.now() / 1000);
const response = await handleAuthorize(authorizeRequestFor(token), config);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 200);
assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456");
assert.equal(seenIdentity.client_id, "app_3b386c81a1db1169fd2c3986");
assert.equal(seenIdentity.usage_subject, "user-456");
assert.ok(body.expiry >= before + 30 && body.expiry <= before + 31);
});
});