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
2 changes: 2 additions & 0 deletions src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ app.all("/*", (c) => {
});

export default app;

export { PreviewGitHubBudget } from "../lib/adapters/cloudflare/preview-github-budget";
49 changes: 49 additions & 0 deletions src/lib/adapters/cloudflare/preview-github-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DurableObject } from "cloudflare:workers";

// One singleton for all preview traffic, independent of URL, client and colo.
// Token buckets permit 60 calls in a burst, then 1000/hour globally and
// 120/hour per client. Every upstream call (not just each lookup) costs one.
export class PreviewGitHubBudget extends DurableObject<CloudflareBindings> {
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env);
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS buckets (
key TEXT PRIMARY KEY, tokens REAL NOT NULL, updated_at INTEGER NOT NULL
)`);
}

reserve(client: string): boolean {
const now = Date.now();
return this.ctx.storage.transactionSync(() => {
// A client bucket is full after 30 minutes; discard only older state.
this.ctx.storage.sql.exec(
"DELETE FROM buckets WHERE key != 'global' AND updated_at < ?",
now - 1800000
);
const buckets = [
{ key: "global", rate: 1000 / 3600000 },
{ key: `client:${client}`, rate: 120 / 3600000 }
].map(({ key, rate }) => {
const row = this.ctx.storage.sql
.exec<{ tokens: number; updated_at: number }>(
"SELECT tokens, updated_at FROM buckets WHERE key = ?",
key
)
.toArray()[0];
const tokens = row
? Math.min(60, row.tokens + Math.max(0, now - row.updated_at) * rate)
: 60;
return { key, tokens };
});
if (buckets.some(({ tokens }) => tokens < 1)) return false;
for (const { key, tokens } of buckets) {
this.ctx.storage.sql.exec(
"INSERT OR REPLACE INTO buckets (key, tokens, updated_at) VALUES (?, ?, ?)",
key,
tokens - 1,
now
);
}
return true;
});
}
}
110 changes: 71 additions & 39 deletions src/lib/auth/bearer-assertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,19 @@ interface AssertionPayload {
exp: number;
iss: typeof ASSERTION_ISSUER;
aud: typeof ASSERTION_AUDIENCE;
purpose: typeof ASSERTION_PURPOSE;
purpose: typeof ASSERTION_PURPOSE | "identity-sync";
body_sha256?: string;
ver: typeof ASSERTION_VERSION;
}

function isInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value);
}

function isAssertionPayload(value: unknown): value is AssertionPayload {
function isAssertionPayload(
value: unknown,
purpose: AssertionPayload["purpose"]
): value is AssertionPayload {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
return (
Expand All @@ -41,7 +45,10 @@ function isAssertionPayload(value: unknown): value is AssertionPayload {
isInteger(record.exp) &&
record.iss === ASSERTION_ISSUER &&
record.aud === ASSERTION_AUDIENCE &&
record.purpose === ASSERTION_PURPOSE &&
record.purpose === purpose &&
(purpose !== "identity-sync" ||
(typeof record.body_sha256 === "string" &&
/^[a-f0-9]{64}$/.test(record.body_sha256))) &&
record.ver === ASSERTION_VERSION
);
}
Expand Down Expand Up @@ -77,44 +84,69 @@ function importedKeyFor(secret: string): Promise<CryptoKey> {
// (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 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;
function assertionVerifier(
purpose: AssertionPayload["purpose"]
): TokenVerifier {
// The sibling purpose's verifier runs alongside this one (see
// requireIdentitySync): an assertion minted for it is expected traffic
// here, not a misconfiguration, so it must not trip the warning below.
const sibling: AssertionPayload["purpose"] =
purpose === "identity-sync" ? ASSERTION_PURPOSE : "identity-sync";
return {
async verify(token, platform): Promise<AuthPrincipal | 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;

for (const secret of secrets) {
let payload: unknown;
try {
payload = await verifyJwt(
token,
await importedKeyFor(secret),
ASSERTION_VERIFY_OPTIONS
);
} catch {
continue;
}
if (!isAssertionPayload(payload)) continue;
let sawSiblingPurpose = false;
for (const secret of secrets) {
let payload: unknown;
try {
payload = await verifyJwt(
token,
await importedKeyFor(secret),
ASSERTION_VERIFY_OPTIONS
);
} catch {
continue;
}
if (!isAssertionPayload(payload, purpose)) {
if (isAssertionPayload(payload, sibling)) sawSiblingPurpose = true;
continue;
}

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;
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;

return { userId: payload.sub, scope: "assertion" };
}
if (purpose === "identity-sync") {
// isAssertionPayload() verified body_sha256 above; re-check here
// so the type narrows without a non-null assertion.
const bodySha256 = payload.body_sha256;
if (typeof bodySha256 !== "string") continue;
return { userId: payload.sub, scope: "identity_sync", bodySha256 };
}
return { userId: payload.sub, scope: "assertion" };
}

// A consistent failure across every configured secret is the only
// signal a misconfigured ASSERTION_SIGNING_SECRET produces.
const now = Date.now();
if (now - lastAuthWarnAt >= WARN_INTERVAL_MS) {
lastAuthWarnAt = now;
logWarn("auth", "Bearer assertion failed verification", {
secretsTried: secrets.length
});
// A consistent failure across every configured secret is the only
// signal a misconfigured ASSERTION_SIGNING_SECRET produces.
// Sibling-purpose assertions are expected here, so they skip it.
const now = Date.now();
if (!sawSiblingPurpose && now - lastAuthWarnAt >= WARN_INTERVAL_MS) {
lastAuthWarnAt = now;
logWarn("auth", "Bearer assertion failed verification", {
secretsTried: secrets.length
});
}
return null;
}
return null;
}
};
};
}

export const bearerAssertionVerifier = assertionVerifier(ASSERTION_PURPOSE);
// Service proofs are not registered in the general bearer verifier list.
export const identitySyncAssertionVerifier = assertionVerifier("identity-sync");
Comment thread
admdly marked this conversation as resolved.
7 changes: 3 additions & 4 deletions src/lib/auth/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { PlatformContext } from "../context";

export interface AuthPrincipal {
userId: string;
scope: "assertion" | "api_key";
}
export type AuthPrincipal =
| { userId: string; scope: "assertion" | "api_key" }
| { userId: string; scope: "identity_sync"; bodySha256: string };

export interface TokenVerifier {
verify(
Expand Down
6 changes: 4 additions & 2 deletions src/lib/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ declare module "hono" {
// and requireAuth() itself don't change.
const verifiers: TokenVerifier[] = [bearerAssertionVerifier];

export function requireAuth(): MiddlewareHandler {
export function requireAuth(
tokenVerifiers: readonly TokenVerifier[] = verifiers
): MiddlewareHandler {
return async (c, next) => {
const header = c.req.header("Authorization");
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || null;
Expand All @@ -28,7 +30,7 @@ export function requireAuth(): MiddlewareHandler {
}

const platform = getPlatform(c);
for (const verifier of verifiers) {
for (const verifier of tokenVerifiers) {
const principal = await verifier.verify(token, platform);
if (principal) {
c.set("auth", principal);
Expand Down
30 changes: 29 additions & 1 deletion src/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Context } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { cache } from "hono/cache";

export function normalizePublicCacheKey(url: string): string {
const cacheUrl = new URL(url);
Expand Down Expand Up @@ -28,3 +29,30 @@ export function singleFlight<T>(
inflight.set(key, promise);
return promise;
}

// Only for public GET representations that never depend on credentials.
// Keep Hono's key/Vary and response privacy safeguards, but prevent an
// unused Authorization header from turning off backend-protective caching.
// Downstream handlers always see the original request (including credentials).
export function publicResponseCache(
options: Parameters<typeof cache>[0]
): MiddlewareHandler {
const middleware = cache(options);
return async (c, next) => {
const original = c.req.raw;
if (c.req.method !== "GET" || !original.headers.has("Authorization")) {
return middleware(c, next);
}
const headers = new Headers(original.headers);
headers.delete("Authorization");
c.req.raw = new Request(original, { headers });
try {
return await middleware(c, async () => {
c.req.raw = original;
await next();
});
} finally {
c.req.raw = original;
}
};
}
23 changes: 22 additions & 1 deletion src/services/central-alerts/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,28 @@ The Central Alerts service provides targeted notifications to FOSSBilling instal

### GET `/list`

Retrieve all alerts in the system.
Retrieve all alerts in the system. Optional `limit` (integer 1–100) and
Comment thread
admdly marked this conversation as resolved.
`offset` enable pagination. An unusable limit keeps the full-list response;
`offset` without a usable limit returns 422. An omitted or unusable offset
with a usable limit defaults to zero.

Successful responses are edge-cached for 60 seconds by the effective page.
Unknown query parameters and equivalent pagination spellings reuse the same
entry. This endpoint is public: Authorization does not affect its response or
bypass its cache. Validation failures and database errors are not cached.

When `limit` is usable the response adds a `pagination` object next to
`alerts`:

```json
{
"result": {
"alerts": [],
"pagination": { "limit": 1, "offset": 0, "has_more": true }
},
"error": null
}
```

**Response:**

Expand Down
Loading
Loading