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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
107 changes: 49 additions & 58 deletions src/lib/auth/bearer-assertion.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<AuthPrincipal | null> {
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;
}
};
18 changes: 16 additions & 2 deletions test/lib/auth/assertion-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface AssertionOverrides {
sub?: string;
iat?: number;
exp?: number;
iss?: string;
aud?: string;
purpose?: string;
ver?: number;
header?: Record<string, string>;
includeContext?: boolean;
}

/** Mints a compact HS256 assertion matching what bearer-assertion.ts verifies. */
Expand All @@ -23,14 +29,22 @@ export async function signAssertion(
overrides: AssertionOverrides = {}
): Promise<string> {
const iat = overrides.iat ?? Math.floor(Date.now() / 1000);
const payload = {
const payload: Record<string, string | number> = {
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));

Expand Down
124 changes: 121 additions & 3 deletions test/lib/auth/bearer-assertion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
};
}
Expand Down Expand Up @@ -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",
Expand Down
Loading