From e5bc7090e279422b35aa7395fb81232a9ec4406d Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Tue, 22 Sep 2026 23:05:16 +0800 Subject: [PATCH] feat(auth): add optional GitHub OAuth for a single administrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增可选 GitHub OAuth,互斥认证并在切换时保留管理员资料。 --- .env.example | 10 +- .github/workflows/deploy.yml | 8 +- DEPLOYMENT.md | 70 ++++++++---- README.md | 39 ++++--- docs/ZERO_TRUST.md | 2 + frontend/src/cloud-api.ts | 8 +- frontend/src/dashboard.ts | 39 +++++-- scripts/administrator.ts | 20 ++++ scripts/deploy.ts | 27 +++-- scripts/deployment-auth.ts | 44 ++++++++ scripts/deployment-config.ts | 35 +++++- src/accounts/auth-provider.ts | 8 ++ src/accounts/auth-routes.ts | 21 ++++ src/accounts/auth.ts | 15 ++- src/accounts/github-auth.ts | 141 ++++++++++++++++++++++++ src/accounts/http.ts | 5 +- src/types.ts | 10 +- src/worker.ts | 6 +- tests/deployment-auth.test.ts | 85 +++++++++++++++ tests/deployment-bootstrap.test.ts | 2 +- tests/oauth.test.ts | 170 +++++++++++++++++++++++++++++ vite.config.ts | 3 + 22 files changed, 690 insertions(+), 78 deletions(-) create mode 100644 scripts/administrator.ts create mode 100644 scripts/deployment-auth.ts create mode 100644 src/accounts/auth-provider.ts create mode 100644 src/accounts/auth-routes.ts create mode 100644 src/accounts/github-auth.ts create mode 100644 tests/deployment-auth.test.ts create mode 100644 tests/oauth.test.ts diff --git a/.env.example b/.env.example index ebce43e..e95bb6e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ # Copy to .dev.vars for local development. CONNECT_TIMEOUT_MS=10000 -# Zero Trust 团队域名(不带 https://)及此应用的 audience,不能使用测试绕过开关。 +# 只启用一种登录方式;生产由 Actions 自动填写 APP_ORIGIN 和 ADMIN_ACCOUNT_ID。 +AUTH_PROVIDER=cloudflare +APP_ORIGIN=https://your-worker.example.workers.dev +ADMIN_ACCOUNT_ID=admin +# cloudflare 模式需要以下两项;github 模式不需要。 ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com ACCESS_AUD=your-access-application-audience +# github 模式填写 OAuth App 和管理员数字 ID(生产由 GITHUB_ADMIN 用户名自动解析)。 +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_ADMIN_ID= # ENCRYPTION_KEY 必须为 32 字节安全随机数的 Base64;本文件只用于本地开发,生产值在 Cloudflare Worker 控制台中保存为 Secret。 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94f0a71..f7607d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: admin_email: - description: 管理员邮箱(首次必填;也可预先保存为 ADMIN_EMAIL 变量) + description: Cloudflare 模式的管理员邮箱(也可保存为 ADMIN_EMAIL 变量;GitHub 模式不填) required: false type: string @@ -26,6 +26,10 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + AUTH_PROVIDER: ${{ vars.AUTH_PROVIDER || 'cloudflare' }} + GITHUB_CLIENT_ID: ${{ vars.GITHUB_CLIENT_ID }} + GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} + GITHUB_ADMIN: ${{ vars.GITHUB_ADMIN }} ADMIN_EMAIL: ${{ inputs.admin_email || secrets.ADMIN_EMAIL || vars.ADMIN_EMAIL }} ACCESS_IDP_IDS: ${{ vars.ACCESS_IDP_IDS }} WORKER_NAME: ${{ vars.WORKER_NAME }} @@ -55,7 +59,7 @@ jobs: - name: Check project run: npm run check - - name: Configure Access, provision D1, migrate and deploy Worker + - name: Configure selected login, provision D1, migrate and deploy Worker run: npm run deploy env: ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 540bf02..c89328f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,6 +1,23 @@ # EdgeSSH 部署与验收 -## 三项准备 +## 先选择一种登录方式 + +本项目只有一个管理员、一份主机资料库。`AUTH_PROVIDER` 决定唯一生效的认证方式,不同时维护两套登录状态。两种方式都部署到 Cloudflare Workers,但 **GitHub 模式不需要 Zero Trust 或 Access 权限**。 + +在 GitHub **Settings > Secrets and variables > Actions** 配置: + +| 名称 | 类型 | Cloudflare 模式 | GitHub 模式 | +| --- | --- | --- | --- | +| `AUTH_PROVIDER` | Variable | `cloudflare`(默认) | `github` | +| `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填 | +| `ADMIN_EMAIL` | Variable | 管理员邮箱 | 不需要 | +| `GITHUB_CLIENT_ID` | Variable | 不需要 | OAuth App 的 Client ID | +| `GITHUB_CLIENT_SECRET` | Secret | 不需要 | OAuth App 的 Client Secret | +| `GITHUB_ADMIN` | Variable | 不需要 | 允许登录的唯一 GitHub 用户名,不是邮箱或组织名 | + +敏感值不要放 Variable。工作流只校验所选方式的配置,另一种方式的旧配置不会参与认证。 + +## Cloudflare 模式准备 1. 在 Cloudflare **启用 Zero Trust**,完成团队域名与计划初始化。组织开通涉及账户确认,不由脚本代办。 2. Fork 本仓库,启用 GitHub Actions,在 **Settings > Secrets and variables > Actions** 保存 `CLOUDFLARE_API_TOKEN` Secret。 @@ -8,6 +25,18 @@ 无需手动创建 Access 应用、OTP、D1,也无需抄录 Account ID、Team Domain 或 AUD。默认入口是 `https://edgessh.<账户子域>.workers.dev`。没有自定义域名也能使用。 +## GitHub 模式准备 + +1. 打开 GitHub **Settings > Developer settings > OAuth Apps > New OAuth App**。 +2. Application name 自定;Homepage URL 填 EdgeSSH 地址,Authorization callback URL 填 `https://你的入口/auth/callback`。 +3. 保存 Client ID,生成一个 Client Secret,按上表分别保存到 Actions Variable 和 Secret。 +4. 设置 `AUTH_PROVIDER=github`、`GITHUB_ADMIN=你的GitHub用户名`,保存 Cloudflare API Token,然后运行 **Actions > Deploy**。邮箱输入框留空。 +5. 若首次部署前不知道入口,可先为 OAuth App 使用占位 URL;部署后将 Action 摘要中的正式入口与回调地址复制回 OAuth App 设置,再登录。 + +GitHub OAuth App 必须由用户在 GitHub 创建;普通 GitHub Token 没有官方“创建 OAuth App”的 REST 接口,工作流不会假装自动完成它。 + +登录时仅读取 GitHub 公开身份,不申请仓库、组织或私人邮箱权限。Action 将用户名解析为数字用户 ID,Worker 只给这个 ID 签发会话。管理员更改 GitHub 用户名后,应同步更新 `GITHUB_ADMIN`。 + ## API Token 权限 以 Cloudflare **Edit Cloudflare Workers** 模板为起点,保留部署所需权限,并补齐以下账户权限。控制台的 Edit/Read 对应 API 文档的 Write/Read。 @@ -18,8 +47,8 @@ | Workers KV Storage | Edit | 保留官方 Workers 模板的部署权限 | | Account Settings | Read | 自动发现账户 | | D1 | Edit | 查找/创建数据库、检查旧数据与执行迁移 | -| Access: Apps and Policies | Edit | 查找/创建 Access 应用及邮箱策略 | -| Access: Organizations, Identity Providers, and Groups | Edit | 获取团队域名、查找/创建 OTP | +| Access: Apps and Policies | Edit | **仅 cloudflare 模式**:查找/创建应用及邮箱策略 | +| Access: Organizations, Identity Providers, and Groups | Edit | **仅 cloudflare 模式**:获取团队域名、查找/创建 OTP | 账户资源只选择实际部署账户。若 Token 可访问多个账户,设置 Actions Variable `CLOUDFLARE_ACCOUNT_ID`,脚本不会猜测目标账户。 @@ -32,16 +61,16 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 1. 校验本地配置,运行类型检查、测试、前端构建和 Wrangler dry-run。 2. 自动发现唯一账户(显式账户 ID 优先),读取现有 Worker Secret **名称**,不尝试读取密钥明文。 3. 默认读取账户 `workers.dev` 子域;未注册时自动注册确定性名称。已有子域不改名,避免影响其他 Worker。 -4. 读取 Zero Trust 团队域;按精确 hostname 复用 Access 自托管应用,或创建应用及明确邮箱的 Allow 策略。 -5. 新应用默认复用/创建 One-time PIN;存在的应用不重写登录方式和人工策略。 +4. 根据 `AUTH_PROVIDER` 仅准备所选认证:Cloudflare 配置 Access/OTP/Allow,GitHub 验证指定管理员并准备 OAuth Secret。 +5. GitHub 模式不调用 Zero Trust API;Cloudflare 模式不需要 GitHub OAuth 参数。既有 Access 应用不重写人工 IdP 配置。 6. 按 ID 或名称复用 D1,不存在才创建;指定 ID 不存在时直接失败,不用新空库替代。 7. 已有 `ENCRYPTION_KEY` 则保留;没有密钥且 D1 没有主机资料时,用安全随机数生成 32 字节密钥。 8. 执行远程 migration,仅应用增量 schema,不清空数据。 -9. 新增 Secret 只通过标准输入交给 Wrangler;部署 Worker,复核三个运行时 Secret 的名称,并将入口写入运行摘要。 +9. 自动沿用旧库唯一资料所有者 ID,新库使用固定 `admin`。新增 Secret 通过标准输入交给 Wrangler;部署后核对当前模式必需的 Secret,并输出入口和 GitHub 回调地址。 生产任务通过 concurrency 串行运行,失败可修正原因后重跑,已创建的资源会复用。请勿用多个仓库同时管理同一个 Worker。 -设置自定义域名时关闭备用 `workers.dev` 入口,所有部署关闭 preview URL。Worker 内仍校验 JWT 的签名、issuer、audience、期限、subject 与邮箱;缺少认证不降级为匿名 SSH。 +设置自定义域名时关闭备用 `workers.dev` 入口,所有部署关闭 preview URL。Cloudflare 模式校验 Access JWT;GitHub 模式使用 state、PKCE 和签名 HttpOnly Cookie。缺少认证不降级为匿名 SSH。 ## 可选配置 @@ -62,28 +91,25 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 ## 密钥生命周期与旧版升级 -- 运行时 `ENCRYPTION_KEY`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 的持久化来源是 **Cloudflare Worker Secrets**。不要保存为 Variable 或明文配置。 +- `ENCRYPTION_KEY` 的持久化来源是 **Cloudflare Worker Secrets**。Cloudflare 模式自动取得并保存 Team Domain/AUD;GitHub Client Secret 由 GitHub Actions Secret 同步到 Worker。不要保存为 Variable 或明文配置。 - 新部署不需要 GitHub 写 Secrets 权限或额外 GitHub Token。生成的值不写入文件、命令行参数或 Actions artifact。 - 重跑、推送新代码时保留原加密密钥。GitHub 中遗留的同名密钥不会替换 Worker 中的密钥。 - **已有 D1 主机资料但缺少密钥时停止部署。** 必须恢复原密钥,不能生成新密钥假装修复。Cloudflare API 不提供 Secret 明文读回,自动生成的密钥也不会显示给用户;不要删除 Worker/Secret。需要独立灾备时,可在首次部署前自行生成并安全备份 32 字节 Base64 密钥,再保存为 `ENCRYPTION_KEY` GitHub Secret。 - 旧部署已有三个 Worker Secrets 时,无需重新输入邮箱即可部署,也不要求复制 Secret 回 GitHub;脚本保留既有 Access 配置。这兼容人工管理的 Zone 级 Access 应用。 - 若需要自动创建/管理 Access,或更换 hostname,请提供 `ADMIN_EMAIL`。自动管理使用账户级 Access API;原有 Zone 级应用请先核对,不要在同一 hostname 叠加应用。 -- 更换密钥、Zero Trust 组织或导致用户 `sub` 改变的身份迁移需要单独的数据迁移方案,不包含在普通 Deploy 中。 +- 切换认证方式不改变 `ENCRYPTION_KEY`、D1 和管理员资料所有者。不要通过更换密钥来切换登录方式。 -## 登录方式扩展:GitHub OAuth +## 切换登录方式,保留同一管理员资料 -认证与授权分开: +1. 修改 `AUTH_PROVIDER`,补齐目标方式的配置,然后运行 Deploy。 +2. 保持 Worker、D1 和加密密钥不变。部署只读检查旧库唯一所有者并沿用其 ID,不搬迁、不重加密资料。 +3. 新库统一使用 `admin`。若旧库实际有多个资料所有者,脚本停止,不会猜测或合并原本隔离的数据。 +4. 从 Cloudflare 改 GitHub 时,**先解除入口域名原有的 Access 网关保护**,否则浏览器仍会先看到 Access。脚本发现这种情况会停止,不自动删除安全策略。仅解除登录网关,不要删除 Worker、D1 或 Secret。 +5. 反向切换时,Action 配置 Access,GitHub Cookie 不再被接受。旧 provider 的 Secret 即使暂时保留,也不是有效登录状态。 -- **登录方式(IdP)**:默认 OTP;未来可用 Cloudflare Access 的 GitHub OAuth,仍由 Access 向 Worker 签发 JWT。 -- **授权策略(Policy)**:默认明确管理员邮箱。添加 GitHub 登录不会自动把整个 GitHub 组织或所有用户加入允许名单。 +GitHub 会话使用 Secure、HttpOnly、SameSite=Lax Cookie,有效期 8 小时;退出清除本浏览器 Cookie,不注销 GitHub 网站账号。会话签名通过 HKDF 从原加密密钥派生独立用途的密钥,无需用户再管理 SESSION_SECRET。无需新增用户表或账号绑定流程。 -有现成 GitHub IdP 时,首次创建应用可通过 `ACCESS_IDP_IDS` 指定它;未设置时自动配置 OTP。多 IdP 新应用不启用单一 IdP 自动跳转。 - -已部署应用请在 Zero Trust 控制台添加 GitHub IdP、将其加入应用允许的登录方式,并在需要显示多种方式时关闭 instant authentication。后续 Deploy **不覆盖**这些设置,`ACCESS_IDP_IDS` 只控制新应用的默认配置。 - -脚本只更新名为 `EdgeSSH administrator` 的自动邮箱策略。改用 GitHub 组织/团队等授权时,可将这条策略改名后自行管理;其他人工策略保持不变。管理员 GitHub 身份的邮箱仍需匹配邮箱策略。不要直接新增 Everyone、Bypass 或整域邮箱授权。 - -未来如果要实现不经过 Access 的原生 GitHub OAuth,则还需设计回调、会话与身份映射;本次没有混入第二套登录系统。 +Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP;这属于 Access 模式,不是原生 `AUTH_PROVIDER=github`。 ## 排障 @@ -94,13 +120,15 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 - **既有策略被人工修改**:脚本不会覆盖额外 require/exclude 等条件;在控制台维护,或改名后重跑。 - **更换域名后无法登录**:带邮箱重新运行以配置新 hostname 的应用;不要仅改路由而沿用旧 AUD。 - **OTP 未收到**:确认输入邮箱完全匹配 Allow 策略,检查垃圾邮件。GitHub 等其他 IdP 的账户邮箱同样必须匹配授权。 +- **GitHub 回调失败**:检查 OAuth App 回调地址是否精确为 `https://实际入口/auth/callback`,Client ID/Secret 是否来自同一 OAuth App;重新从首页登录,不复用旧回调链接。 +- **GitHub 拒绝管理员**:`GITHUB_ADMIN` 应填个人用户名,不是邮箱或组织;用该账号重新授权。 - 手工配置、截图与 Access JWT 排查见 [Zero Trust 指南](docs/ZERO_TRUST.md)。 ## 验收清单 - `npm run check`:类型检查、测试、前端构建与部署 dry-run。 - 空账户 bootstrap 与重复部署:只创建一次应用/OTP/D1,密钥不轮换,不覆盖 GitHub IdP。 -- 未登录访问入口应跳转 Access;未授权邮箱不可进入。 +- Cloudflare 模式未登录跳 Access;GitHub 模式点击首页「登录」前往 GitHub,未授权账号不可进入。 - 登录后 `/api/auth/me` 与主机列表可用;无 JWT、伪造/过期 JWT 不可访问主机、票据或 WebSocket。 - 加密资料跨部署保持可解密;测试不得清空生产 D1。 - 真实 SSH、SFTP 与进程面板必须使用已获授权目标;未提供目标和登录会话时不声称验收完成。 diff --git a/README.md b/README.md index 4a542cc..7d36348 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ ### 不只是一个终端 - **边缘原生**:Worker 通过 Cloudflare TCP Sockets 直接连接 SSH 服务器,无需额外部署 SSH 中转服务,每个会话由 Durable Object 隔离。 -- **身份保护**:使用 Cloudflare Zero Trust Access 认证管理员,不提供匿名连接入口或本地注册登录。 +- **身份保护**:Cloudflare Access 与原生 GitHub OAuth 二选一,只允许管理员登录;切换方式仍使用同一份主机资料。 - **加密存储**:主机资料、密码、私钥与指纹通过 AES-256-GCM 加密后保存到 D1,浏览器不持久化连接凭据。 - **指纹确认**:首次连接时展示服务器 SHA-256 指纹,确认后才发送 SSH 凭据。 - **灵活连接**:支持密码、单密码提示的 keyboard-interactive,以及未加密 OpenSSH 私钥认证;支持 UTF-8、GB18030、Big5 终端编码。 @@ -80,7 +80,7 @@ ```text 浏览器 · 主机总览 / xterm.js / SFTP / 进程面板 - │ HTTPS / WebSocket · Cloudflare Access 身份认证 + │ HTTPS / WebSocket · Cloudflare Access 或 GitHub OAuth ▼ Cloudflare Worker ├── 静态资源与 API @@ -101,30 +101,39 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 - Node.js **22.12.0 或更高版本**,以及 npm。 - 可使用 Workers、Durable Objects 与 D1 的 Cloudflare 账户。 -- 已启用 Cloudflare Zero Trust 的账户。无需手动创建 Access 应用,自定义域名可选。 +- 选择 Cloudflare Access 或原生 GitHub OAuth。GitHub 模式不需要开通 Zero Trust,自定义域名可选。 - 一台你有权访问的公网 SSH 服务器。 ### 一次运行,自动配置认证与部署 -你只需准备三项: +在 Fork 的 **Settings > Secrets and variables > Actions** 设置 `AUTH_PROVIDER` Variable,二选一;未填写时默认 `cloudflare`,兼容旧部署。两种模式都将应用部署到 Cloudflare Workers,**不是选择云服务商**。 -1. **启用 Cloudflare Zero Trust**,完成团队域名和账户计划的初始化。 -2. 在 Fork 后仓库的 **Settings > Secrets and variables > Actions** 添加 Secret:`CLOUDFLARE_API_TOKEN`。权限清单见[部署指南](DEPLOYMENT.md#api-token-权限)。 -3. 打开 **Actions > Deploy > Run workflow**,填写管理员邮箱并运行。也可预先将邮箱保存为 `ADMIN_EMAIL` Variable。 +| 配置 | GitHub 位置 | `cloudflare` | `github` | +| --- | --- | --- | --- | +| `AUTH_PROVIDER` | Variable | `cloudflare` | `github` | +| `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填,不需要 Access 权限 | +| `ADMIN_EMAIL` | Variable | 管理员邮箱,也可在 Run workflow 输入 | 不填 | +| `GITHUB_CLIENT_ID` | Variable | 不填 | OAuth App 的 Client ID | +| `GITHUB_CLIENT_SECRET` | Secret | 不填 | OAuth App 的 Client Secret | +| `GITHUB_ADMIN` | Variable | 不填 | 唯一允许登录的 GitHub 用户名 | + +**Cloudflare 模式**:先启用 Zero Trust,然后运行 Action,自动配置 Access 应用、邮箱 Allow 策略和 OTP。 + +**GitHub 模式**:先在 GitHub **Settings > Developer settings > OAuth Apps** 创建一个 OAuth App,回调地址填 `https://你的入口/auth/callback`。工作流会自动解析管理员数字 ID,跳过全部 Zero Trust 配置。入口尚不确定时,可先使用占位回调地址,部署后从 Action 摘要复制正式地址,再回 GitHub 更新。详情见[部署指南](DEPLOYMENT.md)。 ```text -Zero Trust 已启用 + Cloudflare API Token + 管理员邮箱 +选择登录方式 + 填写对应变量/Secret ↓ Run workflow -发现账户与 workers.dev 子域 → 创建/复用 Access 应用、邮箱 Allow 策略与 OTP - → 获取 Team Domain 与 AUD → 创建/复用 D1 → 首次生成加密密钥 +发现账户与 workers.dev 子域 → 仅配置所选认证方式 + → 创建/复用 D1 → 保留管理员资料归属 → 首次生成加密密钥 → 数据库迁移 → 写入 Worker Secrets → 部署 Worker ↓ -在 Actions 运行摘要打开访问地址,用邮箱验证码登录 +在 Actions 运行摘要打开访问地址,使用所选方式登录 ``` 无需手填 Account ID、D1 ID、Team Domain、AUD 或随机密钥。Token 能访问多个账户时,才需要用 `CLOUDFLARE_ACCOUNT_ID` 选择目标账户。 -EdgeSSH 面向**单管理员**,不允许匿名访问。重复运行复用资源和加密密钥,不删除数据;已有 Access 应用的登录方式及人工策略不会被覆盖。以后可在 Access 添加 GitHub 等身份提供程序,无需修改 Worker 的 JWT 校验逻辑。 +EdgeSSH 面向**单管理员**,不允许匿名访问。两种认证不会同时生效;切换方式不改数据库、资料所有者或加密密钥。旧库只有一个所有者时自动沿用原 ID,新库使用固定管理员 ID。若原域名已有 Access 网关,改 GitHub 前需要解除该域名的旧 Access 保护;工作流不会擅自删除安全策略。 ### 获取项目 @@ -148,7 +157,7 @@ npm ci | `CUSTOM_DOMAIN` | `ssh.example.com` | 真正的自定义域名;`*.workers.dev` 必须留空;兼容 Secret,Secret 优先 | | `ACCESS_IDP_IDS` | `一个或多个 IdP UUID,以逗号分隔` | 创建新应用时使用已有 GitHub/其他 IdP,而非自动配置 OTP | -`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`、`ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**,不需要用户复制回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 +`ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**;Cloudflare 模式另存 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`,GitHub 模式同步 `GITHUB_CLIENT_SECRET`。不需要用户复制自动生成的值回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 推送 `main` 或手动运行 `Deploy` 都会执行检查和部署。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。完整权限、旧版迁移及 GitHub 登录扩展说明见[部署指南](DEPLOYMENT.md);手工维护见 [Zero Trust 指南](docs/ZERO_TRUST.md)。 @@ -199,10 +208,10 @@ EdgeSSH/ ### 安全边界 - **不是端到端加密**:Worker 是实际的 SSH 客户端,会在会话内处理明文凭据。请仅部署到可信账户,并使用最小权限的 SSH 账号或密钥。 -- 主机资料绑定 Access 身份;会话使用一次性票据,辅助通道使用附着令牌,并检查同源请求。 +- 主机资料绑定固定管理员 ID,不随认证来源切换;会话使用一次性票据,辅助通道使用附着令牌,并检查同源请求。 - 连接目标仅限公网地址;域名解析后校验目标 IP,降低 SSRF 与 DNS 重绑定风险。 - 地理定位会向外部定位服务发送服务器公网 IP,不发送 SSH 凭据或命令;位置点位**不代表在线状态**。 -- `workers.dev` 与自定义域名都可以作为正式入口,但所选入口必须由对应的 Cloudflare Access 应用保护;不要把未受 Access 保护的地址当作公开 SSH 入口。 +- `workers.dev` 与自定义域名都可以作为正式入口。Cloudflare 模式需要对应 Access 应用;GitHub 模式由 Worker 的管理员授权与 HttpOnly 会话保护,两者都不允许匿名 SSH。 ### 当前支持范围 diff --git a/docs/ZERO_TRUST.md b/docs/ZERO_TRUST.md index 1e269f0..84abc62 100644 --- a/docs/ZERO_TRUST.md +++ b/docs/ZERO_TRUST.md @@ -1,5 +1,7 @@ # Cloudflare Zero Trust Access 配置 +> 本页仅适用于 `AUTH_PROVIDER=cloudflare`。使用原生 `AUTH_PROVIDER=github` 时不需要 Zero Trust,按[部署指南](../DEPLOYMENT.md#github-模式准备)配置 GitHub OAuth App 即可。两种认证互斥,切换不会更改管理员主机资料。 + > **普通部署无需手工执行本页步骤。** 启用 Zero Trust 后,保存 Cloudflare API Token,在 **Actions > Deploy > Run workflow** 输入管理员邮箱,工作流会自动创建/复用 Access 应用、邮箱 Allow 策略、OTP,并获取团队域与 AUD、保存 Worker Secrets。详见[自动部署指南](../DEPLOYMENT.md)。 > > 本页保留控制台操作,供排障、维护既有应用或扩展 GitHub 等登录方式使用。部署脚本不会覆盖已有应用的 IdP 配置。 diff --git a/frontend/src/cloud-api.ts b/frontend/src/cloud-api.ts index f580ea8..f51fd0d 100644 --- a/frontend/src/cloud-api.ts +++ b/frontend/src/cloud-api.ts @@ -47,10 +47,14 @@ export async function api(path: string, method = 'GET', body?: unknown): Prom body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.headers.get('Content-Type')?.includes('application/json')) { - throw new APIError('Access 登录已过期,请刷新页面重新登录。', 401); + window.dispatchEvent(new Event('auth-required')); + throw new APIError('登录已过期,请重新登录。', 401); } const data = await response.json(); - if (!response.ok) throw new APIError(typeof data.error === 'string' ? data.error : '请求失败,请重试。', response.status); + if (!response.ok) { + if (response.status === 401) window.dispatchEvent(new Event('auth-required')); + throw new APIError(typeof data.error === 'string' ? data.error : '请求失败,请重试。', response.status); + } return data as T; } diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 45eb293..bd19b92 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -30,6 +30,7 @@ export class Dashboard { private busy = false; private isHome = true; private paused = false; + private authenticated = false; private returnFocus?: HTMLElement; private readonly dialog: HTMLDialogElement; private readonly form: HTMLFormElement; @@ -41,14 +42,14 @@ export class Dashboard { ›_EdgeSSH - +
@@ -67,7 +68,7 @@ export class Dashboard {
-
${icon('shield')}
凭据留在你的加密保险箱

Cloudflare Access 认证 · D1 加密存储 · 密钥仅在 Worker

AES-256-GCM
+
${icon('shield')}
凭据留在你的加密保险箱

管理员认证 · D1 加密存储 · 密钥仅在 Worker

AES-256-GCM
@@ -101,6 +102,15 @@ export class Dashboard { document.body.prepend(this.root); this.dialog = this.get('.host-dialog'); this.form = this.get('#cloud-host-form'); + this.get('#account-action').addEventListener('click', async (event) => { + if (!this.authenticated) return; + event.preventDefault(); + try { + const { redirect } = await api<{ redirect: string }>('/api/auth/logout', 'POST'); + location.assign(redirect); + } catch (error) { this.notice(error instanceof Error ? error.message : '退出失败,请重试。'); } + }); + window.addEventListener('auth-required', () => this.signedOut()); this.root.querySelectorAll('[data-add]').forEach((button) => button.addEventListener('click', () => this.openEditor())); this.root.querySelectorAll('.close-dialog').forEach((button) => button.addEventListener('click', () => this.closeEditor())); this.dialog.addEventListener('cancel', (event) => { if (this.busy) event.preventDefault(); }); @@ -139,15 +149,17 @@ export class Dashboard { async start(): Promise { try { - const { account } = await api<{ account: { username: string } }>('/api/auth/me'); + const { account, provider } = await api<{ account: { username: string }; provider: string }>('/api/auth/me'); + this.authenticated = true; this.get('#account-label').textContent = account.username; this.get('.account-avatar').textContent = account.username.slice(0, 1).toUpperCase(); + this.get('#auth-provider-label').textContent = provider === 'github' ? 'GitHub' : 'Access'; + const action = this.get('#account-action'); + action.textContent = '退出'; action.title = '退出登录'; action.href = '/api/auth/logout'; await this.refresh(); } catch (error) { - this.notice(error instanceof Error ? error.message : '无法验证 Access 身份。'); - this.get('#account-label').textContent = '未认证'; - this.get('#host-list').textContent = '请先通过 Cloudflare Access 登录。'; - this.root.querySelectorAll('[data-add], #quick-connect, #bottom-quick').forEach((button) => { button.disabled = true; }); + this.signedOut(); + this.notice(error instanceof Error ? error.message : '无法验证管理员身份。'); } try { const { HostGlobe } = await import('./globe'); @@ -193,6 +205,17 @@ export class Dashboard { const notice = this.get('#home-notice'); notice.textContent = message; notice.hidden = false; } + private signedOut(): void { + this.authenticated = false; + this.get('#account-label').textContent = '未登录'; + const action = this.get('#account-action'); + action.href = '/auth/login'; action.textContent = '登录'; action.title = '管理员登录'; + this.setHosts([]); + this.get('#host-list').textContent = '请点击右上角「登录」验证管理员身份。'; + this.root.querySelectorAll('[data-add], #quick-connect, #bottom-quick').forEach((button) => { button.disabled = true; }); + this.show(); + } + private renderFilters(): void { const root = this.get('#host-filters'); root.replaceChildren(); for (const group of ['', ...new Set(this.hosts.map((host) => host.group))]) { diff --git a/scripts/administrator.ts b/scripts/administrator.ts new file mode 100644 index 0000000..c6163d6 --- /dev/null +++ b/scripts/administrator.ts @@ -0,0 +1,20 @@ +import type { CloudflareApi } from './cloudflare-api.ts'; +import type { Database, DeploymentSettings } from './deployment-config.ts'; + +export async function administratorAccountId(api: CloudflareApi, settings: DeploymentSettings, database: Database): Promise { + const path = `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`; + const tables = await api.request<{ success: boolean; results: { name: string }[] }[]>(path, 'POST', { + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'hosts'", + }); + if (!tables[0].success) throw new Error('无法读取管理员资料归属,请重试。'); + if (!tables[0].results.length) return 'admin'; + const result = await api.request<{ success: boolean; results: { account_id: string }[] }[]>(path, 'POST', { + sql: 'SELECT DISTINCT account_id FROM hosts LIMIT 2', + }); + if (!result[0].success) throw new Error('无法读取管理员资料归属,请重试。'); + const owners = result[0].results; + // 沿用旧库唯一所有者,既不搬数据,也不改变 AES-GCM 的 AAD;新库统一使用 admin。 + // 发现多所有者时不猜测,更不能把本来隔离的数据自动交给一个账号。 + if (owners.length > 1) throw new Error('数据库存在多个资料所有者,无法自动转为单管理员;请先明确资料归属。'); + return owners[0]?.account_id || 'admin'; +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts index e9eb710..8f6db37 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -5,8 +5,10 @@ import { parse, stringify } from 'smol-toml'; import { ensureDatabase } from './cloudflare-d1.ts'; import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts'; -import { ensureAccess, resolveWorkerHostname } from './cloudflare-access.ts'; +import { resolveWorkerHostname } from './cloudflare-access.ts'; import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames } from './deployment-secrets.ts'; +import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts'; +import { administratorAccountId } from './administrator.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const generatedConfig = '.wrangler.generated.toml'; @@ -41,19 +43,14 @@ async function main(): Promise { process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId; const existingSecrets = await readWorkerSecretNames(api, settings); const hostname = settings.customDomain || await resolveWorkerHostname(api, settings); - // 旧部署可能由 Zone 级 Access 管理。未提供邮箱且已有两个 Access Secret 时保留原认证, - // 不擅自新建账户级应用覆盖它;新用户仍只需输入邮箱即可走完整自动配置。 - const preserveAccess = !settings.adminEmail - && existingSecrets.has('ACCESS_TEAM_DOMAIN') && existingSecrets.has('ACCESS_AUD'); - if (!preserveAccess && !settings.adminEmail) { - throw new Error('首次部署需要 ADMIN_EMAIL,请在 Run workflow 输入管理员邮箱,或保存为 Actions 变量。'); - } - const accessSecrets = preserveAccess ? {} : await ensureAccess(api, settings, hostname); - console.log(preserveAccess ? '保留已有 Access 认证配置。' : 'Access 应用、身份提供程序与邮箱策略已就绪。'); + const authentication = await prepareAuthentication(api, settings, hostname, existingSecrets); const database = await ensureDatabase(settings); - const secrets = { ...accessSecrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; + const adminAccountId = await administratorAccountId(api, settings, database); + const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; maskSecrets(secrets); - const config = createDeploymentConfig(template, settings, database); + const config = createDeploymentConfig(template, settings, database, { + hostname, adminAccountId, githubAdminId: authentication.githubAdminId, + }); // 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。 await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8'); console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`); @@ -63,13 +60,15 @@ async function main(): Promise { await runWrangler(['deploy']); // Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。 const deployedSecrets = await readWorkerSecretNames(api, settings); - for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) { + for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) { if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`); } console.log(`部署完成:https://${hostname}`); if (process.env.GITHUB_STEP_SUMMARY) { await appendFile(process.env.GITHUB_STEP_SUMMARY, - `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\nD1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥。\n`); + `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n` + + (settings.authProvider === 'github' ? `GitHub OAuth 回调地址:https://${hostname}/auth/callback\n\n` : '') + + 'D1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥与管理员资料。\n'); } } diff --git a/scripts/deployment-auth.ts b/scripts/deployment-auth.ts new file mode 100644 index 0000000..30938f7 --- /dev/null +++ b/scripts/deployment-auth.ts @@ -0,0 +1,44 @@ +import { ensureAccess } from './cloudflare-access.ts'; +import type { CloudflareApi } from './cloudflare-api.ts'; +import type { DeploymentSettings, RuntimeSecrets } from './deployment-config.ts'; + +export function requiredAuthSecrets(provider: DeploymentSettings['authProvider']): string[] { + return provider === 'github' ? ['GITHUB_CLIENT_SECRET'] : ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']; +} + +export async function prepareAuthentication( + api: CloudflareApi, + settings: DeploymentSettings, + hostname: string, + existing: Set, + fetcher: typeof fetch = fetch, +): Promise<{ secrets: Partial; githubAdminId?: string }> { + if (settings.authProvider === 'cloudflare') { + const preserve = !settings.adminEmail && requiredAuthSecrets('cloudflare').every((name) => existing.has(name)); + if (preserve) { + console.log('保留已有 Cloudflare Access 认证配置。'); + return { secrets: {} }; + } + if (!settings.adminEmail) throw new Error('首次配置 Cloudflare 登录需要 ADMIN_EMAIL。'); + return { secrets: await ensureAccess(api, settings, hostname) }; + } + // GitHub 新部署完全不调用 Zero Trust API。旧域名若仍在 Access 后面,明确停止, + // 不擅自删除用户的安全配置,也不把“双重登录”误报为部署成功。 + if (existing.has('ACCESS_AUD')) { + const response = await fetcher(`https://${hostname}/api/auth/me`, { redirect: 'manual', signal: AbortSignal.timeout(30_000) }); + const location = response.headers.get('Location'); + if (location && new URL(location, `https://${hostname}`).hostname.endsWith('.cloudflareaccess.com')) { + throw new Error('当前域名仍由 Cloudflare Access 保护。请先在 Access 解除该域名的保护,再切换 GitHub;不会删除主机资料或加密密钥。'); + } + } + const response = await fetcher(`https://api.github.com/users/${settings.githubAdmin}`, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`无法确认 GITHUB_ADMIN(HTTP ${response.status}),请检查用户名或稍后重试。`); + const user = await response.json() as { id: number; type: string }; + if (user.type !== 'User' || !Number.isSafeInteger(user.id) || user.id <= 0) { + throw new Error('GITHUB_ADMIN 必须是个人 GitHub 账号,不能是组织。'); + } + return { githubAdminId: String(user.id), secrets: { GITHUB_CLIENT_SECRET: settings.secrets.GITHUB_CLIENT_SECRET! } }; +} diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts index 0d46eae..483fcb3 100644 --- a/scripts/deployment-config.ts +++ b/scripts/deployment-config.ts @@ -1,6 +1,6 @@ import type { TomlTable } from 'smol-toml'; -export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] as const; +export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD', 'GITHUB_CLIENT_SECRET'] as const; export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>; export interface DeploymentSettings { @@ -10,6 +10,9 @@ export interface DeploymentSettings { databaseName: string; databaseId?: string; customDomain?: string; + authProvider: 'cloudflare' | 'github'; + githubClientId?: string; + githubAdmin?: string; adminEmail?: string; identityProviderIds: string[]; secrets: Partial; @@ -24,7 +27,9 @@ export function readDeploymentSettings( template: TomlTable, env: NodeJS.ProcessEnv, ): DeploymentSettings { - const required = ['CLOUDFLARE_API_TOKEN']; + const authProvider = env.AUTH_PROVIDER?.trim() || 'cloudflare'; + if (authProvider !== 'cloudflare' && authProvider !== 'github') throw new Error('AUTH_PROVIDER 只能是 cloudflare 或 github。'); + const required = ['CLOUDFLARE_API_TOKEN', ...(authProvider === 'github' ? ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET', 'GITHUB_ADMIN'] : [])]; const missing = required.filter((name) => !env[name]?.trim()); if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`); @@ -52,15 +57,23 @@ export function readDeploymentSettings( throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。'); } - const adminEmail = env.ADMIN_EMAIL?.trim().toLowerCase(); + const adminEmail = authProvider === 'cloudflare' ? env.ADMIN_EMAIL?.trim().toLowerCase() : undefined; if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) { throw new Error('ADMIN_EMAIL 必须是一个完整的管理员邮箱,不能填写多个邮箱或整个域名。'); } - const identityProviderIds = [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))]; + const identityProviderIds = authProvider === 'cloudflare' + ? [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))] : []; if (identityProviderIds.some((id) => !/^[a-f0-9-]{36}$/i.test(id))) { throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。'); } - const secrets = Object.fromEntries(runtimeSecretNames + const githubClientId = authProvider === 'github' ? env.GITHUB_CLIENT_ID!.trim() : undefined; + const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN!.trim().toLowerCase() : undefined; + if (githubAdmin && !/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(githubAdmin)) { + throw new Error('GITHUB_ADMIN 必须是一个 GitHub 用户名。'); + } + const selectedSecrets = authProvider === 'github' + ? ['ENCRYPTION_KEY', 'GITHUB_CLIENT_SECRET'] : ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']; + const secrets = Object.fromEntries(selectedSecrets .filter((name) => env[name]?.trim()) .map((name) => [name, env[name]!.trim()])) as Partial; if (secrets.ENCRYPTION_KEY) { @@ -80,7 +93,7 @@ export function readDeploymentSettings( return { accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName, - databaseName, databaseId, customDomain, adminEmail, identityProviderIds, secrets, + databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, adminEmail, identityProviderIds, secrets, }; } @@ -88,6 +101,7 @@ export function createDeploymentConfig( template: TomlTable, settings: DeploymentSettings, database: Database, + runtime?: { hostname: string; adminAccountId: string; githubAdminId?: string }, ): TomlTable { return { ...template, @@ -96,6 +110,15 @@ export function createDeploymentConfig( // 自定义入口启用时关闭备用公网入口,避免绕过对应的 Access 登录页。 workers_dev: !settings.customDomain, preview_urls: false, + vars: { + ...template.vars as TomlTable, + AUTH_PROVIDER: settings.authProvider, + ...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}`, ADMIN_ACCOUNT_ID: runtime.adminAccountId } : {}), + ...(settings.authProvider === 'github' ? { + GITHUB_CLIENT_ID: settings.githubClientId!, + ...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}), + } : {}), + }, ...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}), d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB' ? { ...binding, database_name: database.name, database_id: database.uuid } diff --git a/src/accounts/auth-provider.ts b/src/accounts/auth-provider.ts new file mode 100644 index 0000000..5d19789 --- /dev/null +++ b/src/accounts/auth-provider.ts @@ -0,0 +1,8 @@ +import type { Env } from '../types.ts'; +import { APIError } from './http.ts'; + +export function authProvider(env: Pick): 'cloudflare' | 'github' { + const provider = env.AUTH_PROVIDER || 'cloudflare'; + if (provider !== 'cloudflare' && provider !== 'github') throw new APIError('管理员尚未正确配置登录方式。', 503); + return provider; +} diff --git a/src/accounts/auth-routes.ts b/src/accounts/auth-routes.ts new file mode 100644 index 0000000..051462b --- /dev/null +++ b/src/accounts/auth-routes.ts @@ -0,0 +1,21 @@ +import type { Env } from '../types.ts'; +import { authProvider } from './auth-provider.ts'; +import { githubCallback, githubLogin, clearGithubCookies } from './github-auth.ts'; +import { json } from './http.ts'; + +export async function authRoute(request: Request, env: Env): Promise { + const path = new URL(request.url).pathname; + if (!['/auth/login', '/auth/callback', '/api/auth/logout'].includes(path)) return null; + const provider = authProvider(env); + if (path === '/api/auth/logout') { + if (request.method !== 'POST') return json({ error: '请使用 POST 退出登录。' }, 405); + return clearGithubCookies(json({ redirect: provider === 'cloudflare' ? '/cdn-cgi/access/logout' : '/' })); + } + if (request.method !== 'GET') return json({ error: 'Method not allowed' }, 405); + if (provider === 'github') { + return path === '/auth/login' ? await githubLogin(request, env) : await githubCallback(request, env); + } + // Cloudflare 模式不接受 GitHub 回调或会话;登录入口由域名前的 Access 网关处理。 + return path === '/auth/login' ? new Response(null, { status: 302, headers: { Location: '/', 'Cache-Control': 'no-store' } }) + : json({ error: '当前未启用 GitHub 登录。' }, 404); +} diff --git a/src/accounts/auth.ts b/src/accounts/auth.ts index c7d9e9b..74c4e0c 100644 --- a/src/accounts/auth.ts +++ b/src/accounts/auth.ts @@ -1,12 +1,21 @@ import { createRemoteJWKSet, jwtVerify } from 'jose'; -import type { Env } from '../types'; -import { accessToken } from './access-token'; -import { APIError } from './http'; +import type { Env } from '../types.ts'; +import { accessToken } from './access-token.ts'; +import { APIError } from './http.ts'; +import { authProvider } from './auth-provider.ts'; +import { githubAccount } from './github-auth.ts'; export interface Account { id: string; username: string } const resolvers = new Map>(); export async function currentAccount(request: Request, env: Env): Promise { + const identity = authProvider(env) === 'github' + ? await githubAccount(request, env) : await accessAccount(request, env); + // 本项目只有一个管理员。认证来源可切换,资料的加密 AAD 与所有者 ID 必须保持不变。 + return { id: env.ADMIN_ACCOUNT_ID || identity.id, username: identity.username }; +} + +async function accessAccount(request: Request, env: Env): Promise { if (!env.ACCESS_AUD || !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(env.ACCESS_TEAM_DOMAIN ?? '')) { throw new APIError('管理员尚未配置 Zero Trust Access。', 503); } diff --git a/src/accounts/github-auth.ts b/src/accounts/github-auth.ts new file mode 100644 index 0000000..c1e2032 --- /dev/null +++ b/src/accounts/github-auth.ts @@ -0,0 +1,141 @@ +import { base64url, jwtVerify, SignJWT, type JWTPayload } from 'jose'; +import type { Env } from '../types.ts'; +import { APIError, apiFailure } from './http.ts'; +import { secureResponse } from '../http-security.ts'; + +const sessionCookie = '__Host-edgessh-session'; +const flowCookie = '__Host-edgessh-oauth'; +const sessionSeconds = 8 * 60 * 60; +const flowSeconds = 10 * 60; +const encoder = new TextEncoder(); +type GitHubConfig = Env & Required>; + +function config(env: Env): GitHubConfig { + if (!env.APP_ORIGIN || !env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET || !env.GITHUB_ADMIN_ID || !env.ENCRYPTION_KEY) { + throw new APIError('管理员尚未配置 GitHub 登录。', 503); + } + return env as GitHubConfig; +} + +function cookie(request: Request, name: string): string | undefined { + return request.headers.get('Cookie')?.split(';').map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1); +} + +function setCookie(name: string, value: string, maxAge: number): string { + return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; +} + +// 使用 HKDF 做用途隔离,不直接把资料加密密钥用于签名,也不新增用户需要维护的密钥。 +async function signingKey(secret: string): Promise { + const material = await crypto.subtle.importKey('raw', new Uint8Array(base64url.decode(secret)), 'HKDF', false, ['deriveBits']); + return new Uint8Array(await crypto.subtle.deriveBits({ + name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), + info: encoder.encode('github-oauth-cookie'), + }, material, 256)); +} + +function audience(env: GitHubConfig, purpose: string): string { + return `edgessh:${purpose}:${env.GITHUB_CLIENT_ID}:${env.GITHUB_ADMIN_ID}`; +} + +async function sign(env: GitHubConfig, purpose: string, payload: JWTPayload, seconds: number): Promise { + return new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuer(env.APP_ORIGIN) + .setAudience(audience(env, purpose)).setIssuedAt().setExpirationTime(`${seconds}s`) + .sign(await signingKey(env.ENCRYPTION_KEY)); +} + +async function verify(env: GitHubConfig, purpose: string, token: string): Promise { + const { payload } = await jwtVerify(token, await signingKey(env.ENCRYPTION_KEY), { + issuer: env.APP_ORIGIN, audience: audience(env, purpose), algorithms: ['HS256'], requiredClaims: ['exp', 'iat'], + }); + return payload; +} + +function redirect(location: string, cookies: string[] = []): Response { + const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' }); + for (const value of cookies) headers.append('Set-Cookie', value); + return secureResponse(new Response(null, { status: 302, headers })); +} + +export async function githubLogin(request: Request, env: Env): Promise { + const settings = config(env); + if (new URL(request.url).origin !== settings.APP_ORIGIN) throw new APIError('请从配置的正式入口登录。', 400); + const state = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); + const verifier = base64url.encode(crypto.getRandomValues(new Uint8Array(32))); + const challenge = base64url.encode(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(verifier)))); + const url = new URL('https://github.com/login/oauth/authorize'); + // 只验证公开身份,不申请仓库、邮箱或组织权限。 + url.search = new URLSearchParams({ + client_id: settings.GITHUB_CLIENT_ID, redirect_uri: `${settings.APP_ORIGIN}/auth/callback`, + state, code_challenge: challenge, code_challenge_method: 'S256', scope: '', + }).toString(); + const flow = await sign(settings, 'flow', { state, verifier }, flowSeconds); + return redirect(url.toString(), [setCookie(flowCookie, flow, flowSeconds)]); +} + +export async function githubCallback(request: Request, env: Env, fetcher: typeof fetch = fetch): Promise { + let response: Response; + try { + const settings = config(env); + const url = new URL(request.url); + if (url.origin !== settings.APP_ORIGIN) throw new APIError('登录回调地址不匹配。', 400); + const saved = cookie(request, flowCookie); + const code = url.searchParams.get('code'); + if (!saved || !code || url.searchParams.has('error')) throw new APIError('GitHub 登录已取消或过期,请重新登录。', 401); + let flow: JWTPayload; + try { flow = await verify(settings, 'flow', saved); } + catch { throw new APIError('登录请求已过期,请重新登录。', 401); } + if (typeof flow.state !== 'string' || flow.state !== url.searchParams.get('state') || typeof flow.verifier !== 'string') { + throw new APIError('登录请求不匹配,请重新登录。', 401); + } + const tokenResponse = await fetcher('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: settings.GITHUB_CLIENT_ID, client_secret: settings.GITHUB_CLIENT_SECRET, + redirect_uri: `${settings.APP_ORIGIN}/auth/callback`, code, code_verifier: flow.verifier, + }), + signal: AbortSignal.timeout(15_000), + }); + if (!tokenResponse.ok) throw new APIError('GitHub 登录服务暂时不可用,请重试。', 502); + const token = await tokenResponse.json() as { access_token?: string }; + if (!token.access_token) throw new APIError('GitHub 授权码无效或已使用,请重新登录。', 401); + const userResponse = await fetcher('https://api.github.com/user', { + headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' }, + signal: AbortSignal.timeout(15_000), + }); + if (!userResponse.ok) throw new APIError('无法验证 GitHub 身份,请重试。', 502); + const user = await userResponse.json() as { id: number; login: string }; + // 以数字 ID 授权,不信任可改名的用户名,也不把任意 GitHub 用户视为管理员。 + if (!Number.isSafeInteger(user.id) || String(user.id) !== settings.GITHUB_ADMIN_ID || typeof user.login !== 'string') { + throw new APIError('此 GitHub 账号不是本实例的管理员。', 403); + } + const session = await sign(settings, 'session', { sub: String(user.id), username: user.login }, sessionSeconds); + // GitHub access_token 只在本次验证中使用,不写数据库、Cookie 或日志。 + response = redirect('/', [setCookie(sessionCookie, session, sessionSeconds)]); + } catch (error) { + response = apiFailure(error); + } + response.headers.append('Set-Cookie', setCookie(flowCookie, '', 0)); + return response; +} + +export async function githubAccount(request: Request, env: Env): Promise<{ id: string; username: string }> { + const settings = config(env); + const token = cookie(request, sessionCookie); + if (!token) throw new APIError('请先使用 GitHub 登录。', 401); + try { + const payload = await verify(settings, 'session', token); + if (payload.sub !== settings.GITHUB_ADMIN_ID || typeof payload.username !== 'string') throw new Error('Invalid identity'); + return { id: `github:${payload.sub}`, username: payload.username }; + } catch { + throw new APIError('登录已过期,请重新登录。', 401); + } +} + +export function clearGithubCookies(response: Response): Response { + response.headers.append('Set-Cookie', setCookie(sessionCookie, '', 0)); + response.headers.append('Set-Cookie', setCookie(flowCookie, '', 0)); + return response; +} diff --git a/src/accounts/http.ts b/src/accounts/http.ts index 11c1ce8..cea575c 100644 --- a/src/accounts/http.ts +++ b/src/accounts/http.ts @@ -1,7 +1,8 @@ -import { jsonError, secureResponse } from '../http-security'; +import { jsonError, secureResponse } from '../http-security.ts'; export class APIError extends Error { - constructor(message: string, readonly status = 400) { super(message); } + readonly status: number; + constructor(message: string, status = 400) { super(message); this.status = status; } } export function json(value: unknown, status = 200, headers?: HeadersInit): Response { diff --git a/src/types.ts b/src/types.ts index 437cd37..ffdeb43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,8 +49,14 @@ export interface TerminalSize { export interface Env { DB: D1Database; ENCRYPTION_KEY: string; - ACCESS_TEAM_DOMAIN: string; - ACCESS_AUD: string; + AUTH_PROVIDER?: string; + ADMIN_ACCOUNT_ID?: string; + APP_ORIGIN?: string; + ACCESS_TEAM_DOMAIN?: string; + ACCESS_AUD?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + GITHUB_ADMIN_ID?: string; SSH_SESSIONS: DurableObjectNamespace; ASSETS?: Fetcher; CONNECT_TIMEOUT_MS?: string; diff --git a/src/worker.ts b/src/worker.ts index 1f50118..f8e66c5 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2,6 +2,8 @@ import type { Env } from './types'; import { SSHSessionDO } from './backend/durable-object'; import { corsPreflightResponse, corsResponse, httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security'; import { currentAccount } from './accounts/auth'; +import { authRoute } from './accounts/auth-routes'; +import { authProvider } from './accounts/auth-provider'; import { hostsRoute } from './accounts/hosts'; import { apiFailure, json } from './accounts/http'; import { locateHost } from './accounts/location'; @@ -145,6 +147,8 @@ export default { if (url.pathname === '/api/health' && request.method === 'GET') { return corsResponse(secureResponse(Response.json({ status: 'ok', runtime: 'cloudflare-workers', ssh: true }, { headers: { 'Cache-Control': 'no-store' } }))); } + const authentication = await authRoute(request, env); + if (authentication) return secureResponse(authentication); if (url.hostname.endsWith('.workers.dev') && url.pathname === '/api/diagnostics/location' && request.method === 'GET') { const location = await locateHost('8.8.8.8'); if (!location) return corsResponse(jsonError('位置服务暂时不可用。', 503)); @@ -152,7 +156,7 @@ export default { } const account = isApiRequest ? await currentAccount(request, env) : null; if (isApiRequest && !account) return jsonError('请先登录。', 401); - if (url.pathname === '/api/auth/me' && request.method === 'GET') return json({ account }); + if (url.pathname === '/api/auth/me' && request.method === 'GET') return json({ account, provider: authProvider(env) }); if (url.pathname.startsWith('/api/hosts')) return await hostsRoute(request, env, account!.id, url.pathname); if (url.pathname === '/api/session') { if (request.method !== 'POST') return corsResponse(jsonError('Method not allowed', 405)); diff --git a/tests/deployment-auth.test.ts b/tests/deployment-auth.test.ts new file mode 100644 index 0000000..8f42614 --- /dev/null +++ b/tests/deployment-auth.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { parse, stringify } from 'smol-toml'; +import { CloudflareApi } from '../scripts/cloudflare-api.ts'; +import { administratorAccountId } from '../scripts/administrator.ts'; +import { prepareAuthentication, requiredAuthSecrets } from '../scripts/deployment-auth.ts'; +import { createDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts'; + +const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8')); +const environment = { + CLOUDFLARE_API_TOKEN: 'cf-token', AUTH_PROVIDER: 'github', + GITHUB_CLIENT_ID: 'test-client', GITHUB_CLIENT_SECRET: 'test-client-secret', GITHUB_ADMIN: 'admin', +}; +const settings = readDeploymentSettings(template, environment); +const database = { uuid: 'test-db', name: settings.databaseName }; +const json = (result: unknown) => Response.json({ success: true, result }); + +test('each deployment mode validates only its own inputs', () => { + assert.deepEqual(settings.identityProviderIds, []); + assert.equal(readDeploymentSettings(template, { ...environment, ADMIN_EMAIL: 'invalid', ACCESS_TEAM_DOMAIN: 'invalid', ACCESS_IDP_IDS: 'invalid' }).authProvider, 'github'); + assert.equal(readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token', AUTH_PROVIDER: 'cloudflare', GITHUB_ADMIN: 'invalid value' }).authProvider, 'cloudflare'); + for (const field of ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET', 'GITHUB_ADMIN']) { + assert.throws(() => readDeploymentSettings(template, { ...environment, [field]: '' }), new RegExp(field)); + } + assert.throws(() => readDeploymentSettings(template, { ...environment, AUTH_PROVIDER: 'other' }), /AUTH_PROVIDER/); +}); + +test('GitHub deployment uses no Zero Trust API and resolves a stable numeric administrator ID', async () => { + const cloudflare = new CloudflareApi('token', async () => { throw Error('must not call Zero Trust'); }); + const prepared = await prepareAuthentication(cloudflare, settings, 'ssh.example.com', new Set(), async (url) => { + assert.equal(String(url), 'https://api.github.com/users/admin'); + return Response.json({ id: 123, type: 'User' }); + }); + assert.equal(prepared.githubAdminId, '123'); + assert.deepEqual(prepared.secrets, { GITHUB_CLIENT_SECRET: environment.GITHUB_CLIENT_SECRET }); + assert.deepEqual(requiredAuthSecrets('github'), ['GITHUB_CLIENT_SECRET']); + const config = createDeploymentConfig(template, settings, database, { hostname: 'ssh.example.com', adminAccountId: 'old-owner', githubAdminId: '123' }); + const vars = config.vars as Record; + assert.equal(vars.AUTH_PROVIDER, 'github'); + assert.equal(vars.ADMIN_ACCOUNT_ID, 'old-owner'); + assert.equal(vars.APP_ORIGIN, 'https://ssh.example.com'); + assert.equal(vars.GITHUB_ADMIN_ID, '123'); + assert.equal(stringify(config).includes(environment.GITHUB_CLIENT_SECRET), false); +}); + +test('organization names and unresolved GitHub administrators are rejected', async () => { + const cloudflare = new CloudflareApi('token'); + for (const response of [new Response('', { status: 404 }), Response.json({ id: 123, type: 'Organization' })]) { + await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', new Set(), async () => response), /GITHUB_ADMIN/); + } +}); + +test('a mode switch stops rather than leaving GitHub behind an existing Access login', async () => { + const cloudflare = new CloudflareApi('token', async () => { throw Error('must not mutate Access'); }); + await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', new Set(['ACCESS_AUD']), async () => + new Response(null, { status: 302, headers: { Location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' } })), /解除/); +}); + +test('legacy Cloudflare secrets are retained without requiring GitHub values', async () => { + const cf = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token' }); + const api = new CloudflareApi('token', async () => { throw Error('must not overwrite existing Access'); }); + assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com', new Set(['ACCESS_AUD', 'ACCESS_TEAM_DOMAIN'])), { secrets: {} }); +}); + +test('single administrator keeps the original owner ID across provider switches without any data writes', async () => { + for (const owners of [[], [{ account_id: 'legacy-sub' }], [{ account_id: 'admin' }]]) { + const api = new CloudflareApi('token', async (_url, init) => { + const { sql } = JSON.parse(String(init?.body)); + assert.ok(sql.startsWith('SELECT ')); + return json([{ success: true, results: sql.includes('sqlite_master') ? [{ name: 'hosts' }] : owners }]); + }); + assert.equal(await administratorAccountId(api, settings, database), owners[0]?.account_id || 'admin'); + } + const empty = new CloudflareApi('token', async () => json([{ success: true, results: [] }])); + assert.equal(await administratorAccountId(empty, settings, database), 'admin'); +}); + +test('multiple previous owners are never silently merged into an administrator', async () => { + const api = new CloudflareApi('token', async (_url, init) => { + const { sql } = JSON.parse(String(init?.body)); + return json([{ success: true, results: sql.includes('sqlite_master') ? [{ name: 'hosts' }] : [{ account_id: 'first' }, { account_id: 'second' }] }]); + }); + await assert.rejects(administratorAccountId(api, settings, database), /多个资料所有者/); +}); diff --git a/tests/deployment-bootstrap.test.ts b/tests/deployment-bootstrap.test.ts index 40bdb3c..227ab4d 100644 --- a/tests/deployment-bootstrap.test.ts +++ b/tests/deployment-bootstrap.test.ts @@ -8,7 +8,7 @@ import type { DeploymentSettings } from '../scripts/deployment-config.ts'; const settings: DeploymentSettings = { accountId: 'a'.repeat(32), apiToken: 'test-token', workerName: 'edgessh', databaseName: 'edgessh-accounts', adminEmail: 'admin@example.com', - identityProviderIds: [], secrets: {}, + authProvider: 'cloudflare', identityProviderIds: [], secrets: {}, }; const database = { uuid: 'test-database', name: settings.databaseName }; const json = (result: unknown) => Response.json({ success: true, result }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 0000000..1825f84 --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { base64url, decodeJwt, exportJWK, generateKeyPair, SignJWT } from 'jose'; +import type { Env } from '../src/types.ts'; +import { currentAccount } from '../src/accounts/auth.ts'; +import { authRoute } from '../src/accounts/auth-routes.ts'; +import { githubCallback, githubLogin } from '../src/accounts/github-auth.ts'; +import { encryptHost, decryptHost } from '../src/accounts/crypto.ts'; + +const origin = 'https://ssh.example.com'; +const env = { + AUTH_PROVIDER: 'github', APP_ORIGIN: origin, ADMIN_ACCOUNT_ID: 'legacy-access-owner', + ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), + GITHUB_CLIENT_ID: 'test-client', GITHUB_CLIENT_SECRET: 'test-client-secret', GITHUB_ADMIN_ID: '123', + ACCESS_TEAM_DOMAIN: 'test-oauth.cloudflareaccess.com', ACCESS_AUD: 'access-audience', +} as Env; +const request = (path: string, cookie = '', headers = {}) => new Request(`${origin}${path}`, { + headers: { Cookie: cookie, ...headers }, +}); +const cookieValue = (response: Response, name: string) => + response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${name}=`))!.split(';')[0]; + +async function start() { + const response = await githubLogin(request('/auth/login'), env); + const authorization = new URL(response.headers.get('Location')!); + return { response, authorization, cookie: cookieValue(response, '__Host-edgessh-oauth') }; +} + +function exchange(id = 123): typeof fetch { + return async (url, init) => { + if (String(url).endsWith('/access_token')) { + assert.equal(init?.method, 'POST'); + const body = init?.body as URLSearchParams; + assert.equal(body.get('client_secret'), env.GITHUB_CLIENT_SECRET); + assert.equal(body.get('redirect_uri'), `${origin}/auth/callback`); + assert.equal(body.get('code_verifier')?.length, 43); + return Response.json({ access_token: 'private-github-token' }); + } + assert.equal(String(url), 'https://api.github.com/user'); + assert.equal((init?.headers as Record).Authorization, 'Bearer private-github-token'); + return Response.json({ id, login: 'administrator' }); + }; +} + +async function login() { + const flow = await start(); + const response = await githubCallback(request(`/auth/callback?code=valid&state=${flow.authorization.searchParams.get('state')}`, flow.cookie), env, exchange()); + assert.equal(response.status, 302); + return { response, cookie: cookieValue(response, '__Host-edgessh-session') }; +} + +test('OAuth starts with random state, S256 PKCE and no privileged scopes', async () => { + const first = await start(); + const second = await start(); + assert.equal(first.authorization.origin, 'https://github.com'); + assert.equal(first.authorization.searchParams.get('redirect_uri'), `${origin}/auth/callback`); + assert.equal(first.authorization.searchParams.get('scope'), ''); + assert.equal(first.authorization.searchParams.get('code_challenge_method'), 'S256'); + assert.notEqual(first.authorization.searchParams.get('state'), second.authorization.searchParams.get('state')); + const payload = decodeJwt(first.cookie.split('=')[1]); + const challenge = base64url.encode(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(payload.verifier))))); + assert.equal(challenge, first.authorization.searchParams.get('code_challenge')); + assert.equal(payload.state, first.authorization.searchParams.get('state')); + assert.ok(first.response.headers.get('Set-Cookie')?.includes('Secure; HttpOnly; SameSite=Lax')); + assert.equal(first.authorization.toString().includes(env.GITHUB_CLIENT_SECRET!), false); +}); + +test('callback binds state to its browser cookie before any token exchange', async () => { + const flow = await start(); + let calls = 0; + const fetcher: typeof fetch = async () => { calls++; throw Error('must not fetch'); }; + for (const [query, cookie] of [ + ['code=valid&state=wrong', flow.cookie], + [`code=valid&state=${flow.authorization.searchParams.get('state')}`, ''], + [`code=valid&state=${flow.authorization.searchParams.get('state')}`, `${flow.cookie}tampered`], + ['error=access_denied', flow.cookie], + ]) { + const response = await githubCallback(request(`/auth/callback?${query}`, cookie), env, fetcher); + assert.equal(response.status, 401); + assert.match(response.headers.get('Set-Cookie')!, /Max-Age=0/); + } + assert.equal(calls, 0); +}); + +test('only the numeric GitHub administrator receives a session', async () => { + const flow = await start(); + const denied = await githubCallback(request(`/auth/callback?code=valid&state=${flow.authorization.searchParams.get('state')}`, flow.cookie), env, exchange(456)); + assert.equal(denied.status, 403); + assert.equal(denied.headers.getSetCookie().some((value) => value.startsWith('__Host-edgessh-session=')), false); + const { response, cookie } = await login(); + const account = await currentAccount(request('/api/auth/me', cookie), env); + assert.deepEqual(account, { id: env.ADMIN_ACCOUNT_ID, username: 'administrator' }); + assert.equal(response.headers.get('Location'), '/'); + assert.equal(cookie.includes('private-github-token'), false); + assert.equal(decodeJwt(cookie.split('=')[1]).sub, '123'); +}); + +test('wrong signing key, tampered cookies and changed administrator cannot access APIs', async () => { + const { cookie } = await login(); + for (const [token, settings] of [ + [`${cookie}tampered`, env], + [cookie, { ...env, ENCRYPTION_KEY: Buffer.alloc(32, 8).toString('base64') }], + [cookie, { ...env, GITHUB_ADMIN_ID: '456' }], + [cookie, { ...env, APP_ORIGIN: 'https://another.example.com' }], + ] as const) { + await assert.rejects(currentAccount(request('/api/hosts', token), settings), (error: Error & { status: number }) => error.status === 401); + } +}); + +test('flow cookie is not a session and providers never accept each others credentials', async () => { + const flow = await start(); + await assert.rejects(currentAccount(request('/api/hosts', flow.cookie.replace('__Host-edgessh-oauth=', '__Host-edgessh-session=')), env), /过期/); + await assert.rejects(currentAccount(request('/api/hosts', 'CF_Authorization=access-token', { 'Cf-Access-Jwt-Assertion': 'access-token' }), env), /GitHub 登录/); + const { cookie } = await login(); + await assert.rejects(currentAccount(request('/api/hosts', cookie), { ...env, AUTH_PROVIDER: 'cloudflare' }), /Cloudflare Access/); + await assert.rejects(currentAccount(request('/api/hosts', cookie), { ...env, AUTH_PROVIDER: 'unknown' }), /登录方式/); + const callback = await authRoute(request('/auth/callback?code=anything'), { ...env, AUTH_PROVIDER: 'cloudflare' }); + assert.equal(callback?.status, 404); +}); + +test('OAuth rejects an unexpected callback origin and hides upstream errors', async () => { + await assert.rejects(githubLogin(new Request('https://wrong.example.com/auth/login'), env), /正式入口/); + assert.equal((await githubCallback(new Request('https://wrong.example.com/auth/callback?code=x'), env)).status, 400); + const flow = await start(); + const response = await githubCallback(request(`/auth/callback?code=valid&state=${flow.authorization.searchParams.get('state')}`, flow.cookie), env, + async () => { throw Error('client-secret-and-token-in-internal-error'); }); + assert.equal(response.status, 500); + assert.equal((await response.text()).includes('client-secret'), false); +}); + +test('logout clears both cookies, and Cloudflare mode delegates only its own logout', async () => { + const req = new Request(`${origin}/api/auth/logout`, { method: 'POST', headers: { Origin: origin } }); + for (const provider of ['github', 'cloudflare']) { + const response = (await authRoute(req, { ...env, AUTH_PROVIDER: provider }))!; + assert.equal(response.headers.getSetCookie().length, 2); + assert.ok(response.headers.getSetCookie().every((value) => value.includes('Max-Age=0'))); + assert.equal((await response.json() as { redirect: string }).redirect, provider === 'github' ? '/' : '/cdn-cgi/access/logout'); + } + assert.equal((await authRoute(request('/api/auth/logout'), env))?.status, 405); +}); + +test('Cloudflare and GitHub administrators decrypt the same existing host without rewriting data', async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ keys: [{ ...jwk, kid: 'test', alg: 'RS256' }] }); + try { + const token = await new SignJWT({ email: 'admin@example.com' }).setProtectedHeader({ alg: 'RS256', kid: 'test' }) + .setIssuer(`https://${env.ACCESS_TEAM_DOMAIN}`).setAudience(env.ACCESS_AUD!).setSubject('original-subject').setExpirationTime('5m').sign(privateKey); + const cloudflare = await currentAccount(request('/api/auth/me', `CF_Authorization=${token}`), { ...env, AUTH_PROVIDER: 'cloudflare' }); + const { cookie } = await login(); + const github = await currentAccount(request('/api/auth/me', cookie), env); + assert.equal(cloudflare.id, github.id); + const secret = { host: 'example.com', password: 'test-password' }; + const ciphertext = await encryptHost(secret, env.ENCRYPTION_KEY, cloudflare.id, 'host-1'); + assert.deepEqual(await decryptHost(ciphertext, env.ENCRYPTION_KEY, github.id, 'host-1'), secret); + } finally { globalThis.fetch = originalFetch; } +}); + +test('expired sessions are rejected even with a correct signature', async () => { + const encoder = new TextEncoder(); + const material = await crypto.subtle.importKey('raw', base64url.decode(env.ENCRYPTION_KEY), 'HKDF', false, ['deriveBits']); + const key = new Uint8Array(await crypto.subtle.deriveBits({ + name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), info: encoder.encode('github-oauth-cookie'), + }, material, 256)); + const token = await new SignJWT({ sub: '123', username: 'admin' }).setProtectedHeader({ alg: 'HS256' }) + .setIssuer(origin).setAudience(`edgessh:session:${env.GITHUB_CLIENT_ID}:123`) + .setIssuedAt(1).setExpirationTime(2).sign(key); + await assert.rejects(currentAccount(request('/api/auth/me', `__Host-edgessh-session=${token}`), env), /过期/); +}); diff --git a/vite.config.ts b/vite.config.ts index 673fdfa..db3fd3d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ server: { port: 5173, proxy: { + '/auth': { + target: 'http://127.0.0.1:8787', + }, '/api': { target: 'http://127.0.0.1:8787', ws: true,