diff --git a/README.md b/README.md index 25d53a1..9938862 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,21 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d - `ASSERTION_SIGNING_SECRET`: Shared HMAC secret used to verify the short-lived bearer assertions minted by the Extensions site. Configure the same value in both Workers; it is never sent to clients. +- `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted + during a signing-key rotation. Remove it after the new secret has been active + for at least 65 seconds and all in-flight assertions have expired. + +Extensions assertions use HS256 and include the exact issuer +`fossbilling-extensions`, audience `fossbilling-api/extensions-v2`, purpose +`user-authentication`, and protocol version `1`. Assertions are valid for at +most 60 seconds; the previous secret is accepted only as a temporary rotation +window. + +To rotate the shared secret without interrupting requests, first set the API's +`ASSERTION_SIGNING_SECRET_PREVIOUS` to the current value, then replace the API's +active `ASSERTION_SIGNING_SECRET`, and finally replace the Extensions site's +active secret. After at least 65 seconds, verify requests and remove the API +previous secret. ## Development @@ -89,6 +104,8 @@ npm install ```env GITHUB_TOKEN="your-token" ASSERTION_SIGNING_SECRET="local-shared-secret" + # Optional while rotating the shared assertion secret. + # ASSERTION_SIGNING_SECRET_PREVIOUS="previous-local-shared-secret" ``` 2. Apply migrations to the local D1 databases: diff --git a/src/lib/auth/bearer-assertion.ts b/src/lib/auth/bearer-assertion.ts index 9129c0a..c819218 100644 --- a/src/lib/auth/bearer-assertion.ts +++ b/src/lib/auth/bearer-assertion.ts @@ -1,25 +1,33 @@ +import { verify as verifyJwt } from "hono/jwt"; import { AuthPrincipal, TokenVerifier } from "./interfaces"; const CLOCK_SKEW_SECONDS = 5; +const ASSERTION_TTL_SECONDS = 60; +const ASSERTION_ISSUER = "fossbilling-extensions"; +const ASSERTION_AUDIENCE = "fossbilling-api/extensions-v2"; +const ASSERTION_PURPOSE = "user-authentication"; +const ASSERTION_VERSION = 1; + +const ASSERTION_VERIFY_OPTIONS = { + alg: "HS256", + aud: ASSERTION_AUDIENCE, + exp: true, + iat: false, + iss: ASSERTION_ISSUER +} as const; interface AssertionPayload { sub: string; iat: number; exp: number; + iss: typeof ASSERTION_ISSUER; + aud: typeof ASSERTION_AUDIENCE; + purpose: typeof ASSERTION_PURPOSE; + ver: typeof ASSERTION_VERSION; } -function base64UrlDecode(input: string): Uint8Array { - const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); - const padded = - normalized.length % 4 === 0 - ? normalized - : normalized + "=".repeat(4 - (normalized.length % 4)); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; +function isInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value); } function isAssertionPayload(value: unknown): value is AssertionPayload { @@ -28,61 +36,44 @@ function isAssertionPayload(value: unknown): value is AssertionPayload { return ( typeof record.sub === "string" && record.sub.length > 0 && - typeof record.iat === "number" && - Number.isFinite(record.iat) && - typeof record.exp === "number" && - Number.isFinite(record.exp) + isInteger(record.iat) && + isInteger(record.exp) && + record.iss === ASSERTION_ISSUER && + record.aud === ASSERTION_AUDIENCE && + record.purpose === ASSERTION_PURPOSE && + record.ver === ASSERTION_VERSION ); } -// Verifies a compact HS256 assertion (header.payload.signature). The header -// is never parsed or trusted to pick the algorithm, which avoids alg-confusion. +// Verifies the Extensions site's compact HS256 assertion +// (header.payload.signature). Hono performs JWT parsing and signature +// verification with the algorithm pinned by ASSERTION_VERIFY_OPTIONS; the +// checks below are specific to this assertion profile. export const bearerAssertionVerifier: TokenVerifier = { async verify(token, platform): Promise { - const secret = platform.getEnv("ASSERTION_SIGNING_SECRET"); - if (!secret) return null; + const secrets = [ + platform.getEnv("ASSERTION_SIGNING_SECRET"), + platform.getEnv("ASSERTION_SIGNING_SECRET_PREVIOUS") + ].filter((secret): secret is string => Boolean(secret)); + if (secrets.length === 0) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - const [headerB64, payloadB64, signatureB64] = parts; + for (const secret of secrets) { + let payload: unknown; + try { + payload = await verifyJwt(token, secret, ASSERTION_VERIFY_OPTIONS); + } catch { + continue; + } + if (!isAssertionPayload(payload)) continue; - let payload: unknown; - try { - payload = JSON.parse( - new TextDecoder().decode(base64UrlDecode(payloadB64)) - ); - } catch { - return null; - } - if (!isAssertionPayload(payload)) return null; + const now = Math.floor(Date.now() / 1000); + if (payload.iat > now + CLOCK_SKEW_SECONDS) continue; + if (payload.exp <= payload.iat) continue; + if (payload.exp - payload.iat > ASSERTION_TTL_SECONDS) continue; - let signature: Uint8Array; - try { - signature = base64UrlDecode(signatureB64); - } catch { - return null; + return { userId: payload.sub, scope: "assertion" }; } - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"] - ); - - const valid = await crypto.subtle.verify( - "HMAC", - key, - signature, - new TextEncoder().encode(`${headerB64}.${payloadB64}`) - ); - if (!valid) return null; - - const now = Math.floor(Date.now() / 1000); - if (payload.exp <= now) return null; - if (payload.iat > now + CLOCK_SKEW_SECONDS) return null; - - return { userId: payload.sub, scope: "assertion" }; + return null; } }; diff --git a/test/lib/auth/assertion-helper.ts b/test/lib/auth/assertion-helper.ts index 522ea19..d39cca0 100644 --- a/test/lib/auth/assertion-helper.ts +++ b/test/lib/auth/assertion-helper.ts @@ -15,6 +15,12 @@ export interface AssertionOverrides { sub?: string; iat?: number; exp?: number; + iss?: string; + aud?: string; + purpose?: string; + ver?: number; + header?: Record; + includeContext?: boolean; } /** Mints a compact HS256 assertion matching what bearer-assertion.ts verifies. */ @@ -23,14 +29,22 @@ export async function signAssertion( overrides: AssertionOverrides = {} ): Promise { const iat = overrides.iat ?? Math.floor(Date.now() / 1000); - const payload = { + const payload: Record = { sub: overrides.sub ?? "user-1", iat, exp: overrides.exp ?? iat + 60 }; + if (overrides.includeContext !== false) { + Object.assign(payload, { + iss: overrides.iss ?? "fossbilling-extensions", + aud: overrides.aud ?? "fossbilling-api/extensions-v2", + purpose: overrides.purpose ?? "user-authentication", + ver: overrides.ver ?? 1 + }); + } const headerB64 = base64UrlEncodeString( - JSON.stringify({ alg: "HS256", typ: "JWT" }) + JSON.stringify(overrides.header ?? { alg: "HS256", typ: "JWT" }) ); const payloadB64 = base64UrlEncodeString(JSON.stringify(payload)); diff --git a/test/lib/auth/bearer-assertion.test.ts b/test/lib/auth/bearer-assertion.test.ts index 060ec5f..3aba88d 100644 --- a/test/lib/auth/bearer-assertion.test.ts +++ b/test/lib/auth/bearer-assertion.test.ts @@ -5,13 +5,19 @@ import { base64UrlEncodeString, signAssertion } from "./assertion-helper"; const SECRET = "test-secret"; -function platformWithSecret(secret: string | undefined): PlatformContext { +function platformWithSecret( + secret: string | undefined, + previousSecret?: string +): PlatformContext { return { getCache: () => { throw new Error("not implemented"); }, - getEnv: (key: string) => - key === "ASSERTION_SIGNING_SECRET" ? secret : undefined, + getEnv: (key: string) => { + if (key === "ASSERTION_SIGNING_SECRET") return secret; + if (key === "ASSERTION_SIGNING_SECRET_PREVIOUS") return previousSecret; + return undefined; + }, raw: undefined as unknown as PlatformContext["raw"] }; } @@ -52,6 +58,118 @@ describe("bearerAssertionVerifier", () => { expect(principal).toBeNull(); }); + it("accepts a token signed with the previous secret during rotation", async () => { + const token = await signAssertion("previous-secret"); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET, "previous-secret") + ); + + expect(principal).toEqual({ userId: "user-1", scope: "assertion" }); + }); + + it("rejects a token signed with the previous secret when it is not configured", async () => { + const token = await signAssertion("previous-secret"); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + + it.each([ + ["issuer", { iss: "wrong-issuer" }], + ["audience", { aud: "wrong-audience" }], + ["purpose", { purpose: "wrong-purpose" }], + ["version", { ver: 2 }] + ])("rejects a token with a wrong %s claim", async (_name, overrides) => { + const token = await signAssertion(SECRET, overrides); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + + it.each(["iat", "exp"])( + "rejects fractional %s NumericDate values", + async (claim) => { + const now = Math.floor(Date.now() / 1000); + const overrides = + claim === "iat" + ? { iat: now + 0.5, exp: now + 60 } + : { iat: now, exp: now + 59.5 }; + const token = await signAssertion(SECRET, { + iat: overrides.iat, + exp: overrides.exp + }); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + } + ); + + it.each([ + ["zero", 0], + ["negative", -1], + ["overlong", 61] + ])("rejects a token with a %s lifetime", async (_name, lifetime) => { + const now = Math.floor(Date.now() / 1000); + const token = await signAssertion(SECRET, { + iat: now, + exp: now + lifetime + }); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + + it("rejects a token issued too far in the future", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await signAssertion(SECRET, { + iat: now + 6, + exp: now + 66 + }); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + + it("rejects a token that declares a different algorithm", async () => { + const token = await signAssertion(SECRET, { + header: { alg: "HS384", typ: "JWT" } + }); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + + it("rejects a legacy token without contextual claims", async () => { + const token = await signAssertion(SECRET, { + includeContext: false + }); + const principal = await bearerAssertionVerifier.verify( + token, + platformWithSecret(SECRET) + ); + + expect(principal).toBeNull(); + }); + it("rejects a malformed token", async () => { const principal = await bearerAssertionVerifier.verify( "not-a-jwt",