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
3 changes: 2 additions & 1 deletion src/services/extensions/v2/db/external-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
});
36 changes: 29 additions & 7 deletions src/services/extensions/v2/users-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ 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 || !RFC3339_TIMESTAMP.test(value)) return false;
const expiresAt = Date.parse(value);
Comment thread
admdly marked this conversation as resolved.
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) {}

Expand All @@ -31,26 +44,35 @@ 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<DatabaseResult<GithubIdentity>> {
try {
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.
Expand Down
15 changes: 12 additions & 3 deletions test/services/extensions/v2/db-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,15 @@ export async function insertUser(
is_moderator?: number;
github_login?: string;
github_orgs?: string;
github_orgs_expires_at?: string | null;
}
): Promise<void> {
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
Expand All @@ -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();
}
Expand Down
69 changes: 69 additions & 0 deletions test/services/extensions/v2/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,75 @@ 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],
["malformed", "2099"]
])(
"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, {
Expand Down
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading