feat(install): post-install setup flow with group bind choice

The GitHub App Setup URL now lands on /auth/github/install, which renders a choice page: bind the installation to a new inst-{id} group or to an existing group the signed-in user owns (owner role re-checked on POST /auth/github/install/bind). Adds an App-JWT helper (getInstallationAccount) to name the auto-created group, audit entries, a console toast, and keeps the installation.created webhook fallback.
This commit is contained in:
RhenCloud 2026-08-13 09:39:02 +08:00
parent b600f02027
commit 39bb639883
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
10 changed files with 613 additions and 8 deletions

View file

@ -0,0 +1,348 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createOAuthRoutes } from "../web/oauth-routes";
import { createAdminSession, adminCookie } from "../web/session";
import { loadGroups } from "../web/groups";
import { getInstallationAccount } from "../github/oauth";
import type { Env } from "../types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
function bufToPem(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
const b64 = btoa(s);
return `-----BEGIN PRIVATE KEY-----\n${b64.replace(/(.{64})/g, "$1\n")}\n-----END PRIVATE KEY-----`;
}
async function makeAppKeyPair(): Promise<{ appId: string; pem: string }> {
const pair = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"],
);
const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey);
return { appId: "12345", pem: bufToPem(pkcs8) };
}
describe("getInstallationAccount", () => {
let calledUrls: string[];
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function mockApi(status: number, body: unknown): void {
calledUrls = [];
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
calledUrls.push(String(input));
void init;
return Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
};
}
it("returns the installation account login using an App JWT", async () => {
const { appId, pem } = await makeAppKeyPair();
mockApi(200, { id: 555, account: { login: "myorg" } });
const login = await getInstallationAccount(appId, pem, 555);
expect(login).toBe("myorg");
expect(calledUrls[0]).toContain("/app/installations/555");
});
it("returns null when the App credentials are missing", async () => {
expect(await getInstallationAccount("", "", 555)).toBeNull();
});
it("returns null on API errors", async () => {
const { appId, pem } = await makeAppKeyPair();
mockApi(404, { message: "not found" });
expect(await getInstallationAccount(appId, pem, 555)).toBeNull();
});
});
describe("GET /github/install", () => {
const app = createOAuthRoutes();
const restoredFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
void init;
return Promise.resolve(
new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
afterEach(() => {
globalThis.fetch = restoredFetch;
});
it("requires login first and preserves the installation id", async () => {
const env = createEnv();
const res = await app.request(
"/github/install?installation_id=555",
{ headers: { accept: "text/html" } },
env,
);
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/auth/github?redirect=");
expect(decodeURIComponent(location)).toContain("/github/install?installation_id=555");
});
it("renders a choice page with the groups the user owns", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "My Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
{
id: "theirs",
name: "Other Team",
adminIds: [],
members: [{ login: "bob", role: "owner" }],
},
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const res = await app.request(
"/github/install?installation_id=555&setup_action=install",
{ headers: { cookie: adminCookie(sessionId), accept: "text/html" } },
env,
);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("inst-555");
expect(html).toContain("My Team");
expect(html).toContain('value="mine"');
expect(html).not.toContain('value="theirs"');
});
it("rejects a missing installation id", async () => {
const env = createEnv();
const res = await app.request("/github/install", { headers: { accept: "text/html" } }, env);
expect(res.status).toBe(400);
});
});
describe("POST /github/install/bind", () => {
const app = createOAuthRoutes();
const restoredFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
void init;
return Promise.resolve(
new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function formBody(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
it("binds the installation to an existing group the user owns", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "My Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const res = await app.request(
"/github/install/bind",
{
method: "POST",
headers: {
cookie: adminCookie(sessionId),
"content-type": "application/x-www-form-urlencoded",
},
body: formBody({ installation_id: "555", group: "mine" }),
},
env,
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin?install=ok");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.id).toBe("mine");
expect(groups[0]!.installationId).toBe(555);
});
it("refuses to bind to a group the user does not own", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "theirs",
name: "Other Team",
adminIds: [],
members: [{ login: "bob", role: "owner" }],
},
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const res = await app.request(
"/github/install/bind",
{
method: "POST",
headers: {
cookie: adminCookie(sessionId),
"content-type": "application/x-www-form-urlencoded",
},
body: formBody({ installation_id: "555", group: "theirs" }),
},
env,
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin?error=forbidden");
const groups = await loadGroups(kv);
expect(groups[0]!.installationId).toBeUndefined();
});
it("auto-creates inst-{id} and joins the installer as owner (self-signup)", async () => {
const kv = createMockKV();
const { appId, pem } = await makeAppKeyPair();
const env = createEnv({
KV: kv,
ALLOW_SELF_SIGNUP: "1",
GITHUB_APP_ID: appId,
GITHUB_PRIVATE_KEY: pem,
});
const sessionId = await createAdminSession(kv, "1001", "alice");
const res = await app.request(
"/github/install/bind",
{
method: "POST",
headers: {
cookie: adminCookie(sessionId),
"content-type": "application/x-www-form-urlencoded",
},
body: formBody({ installation_id: "555", group: "" }),
},
env,
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin?install=ok");
const groups = await loadGroups(kv);
const group = groups.find((g) => g.id === "inst-555");
expect(group).toBeDefined();
expect(group?.installationId).toBe(555);
expect(group?.name).toBe("myorg");
expect(group?.members).toContainEqual({ login: "alice", role: "owner" });
});
it("auto-creates inst-{id} without joining members when self-signup is off", async () => {
const kv = createMockKV();
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const res = await app.request(
"/github/install/bind",
{
method: "POST",
headers: {
cookie: adminCookie(sessionId),
"content-type": "application/x-www-form-urlencoded",
},
body: formBody({ installation_id: "555", group: "" }),
},
env,
);
expect(res.status).toBe(302);
const groups = await loadGroups(kv);
const group = groups.find((g) => g.installationId === 555);
expect(group).toBeDefined();
expect(group?.members ?? []).toHaveLength(0);
});
it("requires login to bind", async () => {
const env = createEnv();
const res = await app.request(
"/github/install/bind",
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: formBody({ installation_id: "555", group: "" }),
},
env,
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin?error=forbidden");
});
});

View file

@ -5,6 +5,69 @@ export function getOAuthURL(clientId: string, state: string): string {
return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`;
}
function b64url(buf: ArrayBuffer | string): string {
const bytes = typeof buf === "string" ? new TextEncoder().encode(buf) : new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function pemToBinary(pem: string): ArrayBuffer {
const base64 = pem
.replace(/-----BEGIN [^-]+-----/, "")
.replace(/-----END [^-]+-----/, "")
.replace(/\s+/g, "");
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
/** GitHub App JWT (RS256, PKCS#8 PEM key), valid ~10 minutes. */
async function createAppJwt(appId: string, privateKey: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = b64url(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }));
const data = `${header}.${payload}`;
const key = await crypto.subtle.importKey(
"pkcs8",
pemToBinary(privateKey),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(data));
return `${data}.${b64url(sig)}`;
}
/**
* Look up the account (org/user login) that owns a GitHub App installation,
* using an App JWT. Returns null when the App credentials are missing or the
* lookup fails (the caller falls back to an anonymous installation group).
*/
export async function getInstallationAccount(
appId: string,
privateKey: string,
installationId: number,
): Promise<string | null> {
if (!appId || !privateKey) return null;
try {
const jwt = await createAppJwt(appId, privateKey);
const res = await fetch(`https://api.github.com/app/installations/${installationId}`, {
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) return null;
const data = (await res.json()) as { account?: { login?: string } };
return data.account?.login ?? null;
} catch {
return null;
}
}
export async function handleOAuthCallback(
clientId: string,
clientSecret: string,

View file

@ -1,8 +1,16 @@
import { Hono } from "hono";
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
import { getOAuthURL, handleOAuthCallback, getInstallationAccount } from "../github/oauth";
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
import { createAdminSession, adminCookie, getAdminSession } from "./session";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess } from "./groups";
import {
loadGroups,
saveGroups,
resolveScope,
hasAnyAccess,
ensureInstallationGroup,
normalizeGroupMembers,
roleAt,
} from "./groups";
import { clientIp } from "./auth";
import { recordAudit } from "../lib/audit";
import { sendMessage } from "../drivers/telegram/rest";
@ -36,6 +44,14 @@ function safeRedirectPath(value: string | undefined): string {
return value;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function selfSignupEnabled(env: Env): boolean {
const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase();
return flag === "1" || flag === "true" || flag === "yes" || flag === "on";
@ -71,6 +87,32 @@ async function ensurePersonalGroup(env: Env, userId: string, login: string): Pro
return true;
}
/**
* Post-install choice page: pick which group the installation binds to.
* Options are the groups the signed-in user owns (role `owner`), plus a
* default "create a new group" choice.
*/
function installPage(opts: {
installationId: number;
accountLogin: string;
owned: Group[];
}): string {
const { installationId, accountLogin, owned } = opts;
const accountLine = accountLogin
? `<p>账号:<b>${escapeHtml(accountLogin)}</b>(安装 ID <code>${installationId}</code></p>`
: `<p>安装 ID<code>${installationId}</code></p>`;
const ownedOptions = owned
.map(
(g) =>
`<label class="opt"><input type="radio" name="group" value="${escapeHtml(g.id)}"><span><b>${escapeHtml(g.name)}</b> <code>${escapeHtml(g.id)}</code></span></label>`,
)
.join("");
const ownedNote = owned.length
? '<p class="hint">也可以选择绑定到你有 owner 权限的已有分组:</p>'
: "";
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安装 GitHub App</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 32px;width:min(480px,92vw);box-shadow:0 1px 3px rgba(0,0,0,.06)}h1{font-size:17px;margin:0 0 4px}p{color:#57606a;font-size:13.5px;margin:6px 0}code{background:#f0f1f3;border-radius:4px;padding:1px 5px;font-size:12.5px}.opt{display:flex;align-items:flex-start;gap:8px;padding:9px 10px;border:1px solid #e5e7eb;border-radius:8px;margin-top:8px;cursor:pointer}.opt:hover{background:#fafbfc}.hint{font-size:12.5px;color:#8b949e;margin-top:10px}.btn{display:inline-block;margin-top:14px;background:#1f2328;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:14px;cursor:pointer}.btn:hover{background:#32383f}.skip{margin-left:12px;color:#8b949e;font-size:13px;text-decoration:none}</style></head><body><div class="card"><h1>GitHub App 安装成功</h1>${accountLine}<p>将安装绑定到哪个分组?建议直接创建新分组,之后可以在控制台添加路由与成员。</p><form method="post" action="/auth/github/install/bind"><input type="hidden" name="installation_id" value="${installationId}"><label class="opt"><input type="radio" name="group" value="" checked><span><b>创建新分组</b> <code>inst-${installationId}</code></span></label>${ownedNote}${ownedOptions}<button class="btn" type="submit">确定</button></form></div></body></html>`;
}
export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
@ -90,6 +132,140 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
return c.redirect(url);
});
/**
* GitHub App post-install redirect: the App's "Setup URL" points here, so
* the browser lands on this route right after a user installs the App on an
* org/user before any webhook event arrives. The user picks which group
* the installation binds to (an existing group they own, or a new
* auto-created `inst-{id}` group); the actual provisioning happens on
* `POST /auth/github/install/bind`.
*/
app.get("/github/install", async (c) => {
const rawId = c.req.query("installation_id");
const installationId = Number(rawId);
if (!rawId || !Number.isInteger(installationId) || installationId <= 0) {
return c.json({ error: "Missing installation_id" }, 400);
}
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
if (!session) {
const target = `/auth/github/install?installation_id=${installationId}`;
return c.redirect(`/auth/github?redirect=${encodeURIComponent(target)}`);
}
const accountLogin =
(await getInstallationAccount(
c.env.GITHUB_APP_ID ?? "",
c.env.GITHUB_PRIVATE_KEY ?? "",
installationId,
)) ?? "";
const groups = await loadGroups(c.env.KV);
const scope = resolveScope(c.env, groups, session.userId, session.login);
const owned = groups.filter((g) => roleAt(scope, g.id) === "owner");
return c.html(installPage({ installationId, accountLogin, owned }));
});
app.post("/github/install/bind", async (c) => {
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
if (!session) {
return c.redirect("/admin?error=forbidden");
}
const body = await c.req.parseBody();
const rawId = String(body["installation_id"] ?? "");
const installationId = Number(rawId);
if (!Number.isInteger(installationId) || installationId <= 0) {
return c.json({ error: "Missing installation_id" }, 400);
}
const chosenGroupId = String(body["group"] ?? "").trim();
const groups = await loadGroups(c.env.KV);
const scope = resolveScope(c.env, groups, session.userId, session.login);
const bind = async (groupId: string, group: Group | null): Promise<Response> => {
if (!group) {
return c.redirect("/admin?error=install");
}
const next = groups.map((g) => (g.id === group.id ? { ...g, installationId } : g));
await saveGroups(c.env.KV, next);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "installation.bind",
targetType: "group",
targetId: groupId,
groupId,
detail: { installationId },
ip: clientIp(c),
});
return c.redirect("/admin?install=ok");
};
if (chosenGroupId) {
// Binding to an existing group requires owner permission on it.
const group = groups.find((g) => g.id === chosenGroupId);
if (!group || roleAt(scope, chosenGroupId) !== "owner") {
return c.redirect("/admin?error=forbidden");
}
return bind(chosenGroupId, group);
}
// Default: auto-create a dedicated inst-{id} group.
const accountLogin =
(await getInstallationAccount(
c.env.GITHUB_APP_ID ?? "",
c.env.GITHUB_PRIVATE_KEY ?? "",
installationId,
)) ?? "";
const group = await ensureInstallationGroup(c.env.KV, installationId, accountLogin);
if (!group) {
return c.redirect("/admin?error=install");
}
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "installation.created",
targetType: "group",
targetId: group.id,
groupId: group.id,
detail: { source: "setup_url", account: accountLogin || undefined },
ip: clientIp(c),
});
// Self-service SaaS: the installer manages their own auto-created group.
if (selfSignupEnabled(c.env)) {
const members = normalizeGroupMembers(group);
const alreadyMember = members.some(
(m) => m.login.toLowerCase() === session.login.toLowerCase() || m.login === session.userId,
);
if (!alreadyMember) {
const updated: Group = {
...group,
members: [...members, { login: session.login, role: "owner" }],
adminIds: [...new Set([...(group.adminIds ?? []), session.login])],
};
const all = await loadGroups(c.env.KV);
await saveGroups(
c.env.KV,
all.map((g) => (g.id === group.id ? updated : g)),
);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "group.member.add",
targetType: "group",
targetId: group.id,
groupId: group.id,
detail: { login: session.login, role: "owner", auto: true },
ip: clientIp(c),
});
}
}
return c.redirect("/admin?install=ok");
});
app.get("/github/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");