mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
b600f02027
commit
39bb639883
10 changed files with 613 additions and 8 deletions
|
|
@ -15,7 +15,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
|||
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
|
||||
- Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; a `custom` provider accepts arbitrary signed JSON posts (`X-WebHooker-Signature`) as `custom` events; GitLab etc. can be added later
|
||||
- Per-group webhook ingress: optional `POST /webhook/{groupId}` with a per-group secret in KV (`tenant:{groupId}`) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacy `POST /webhook` (global secrets, all routes) stays untouched
|
||||
- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). `installation.created` events auto-provision: a dedicated `inst-{installationId}` group is created, or existing groups whose `owners` match the installing account get bound automatically
|
||||
- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). The App's Setup URL points at `GET /auth/github/install`, which renders a choice page (create `inst-{installationId}` or bind to a group the signed-in user owns, verified by role); `POST /auth/github/install/bind` performs the provisioning. `installation.created` webhook events auto-provision as a fallback (create `inst-{installationId}` or bind existing groups whose `owners` match the installing account)
|
||||
- GitHub OAuth: octokit (token is stored hashed for reverse lookup)
|
||||
- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist
|
||||
- Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only
|
||||
|
|
@ -106,7 +106,7 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
|
|||
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
|
||||
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
|
||||
- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
|
||||
- Auto-provision GitHub App installs on `installation.created`: create `inst-{installationId}` group or bind existing groups whose `owners` match the installing account
|
||||
- Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups
|
||||
- Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members
|
||||
- Issue single-use 7-day group invite links (`invite:{token}`); accepting joins as admin/viewer (never owner); `ALLOW_SELF_SIGNUP=1` creates a deterministic personal group (`u-{userId}`) on first login
|
||||
- Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS`
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ onMounted(() => {
|
|||
const invite = params.get("invite");
|
||||
if (invite === "ok") push(t("members.inviteOk"));
|
||||
else if (invite != null && invite !== "ok") push(t("members.inviteBad"), "bad");
|
||||
if (params.get("install") === "ok") push(t("install.ok"));
|
||||
}
|
||||
loadGroups();
|
||||
loadLogs(50, logFilterGroup.value || undefined);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const en: Dict = {
|
|||
"members.errLastOwner": "Cannot remove the last owner.",
|
||||
"members.inviteOk": "Invite accepted — welcome!",
|
||||
"members.inviteBad": "This invite is invalid or expired.",
|
||||
"install.ok":
|
||||
"GitHub App installation registered — the group has been created and bound to this installation.",
|
||||
"webhook.title": "Webhook endpoint",
|
||||
"webhook.note": "(per-group URL + secret)",
|
||||
"webhook.empty": "Not configured. Generate a secret to enable the group's own webhook endpoint.",
|
||||
|
|
@ -302,6 +304,7 @@ const zh: Dict = {
|
|||
"members.errLastOwner": "不能移除最后一位 owner。",
|
||||
"members.inviteOk": "邀请已接受 —— 欢迎!",
|
||||
"members.inviteBad": "该邀请无效或已过期。",
|
||||
"install.ok": "GitHub App 安装已注册 —— 分组已创建并绑定到该安装。",
|
||||
"webhook.title": "Webhook 入口",
|
||||
"webhook.note": "(分组独立的 URL + secret)",
|
||||
"webhook.empty": "未配置。生成 secret 即可启用该分组的独立 webhook 入口。",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,14 @@ Any JSON payload signed with `X-WebHooker-Signature: sha256=<hex>` (HMAC-SHA256
|
|||
|
||||
### GitHub App Installation Events
|
||||
|
||||
`installation` webhook events (`created`, ...) are auto-provisioned: a group named after the installing account (`inst-{installationId}`, bound via `installationId`) is created automatically, or existing groups whose `owners` match the installing account are bound to the installation. See [Configuration → GitHub App tenant isolation](../guide/configuration.md#github-app-tenant-isolation).
|
||||
`installation` webhook events (`created`, ...) are auto-provisioned as a fallback: a group named after the installing account (`inst-{installationId}`, bound via `installationId`) is created automatically, or existing groups whose `owners` match the installing account are bound to the installation. See [Configuration → GitHub App tenant isolation](../guide/configuration.md#github-app-tenant-isolation).
|
||||
|
||||
The primary flow is the App's **Setup URL** — set it to `{BASE_URL}/auth/github/install`. After a user installs the App, the browser lands on:
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/auth/github/install` | Choice page: bind the installation to a new group or an existing group the signed-in user owns |
|
||||
| `POST` | `/auth/github/install/bind` | Provisions the binding (owner role re-checked) and redirects to `/admin?install=ok` |
|
||||
|
||||
## Error Format
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ Payload schema:
|
|||
|
||||
When the GitHub App is installed, its events arrive at the global endpoint for **every** installation. To keep tenants apart, bind each group to the installation id that should feed it: `"installationId": 12345678`. The id is visible in the App's installation webhook payload (`installation.id`) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its `owners` list is empty. Groups without `installationId` keep the legacy behavior (`owners` filtering).
|
||||
|
||||
Binding is **auto-configured**: the `installation.created` webhook event creates a dedicated `inst-{installationId}` group (name = the installing account) bound to the installation, or automatically binds every existing group whose `owners` match the installing account. No manual id entry is needed — just install the app, then add routes/members to the auto-created group in the console.
|
||||
Binding is **auto-configured** — the GitHub App's _Setup URL_ should point to `{BASE_URL}/auth/github/install`. Right after a user installs the App, the browser lands there and they choose where the installation binds: a **new group** (`inst-{installationId}`, default) or any **existing group they own** (owner role checked again on submit; `POST /auth/github/install/bind` performs the provisioning). No manual id entry is needed. As a fallback (e.g. when the Setup URL is not configured), the `installation.created` webhook event creates/binds the group automatically — existing groups whose `owners` match the installing account are bound, otherwise a dedicated `inst-{installationId}` group is created. Then just add routes/members in the console.
|
||||
|
||||
## Routes
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,14 @@ POST /webhook
|
|||
|
||||
### GitHub App 安装事件
|
||||
|
||||
`installation` webhook 事件(`created` 等)会自动配置:按安装账号自动创建分组(`inst-{installationId}`,绑定 `installationId`);或把 `owners` 匹配该账号的现有分组自动绑定到该安装。参见[配置 → GitHub App 租户隔离](../guide/configuration.md#github-app-租户隔离)。
|
||||
`installation` webhook 事件(`created` 等)作为兜底会自动配置:按安装账号自动创建分组(`inst-{installationId}`,绑定 `installationId`);或把 `owners` 匹配该账号的现有分组自动绑定到该安装。参见[配置 → GitHub App 租户隔离](../guide/configuration.md#github-app-租户隔离)。
|
||||
|
||||
主要流程是 App 的 **Setup URL** —— 将其设置为 `{BASE_URL}/auth/github/install`。用户安装 App 后浏览器会跳转到:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | --------------------------- | ------------------------------------------------------------- |
|
||||
| `GET` | `/auth/github/install` | 选择页:将安装绑定到新分组或登录用户拥有 owner 权限的已有分组 |
|
||||
| `POST` | `/auth/github/install/bind` | 执行绑定(再次校验 owner 角色)并跳转 `/admin?install=ok` |
|
||||
|
||||
## 错误格式
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
|||
|
||||
GitHub App 安装后,**所有**安装方的事件都会到达全局端点。要让租户互相隔离,请把每个分组绑定到应当为其提供事件的安装 ID:`"installationId": 12345678`。该 ID 可从 App 安装 webhook 载荷(`installation.id`)或 GitHub App 安装页 URL 看到。即使分组的 `owners` 为空,来自其它安装的事件也会被拒绝。未设置 `installationId` 的分组保持旧行为(`owners` 过滤)。
|
||||
|
||||
绑定是**自动配置**的:`installation.created` webhook 事件会自动创建绑定到该安装的专用分组 `inst-{installationId}`(名称为安装账号),或自动把 `owners` 匹配该账号的现有分组绑定到该安装。无需手动填写 ID——安装 App 后在控制台为自动创建的分组添加路由和成员即可。
|
||||
绑定是**自动配置**的 —— 将 GitHub App 的 _Setup URL_ 指向 `{BASE_URL}/auth/github/install`。用户安装 App 后浏览器立即跳转到该页面,可选择将安装绑定到:**新分组**(`inst-{installationId}`,默认)或任意**自己拥有 owner 权限的已有分组**(提交时再次校验角色;由 `POST /auth/github/install/bind` 完成配置)。无需手动填写 ID。作为兜底(例如未配置 Setup URL 时),`installation.created` webhook 事件也会自动创建/绑定分组 —— `owners` 匹配安装账号的现有分组会被绑定,否则创建独立的 `inst-{installationId}` 分组。之后在控制台为分组添加路由和成员即可。
|
||||
|
||||
## 路由
|
||||
|
||||
|
|
|
|||
348
src/__tests__/install.test.ts
Normal file
348
src/__tests__/install.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue