From 9a48d6abf5ccda63078d472c92e0d12998fa6d17 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Fri, 31 Jul 2026 09:03:11 +0100 Subject: [PATCH 1/2] Require fresh GitHub organization evidence --- .../extensions/v2/db/external-tables.ts | 3 +- src/services/extensions/v2/users-database.ts | 33 +++++++-- test/services/extensions/v2/db-fixtures.ts | 15 +++- test/services/extensions/v2/index.test.ts | 68 +++++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/services/extensions/v2/db/external-tables.ts b/src/services/extensions/v2/db/external-tables.ts index 8eff6e9..58f0265 100644 --- a/src/services/extensions/v2/db/external-tables.ts +++ b/src/services/extensions/v2/db/external-tables.ts @@ -13,5 +13,6 @@ export const users = sqliteTable("users", { name: text("name"), isModerator: integer("is_moderator"), githubLogin: text("github_login"), - githubOrgs: text("github_orgs") + githubOrgs: text("github_orgs"), + githubOrgsExpiresAt: text("github_orgs_expires_at") }); diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/users-database.ts index 8e9f076..9802434 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/users-database.ts @@ -9,11 +9,21 @@ export type GithubIdentity = { githubOrgs: string[]; }; +function isFutureGithubOrgsExpiry( + value: string | null | undefined, + now = Date.now() +): boolean { + if (!value) return false; + const expiresAt = Date.parse(value); + return Number.isFinite(expiresAt) && expiresAt > now; +} + // `users` is owned by the FOSSBilling/extensions repo (src/lib/db/users.sql there), // NOT this repo, but lives in the same DB_EXTENSIONS database. If that schema // changes (columns renamed/dropped), update fossbilling/api AND that file. Assumed // columns used here: users.id (TEXT, = auth `sub` claim), users.is_moderator -// (INTEGER 0/1), users.github_login (TEXT), users.github_orgs (TEXT, JSON array). +// (INTEGER 0/1), users.github_login (TEXT), users.github_orgs (TEXT, JSON +// array), and users.github_orgs_expires_at (TEXT, absolute RFC3339 expiry). export class UsersDatabase { constructor(private db: ExtensionsDb) {} @@ -31,9 +41,9 @@ export class UsersDatabase { // Used to verify developer-profile claims against the claimant's own // linked GitHub identity — see DevelopersDatabase.claim(). github_orgs is - // only populated once the claimant has signed in via GitHub since the - // auth service started requesting the read:org scope; absent columns/rows - // resolve to "no linked identity" rather than throwing. + // only usable while its central-auth expiry is in the future; absent, + // malformed, or expired organization evidence resolves to no memberships + // rather than throwing. async getGithubIdentity( userId: string ): Promise> { @@ -41,16 +51,25 @@ export class UsersDatabase { const [row] = await this.db .select({ githubLogin: users.githubLogin, - githubOrgs: users.githubOrgs + githubOrgs: users.githubOrgs, + githubOrgsExpiresAt: users.githubOrgsExpiresAt }) .from(users) .where(eq(users.id, userId)); let githubOrgs: string[] = []; - if (row?.githubOrgs) { + if ( + row?.githubOrgs && + isFutureGithubOrgsExpiry(row.githubOrgsExpiresAt) + ) { try { const parsed = JSON.parse(row.githubOrgs); - if (Array.isArray(parsed)) githubOrgs = parsed; + if ( + Array.isArray(parsed) && + parsed.every((org) => typeof org === "string") + ) { + githubOrgs = parsed; + } } catch { // Malformed JSON is treated the same as "no orgs recorded" — // never let a parse failure block or wrongly verify a claim. diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts index fb2edbc..91cf05f 100644 --- a/test/services/extensions/v2/db-fixtures.ts +++ b/test/services/extensions/v2/db-fixtures.ts @@ -146,8 +146,15 @@ export async function insertUser( is_moderator?: number; github_login?: string; github_orgs?: string; + github_orgs_expires_at?: string | null; } ): Promise { + const githubOrgsExpiresAt = + row.github_orgs_expires_at !== undefined + ? row.github_orgs_expires_at + : row.github_orgs != null + ? "2099-01-01T00:00:00.000Z" + : null; // Upsert rather than a plain INSERT: ensureUser() (called automatically // by the other insert* helpers below, and by authHeaders() in // index.test.ts, to satisfy users(id) FKs - e.g. developers.owner_user_id @@ -156,17 +163,19 @@ export async function insertUser( // created a bare stub row for this id before this richer call runs. await db .prepare( - `INSERT INTO users (id, is_moderator, github_login, github_orgs) VALUES (?, ?, ?, ?) + `INSERT INTO users (id, is_moderator, github_login, github_orgs, github_orgs_expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET is_moderator = excluded.is_moderator, github_login = excluded.github_login, - github_orgs = excluded.github_orgs` + github_orgs = excluded.github_orgs, + github_orgs_expires_at = excluded.github_orgs_expires_at` ) .bind( row.id, row.is_moderator ?? null, row.github_login ?? null, - row.github_orgs ?? null + row.github_orgs ?? null, + githubOrgsExpiresAt ) .run(); } diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index bec7495..96e84e4 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -981,6 +981,74 @@ describe("Extensions API v2", () => { expect(created.result.github_org_verified).toBe(true); }); + it("keeps username verification independent of organization expiry", async () => { + mockGithubEntity("User"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify(["former-org"]), + github_orgs_expires_at: "2000-01-01T00:00:00.000Z" + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(body.result.github_org_verified).toBe(true); + }); + + it.each([ + ["expired", "2000-01-01T00:00:00.000Z"], + ["missing", null] + ])( + "does not verify an organization from %s GitHub membership evidence", + async (_state, github_orgs_expires_at) => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]), + github_orgs_expires_at + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + } + ); + + it("does not verify an organization from a fresh confirmed empty list", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify([]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + }); + it("verifies the Publisher URL when it matches GitHub's on-file website", async () => { mockGithubEntity("Organization", "https://www.acme.example/"); await insertUser(db, { diff --git a/vitest.config.ts b/vitest.config.ts index 34ef9dd..d16fd96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,7 +32,7 @@ const v1SchemaSql = readFileSync( "utf8" ); const usersStubSql = - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY NOT NULL, name TEXT, is_moderator INTEGER, github_login TEXT, github_orgs TEXT);"; + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY NOT NULL, name TEXT, is_moderator INTEGER, github_login TEXT, github_orgs TEXT, github_orgs_expires_at TEXT);"; export default defineConfig({ plugins: [ From 65a71722500464ec3347262d510001eb8f8a79ac Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Fri, 31 Jul 2026 09:26:50 +0100 Subject: [PATCH 2/2] Reject malformed GitHub organization expiries --- src/services/extensions/v2/users-database.ts | 5 ++++- test/services/extensions/v2/index.test.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/users-database.ts index 9802434..f3b55ee 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/users-database.ts @@ -9,11 +9,14 @@ export type GithubIdentity = { githubOrgs: string[]; }; +const RFC3339_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + function isFutureGithubOrgsExpiry( value: string | null | undefined, now = Date.now() ): boolean { - if (!value) return false; + if (!value || !RFC3339_TIMESTAMP.test(value)) return false; const expiresAt = Date.parse(value); return Number.isFinite(expiresAt) && expiresAt > now; } diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index 96e84e4..9edc76e 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -1005,7 +1005,8 @@ describe("Extensions API v2", () => { it.each([ ["expired", "2000-01-01T00:00:00.000Z"], - ["missing", null] + ["missing", null], + ["malformed", "2099"] ])( "does not verify an organization from %s GitHub membership evidence", async (_state, github_orgs_expires_at) => {