From c6e5786bbaaba84ed1f3b8512674319ab80287fd Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Wed, 23 Sep 2026 07:08:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=9B=BA=E5=AE=9A=E5=8D=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=B9=B6?= =?UTF-8?q?=E6=92=A4=E9=94=80=E6=97=A7=E4=BC=9A=E8=AF=9D=20/=20stabilize?= =?UTF-8?q?=20admin=20workspace=20and=20revoke=20stale=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- .github/workflows/deploy.yml | 1 + DEPLOYMENT.md | 24 ++-- README.md | 12 +- docs/ZERO_TRUST.md | 4 +- migrations/0002_workspace_state.sql | 17 +++ scripts/administrator.ts | 20 --- scripts/deploy.ts | 21 ++- scripts/deployment-auth.ts | 37 +++-- scripts/deployment-config.ts | 16 ++- scripts/deployment-secrets.ts | 15 ++ scripts/workspace-state.ts | 116 ++++++++++++++++ src/accounts/auth-routes.ts | 5 + src/accounts/auth.ts | 25 ++-- src/accounts/github-auth.ts | 14 +- src/accounts/workspace.ts | 40 ++++++ src/http-security.ts | 4 + src/types.ts | 1 - src/worker.ts | 7 +- tests/auth.test.ts | 8 ++ tests/deployment-auth.test.ts | 165 +++++++++++++++++----- tests/deployment-bootstrap.test.ts | 15 +- tests/deployment.test.ts | 1 + tests/oauth.test.ts | 206 ++++++++++++++++++++++------ 24 files changed, 617 insertions(+), 162 deletions(-) create mode 100644 migrations/0002_workspace_state.sql delete mode 100644 scripts/administrator.ts create mode 100644 scripts/workspace-state.ts create mode 100644 src/accounts/workspace.ts diff --git a/.env.example b/.env.example index e95bb6e..c24b05e 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,12 @@ # Copy to .dev.vars for local development. CONNECT_TIMEOUT_MS=10000 -# 只启用一种登录方式;生产由 Actions 自动填写 APP_ORIGIN 和 ADMIN_ACCOUNT_ID。 +# 只启用一种登录方式;生产由 Actions 自动填写 APP_ORIGIN,工作区 ID 保存在 D1。 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 模式填写 OAuth App 和管理员数字 ID;本地 D1 的 workspace_state 也要选择 github。 # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= # GITHUB_ADMIN_ID= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b27449..6dda556 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,7 @@ jobs: GITHUB_CLIENT_ID: ${{ vars.GITHUB_CLIENT_ID }} GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} GITHUB_ADMIN: ${{ vars.GITHUB_ADMIN }} + GITHUB_ADMIN_ID: ${{ vars.GITHUB_ADMIN_ID }} ADMIN_EMAIL: ${{ inputs.admin_email || secrets.ADMIN_EMAIL || vars.ADMIN_EMAIL }} ACCESS_IDP_IDS: ${{ vars.ACCESS_IDP_IDS }} WORKER_NAME: ${{ vars.WORKER_NAME }} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 8659dc3..cc7ff11 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -14,7 +14,8 @@ | `ADMIN_EMAIL` | Variable | 管理员邮箱 | 不需要 | | `GITHUB_CLIENT_ID` | Variable | 不需要 | OAuth App 的 Client ID | | `GITHUB_CLIENT_SECRET` | Secret | 不需要 | OAuth App 的 Client Secret | -| `GITHUB_ADMIN` | Variable | 不需要 | 允许登录的唯一 GitHub 用户名,不是邮箱或组织名 | +| `GITHUB_ADMIN` | Variable | 不需要 | 首次启用时用于解析数字 ID 的 GitHub 用户名 | +| `GITHUB_ADMIN_ID` | Variable | 不需要 | 通常不填;显式更换管理员时填写新的数字用户 ID | `CUSTOM_DOMAIN` 只填完整主机名,不带 `https://`、路径或通配符。域名必须由部署账户的 Cloudflare Zone 管理;Action 会自动绑定 Worker,Cloudflare 负责 DNS 与证书。需要使用免费 `workers.dev` 地址时将它留空,不能填写 `*.workers.dev`。 @@ -28,17 +29,19 @@ 无需手动创建 Access 应用、OTP、D1,也无需抄录 Account ID、Team Domain 或 AUD。填写 `CUSTOM_DOMAIN` 时使用自定义域名;留空才使用 `https://edgessh.<账户子域>.workers.dev`。 +Access 应用的策略是唯一授权名单。可以在控制台添加多个明确邮箱,也可以启用多个 IdP;通过这些策略的身份都拥有同一管理员工作区的完整权限,共用一份主机资料。Worker 不再维护第二份邮箱白名单。 + ## GitHub 模式准备 1. 打开 GitHub **设置(Settings)> 开发者设置(Developer settings)> OAuth 应用(OAuth Apps)> 新建 OAuth 应用(New OAuth App)**。 2. **应用名称(Application name)**自定;**主页 URL(Homepage URL)**填 EdgeSSH 地址,**授权回调 URL(Authorization callback URL)**填 `https://你的入口/auth/callback`。 3. 保存 Client ID,生成一个 Client Secret,按上表分别保存到 Actions Variable 和 Secret。 -4. 设置 `AUTH_PROVIDER=github`、`CUSTOM_DOMAIN=你的主机名`、`GITHUB_ADMIN=你的GitHub用户名`,保存 Cloudflare API Token,然后运行 **Actions > Deploy**。使用 `workers.dev` 时才省略 `CUSTOM_DOMAIN`;邮箱输入框留空。 +4. 设置 `AUTH_PROVIDER=github`、`CUSTOM_DOMAIN=你的主机名`、首次使用的 `GITHUB_ADMIN=你的GitHub用户名`,保存 Cloudflare API Token,然后运行 **Actions > Deploy**。使用 `workers.dev` 时才省略 `CUSTOM_DOMAIN`;邮箱输入框留空。 5. 若首次部署前不知道入口,可先为 OAuth App 使用占位 URL;部署后将 Action 摘要中的正式入口与回调地址复制回 OAuth App 设置,再登录。 GitHub OAuth App 必须由用户在 GitHub 创建;普通 GitHub Token 没有官方“创建 OAuth App”的 REST 接口,工作流不会假装自动完成它。 -登录时仅读取 GitHub 公开身份,不申请仓库、组织或私人邮箱权限。Action 将用户名解析为数字用户 ID,Worker 只给这个 ID 签发会话。管理员更改 GitHub 用户名后,应同步更新 `GITHUB_ADMIN`。 +登录时仅读取 GitHub 公开身份,不申请仓库、组织或私人邮箱权限。首次部署将用户名解析为数字用户 ID 并固定在 D1;后续普通部署直接复用该 ID,不会因用户名改名或易主而改变管理员。仅在明确更换管理员时设置 `GITHUB_ADMIN_ID` 为新的数字 ID 并部署,部署会同时撤销旧会话;完成后可保留该值作为显式配置。 ## API Token 权限 @@ -64,12 +67,12 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 1. 校验本地配置,运行类型检查、测试、前端构建和 Wrangler dry-run。 2. 自动发现唯一账户(显式账户 ID 优先),读取现有 Worker Secret **名称**,不尝试读取密钥明文。 3. 有 `CUSTOM_DOMAIN` 时使用该入口;否则读取账户 `workers.dev` 子域,未注册时自动注册确定性名称。已有子域不改名,避免影响其他 Worker。 -4. 根据 `AUTH_PROVIDER` 仅准备所选认证:Cloudflare 配置 Access/OTP/Allow,GitHub 验证指定管理员并准备 OAuth Secret。 +4. 根据 `AUTH_PROVIDER` 仅准备所选认证:Cloudflare 核对实际 Access 应用、策略、Team Domain 与 AUD,GitHub 首次解析并固定管理员数字 ID,后续直接复用。 5. GitHub 模式不调用 Zero Trust API;Cloudflare 模式不需要 GitHub OAuth 参数。既有 Access 应用不重写人工 IdP 配置。 6. 按 ID 或名称复用 D1,不存在才创建;指定 ID 不存在时直接失败,不用新空库替代。 7. 已有 `ENCRYPTION_KEY` 则保留;没有密钥且 D1 没有主机资料时,用安全随机数生成 32 字节密钥。 -8. 执行远程 migration,仅应用增量 schema,不清空数据。 -9. 自动沿用旧库唯一资料所有者 ID,新库使用固定 `admin`。新增 Secret 通过标准输入交给 Wrangler;部署后核对当前模式必需的 Secret,并输出入口和 GitHub 回调地址。 +8. 执行远程 migration,仅应用增量 schema,不清空数据;单行 `workspace_state` 永久保存资料所有者、当前认证方式和会话代次。 +9. 自动沿用旧库唯一资料所有者 ID,新库使用固定 `admin`。即使主机表后来清空,工作区 ID 也不再重新推断。新增 Secret 通过标准输入交给 Wrangler;部署后核对当前模式必需的 Secret,并输出入口和 GitHub 回调地址。 生产任务通过 concurrency 串行运行,失败可修正原因后重跑,已创建的资源会复用。请勿用多个仓库同时管理同一个 Worker。 @@ -97,6 +100,7 @@ EdgeSSH-Auto-Update: true | `D1_DATABASE_NAME` | Variable | `-accounts` | | `D1_DATABASE_ID` | Variable | 指定已有 D1 UUID,不填则按名称查找 | | `ACCESS_IDP_IDS` | Variable | 新应用采用的 IdP UUID,多个用逗号分隔 | +| `GITHUB_ADMIN_ID` | Variable | 仅显式更换 GitHub 管理员时填写数字用户 ID | | `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 | `DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。 @@ -109,19 +113,19 @@ EdgeSSH-Auto-Update: true - 新部署不需要 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 应用。 +- 旧部署无需重新输入邮箱,也不要求复制 Secret 回 GitHub;Cloudflare 模式每次从实际 Access 应用重新核对 Team Domain、AUD 与策略,不以 Secret 名称代替有效性检查,也不会改写人工维护的 IdP 或身份策略。 - 若需要自动创建/管理 Access,或更换 hostname,请提供 `ADMIN_EMAIL`。自动管理使用账户级 Access API;原有 Zone 级应用请先核对,不要在同一 hostname 叠加应用。 - 切换认证方式不改变 `ENCRYPTION_KEY`、D1 和管理员资料所有者。不要通过更换密钥来切换登录方式。 ## 切换登录方式,保留同一管理员资料 1. 修改 `AUTH_PROVIDER`,补齐目标方式的配置,然后运行 Deploy。 -2. 保持 Worker、D1 和加密密钥不变。部署只读检查旧库唯一所有者并沿用其 ID,不搬迁、不重加密资料。 +2. 保持 Worker、D1 和加密密钥不变。首次升级只读检查旧库唯一所有者并写入固定工作区状态,不搬迁、不重加密资料。 3. 新库统一使用 `admin`。若旧库实际有多个资料所有者,脚本停止,不会猜测或合并原本隔离的数据。 4. 从 Cloudflare 改 GitHub 时,**先解除入口域名原有的 Access 网关保护**,否则浏览器仍会先看到 Access。脚本发现这种情况会停止,不自动删除安全策略。仅解除登录网关,不要删除 Worker、D1 或 Secret。 -5. 反向切换时,Action 配置 Access,GitHub Cookie 不再被接受。旧 provider 的 Secret 即使暂时保留,也不是有效登录状态。 +5. 反向切换时,Action 会核对或准备有效 Access 应用;只有 Secret 名称但入口配置不完整会明确失败。切换会递增会话代次,旧 provider 的 Cookie 即使切回原方式也不会复活。 -GitHub 会话使用 Secure、HttpOnly、SameSite=Lax Cookie,有效期 8 小时;退出清除本浏览器 Cookie,不注销 GitHub 网站账号。会话签名通过 HKDF 从原加密密钥派生独立用途的密钥,无需用户再管理 SESSION_SECRET。无需新增用户表或账号绑定流程。 +GitHub 会话使用 Secure、HttpOnly、SameSite=Lax Cookie,有效期 8 小时;退出会先验证当前身份,再让该实例的全部管理员会话失效并清除本浏览器 Cookie,但不注销 GitHub 网站账号。Access 退出同样记录撤销时间并跳转到 Access 注销。会话签名通过 HKDF 从原加密密钥派生独立用途的密钥,无需用户再管理 SESSION_SECRET;撤销不会轮换 `ENCRYPTION_KEY`。无需新增用户表、设备后台或账号绑定流程。 Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP;这属于 Access 模式,不是原生 `AUTH_PROVIDER=github`。 diff --git a/README.md b/README.md index 3977d1c..ffe6b35 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,14 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 | `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 用户名 | +| `GITHUB_ADMIN` | Variable | 不填 | 首次启用时解析数字 ID 的 GitHub 用户名 | +| `GITHUB_ADMIN_ID` | Variable | 不填 | 通常留空;显式更换管理员时填写数字 ID | `CUSTOM_DOMAIN` 只填主机名,不带 `https://`、路径或通配符。域名需要由部署账户的 Cloudflare Zone 管理,Token 需要该 Zone 的 Workers Routes 编辑和 Zone 读取权限;Action 会自动绑定 Worker,Cloudflare 负责 DNS 与证书。确实使用 `workers.dev` 时才将它留空,且不要把 `*.workers.dev` 填进去。 -**Cloudflare 模式**:先在 Cloudflare 控制台启用 Zero Trust,然后运行 Action,自动配置 Access 应用、邮箱 Allow 策略和 OTP。 +**Cloudflare 模式**:先在 Cloudflare 控制台启用 Zero Trust,然后运行 Action,自动配置 Access 应用、邮箱 Allow 策略和 OTP。可在 Access 中明确允许多个邮箱或多个 IdP;它们只是同一管理员工作区的多个获准登录身份,不会产生独立资料库。 -**GitHub 模式**:先在 GitHub **设置(Settings)> 开发者设置(Developer settings)> OAuth 应用(OAuth Apps)** 创建一个 OAuth App,回调地址填 `https://你的入口/auth/callback`。工作流会自动解析管理员数字 ID,跳过全部 Zero Trust 配置。入口尚不确定时,可先使用占位回调地址,部署后从 Action 摘要复制正式地址,再回 GitHub 更新。详情见[部署指南](DEPLOYMENT.md)。 +**GitHub 模式**:先在 GitHub **设置(Settings)> 开发者设置(Developer settings)> OAuth 应用(OAuth Apps)** 创建一个 OAuth App,回调地址填 `https://你的入口/auth/callback`。首次工作流会解析管理员数字 ID 并固定到 D1,后续不再因用户名变化而更新,且跳过全部 Zero Trust API。入口尚不确定时,可先使用占位回调地址,部署后从 Action 摘要复制正式地址,再回 GitHub 更新。详情见[部署指南](DEPLOYMENT.md)。 ```text 确定自定义域名(或使用 workers.dev)+ 选择登录方式 + 填写对应变量/Secret @@ -137,7 +138,7 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 无需手填 Account ID、D1 ID、Team Domain、AUD 或随机密钥。Token 能访问多个账户时,才需要用 `CLOUDFLARE_ACCOUNT_ID` 选择目标账户。 -EdgeSSH 面向**单管理员**,不允许匿名访问。两种认证不会同时生效;切换方式不改数据库、资料所有者或加密密钥。旧库只有一个所有者时自动沿用原 ID,新库使用固定管理员 ID。若原域名已有 Access 网关,改 GitHub 前需要解除该域名的旧 Access 保护;工作流不会擅自删除安全策略。 +EdgeSSH 面向**单管理员工作区**,不允许匿名访问。两种认证不会同时生效;切换方式不改数据库、资料所有者或加密密钥,并会让旧会话失效。旧库只有一个所有者时自动沿用原 ID 并永久记录,新库使用固定管理员 ID;发现多个所有者会停止而非静默合并。若原域名已有 Access 网关,改 GitHub 前需要解除该域名的旧 Access 保护;工作流不会擅自删除安全策略。 ### 获取项目 @@ -159,6 +160,7 @@ npm ci | `D1_DATABASE_NAME` | `my-edgessh-accounts` | 默认 `-accounts` | | `D1_DATABASE_ID` | `00000000-0000-4000-8000-000000000001` | 复用明确指定的数据库 | | `ACCESS_IDP_IDS` | `一个或多个 IdP UUID,以逗号分隔` | 创建新应用时使用已有 GitHub/其他 IdP,而非自动配置 OTP | +| `GITHUB_ADMIN_ID` | `12345678` | 仅在明确更换 GitHub 管理员时填写 | `ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**;Cloudflare 模式另存 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`,GitHub 模式同步 `GITHUB_CLIENT_SECRET`。不需要用户复制自动生成的值回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 @@ -228,7 +230,7 @@ EdgeSSH/ | 会话 | SSH 2.0、交互式 Shell、PTY、窗口尺寸同步、Keepalive | | 文件协议 | SFTP v3;单文件上传与下载上限 64 MiB;仅删除空目录 | | 私钥认证 | 未加密 OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 | -| 主机管理 | 每个身份最多 200 台;不支持团队共享 | +| 主机管理 | 单一管理员工作区最多 200 台;不提供多租户或独立用户资料 | | 终端编码 | UTF-8、GB18030、Big5,取决于浏览器解码支持 | 暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。 diff --git a/docs/ZERO_TRUST.md b/docs/ZERO_TRUST.md index 84abc62..87cd999 100644 --- a/docs/ZERO_TRUST.md +++ b/docs/ZERO_TRUST.md @@ -66,7 +66,7 @@ https://developers.cloudflare.com/workers/configuration/cloudflare-access/ | 选择器(Selector) | `电子邮件(Emails)` | | 值(Value) | 你的完整邮箱,例如 `you@example.com` | -如果需要多个管理员,请逐个添加明确邮箱。 +如果需要使用多个邮箱登录,请逐个添加明确邮箱。它们都是同一管理员工作区的获准身份,共用完整权限与同一份主机资料,不是独立用户。 > [!WARNING] > 不要使用**包括(Include)> 所有人(Everyone)**。如果使用一次性 PIN(One-time PIN),也不要只写**包括(Include)> 登录方式(Login Methods)> 一次性 PIN(One-time PIN)**,否则任何能接收邮件验证码的人都可能符合策略。 @@ -131,7 +131,7 @@ https://developers.cloudflare.com/cloudflare-one/access-controls/applications/ht | `ACCESS_AUD` | `012345…`(常见外观) | 前面取得的应用受众 (AUD) 标签(Application Audience (AUD) Tag),必须原样复制实际值 | | `ENCRYPTION_KEY` | `AbCd…=`(44 字符 Base64) | 32 字节安全随机数的标准 Base64,只在首次部署时生成,后续始终复用;不要使用示例文本 | -`Deploy` 通过标准输入写入新机密,后续保留 Worker 中的加密密钥。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。旧部署已有 Access 机密且未输入管理员邮箱时,也会保留原 Access 配置。 +`Deploy` 通过标准输入写入机密,后续保留 Worker 中的加密密钥。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。普通重部署会从实际 Access 应用重新取得 Team Domain 与 AUD,并核对现有策略;不会仅凭旧 Secret 名称假定入口有效,也不会覆盖人工维护的 IdP 或明确身份策略。 `ENCRYPTION_KEY` 的生成要求和保管注意事项见[部署指南](../DEPLOYMENT.md#worker-runtime-secrets)。生产部署不需要在本地执行 Wrangler 机密(Secret)或 Worker 部署命令。 diff --git a/migrations/0002_workspace_state.sql b/migrations/0002_workspace_state.sql new file mode 100644 index 0000000..c702b27 --- /dev/null +++ b/migrations/0002_workspace_state.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS workspace_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + account_id TEXT NOT NULL, + auth_provider TEXT NOT NULL CHECK (auth_provider IN ('cloudflare', 'github')), + auth_revision INTEGER NOT NULL, + access_not_before INTEGER NOT NULL, + github_admin_id TEXT +); + +-- 旧库只有一个资料所有者时原样沿用,避免改变既有密文的 AAD。 +-- 多所有者库不会写入状态,部署脚本会明确停止并要求人工确认。 +INSERT OR IGNORE INTO workspace_state ( + id, account_id, auth_provider, auth_revision, access_not_before, github_admin_id +) +SELECT 1, COALESCE(MIN(account_id), 'admin'), 'cloudflare', 1, 0, NULL +FROM hosts +HAVING COUNT(DISTINCT account_id) <= 1; diff --git a/scripts/administrator.ts b/scripts/administrator.ts deleted file mode 100644 index c6163d6..0000000 --- a/scripts/administrator.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 8f6db37..b9be47d 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -6,9 +6,9 @@ import { ensureDatabase } from './cloudflare-d1.ts'; import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts'; import { resolveWorkerHostname } from './cloudflare-access.ts'; -import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames } from './deployment-secrets.ts'; +import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts'; import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts'; -import { administratorAccountId } from './administrator.ts'; +import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const generatedConfig = '.wrangler.generated.toml'; @@ -43,13 +43,22 @@ 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); - const authentication = await prepareAuthentication(api, settings, hostname, existingSecrets); const database = await ensureDatabase(settings); - const adminAccountId = await administratorAccountId(api, settings, database); + const workspace = await readWorkspaceState(api, settings, database); + const deployedGitHubId = workspace.githubAdminId + ?? (settings.authProvider === 'github' ? await readWorkerVariable(api, settings, 'GITHUB_ADMIN_ID') : undefined); + if (deployedGitHubId && !/^[1-9]\d*$/.test(deployedGitHubId)) { + throw new Error('已部署的 GitHub 管理员数字 ID 无效,停止自动覆盖。'); + } + const authentication = await prepareAuthentication(api, settings, hostname, { + existingSecrets, + fixedGithubAdminId: deployedGitHubId, + previousProvider: workspace.authProvider, + }); const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; maskSecrets(secrets); const config = createDeploymentConfig(template, settings, database, { - hostname, adminAccountId, githubAdminId: authentication.githubAdminId, + hostname, githubAdminId: authentication.githubAdminId, }); // 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。 await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8'); @@ -58,6 +67,8 @@ async function main(): Promise { // Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。 if (Object.keys(secrets).length) await runWrangler(['secret', 'bulk'], JSON.stringify(secrets)); await runWrangler(['deploy']); + // Worker 成功发布后再切换状态;失败的发布不会让旧版本提前失去当前认证方式。 + await updateWorkspaceState(api, settings, database, workspace, authentication.githubAdminId); // Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。 const deployedSecrets = await readWorkerSecretNames(api, settings); for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) { diff --git a/scripts/deployment-auth.ts b/scripts/deployment-auth.ts index 30938f7..9827226 100644 --- a/scripts/deployment-auth.ts +++ b/scripts/deployment-auth.ts @@ -2,6 +2,13 @@ import { ensureAccess } from './cloudflare-access.ts'; import type { CloudflareApi } from './cloudflare-api.ts'; import type { DeploymentSettings, RuntimeSecrets } from './deployment-config.ts'; +interface AuthenticationOptions { + existingSecrets?: Set; + fixedGithubAdminId?: string; + previousProvider?: DeploymentSettings['authProvider']; + fetcher?: typeof fetch; +} + export function requiredAuthSecrets(provider: DeploymentSettings['authProvider']): string[] { return provider === 'github' ? ['GITHUB_CLIENT_SECRET'] : ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']; } @@ -10,26 +17,36 @@ export async function prepareAuthentication( api: CloudflareApi, settings: DeploymentSettings, hostname: string, - existing: Set, - fetcher: typeof fetch = fetch, + options: AuthenticationOptions = {}, ): Promise<{ secrets: Partial; githubAdminId?: string }> { + const fetcher = options.fetcher ?? fetch; 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。'); + // 每次从对应 Access 应用刷新 Team Domain/AUD;Secret 名称本身不能证明入口仍然有效。 return { secrets: await ensureAccess(api, settings, hostname) }; } // GitHub 新部署完全不调用 Zero Trust API。旧域名若仍在 Access 后面,明确停止, // 不擅自删除用户的安全配置,也不把“双重登录”误报为部署成功。 - if (existing.has('ACCESS_AUD')) { + try { 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')) { + const target = location ? new URL(location, `https://${hostname}`) : null; + if (target && (target.hostname.endsWith('.cloudflareaccess.com') + || target.pathname.startsWith('/cdn-cgi/access/'))) { throw new Error('当前域名仍由 Cloudflare Access 保护。请先在 Access 解除该域名的保护,再切换 GitHub;不会删除主机资料或加密密钥。'); } + } catch (error) { + if (error instanceof Error && error.message.includes('仍由 Cloudflare Access 保护')) throw error; + if (options.previousProvider === 'cloudflare' + || [...(options.existingSecrets ?? [])].some((name) => name.startsWith('ACCESS_'))) { + throw new Error('无法确认入口是否已解除 Cloudflare Access 保护,请检查域名后重试;不会自动删除现有策略。'); + } + } + const fixed = settings.githubAdminId ?? options.fixedGithubAdminId; + if (fixed) { + return { githubAdminId: fixed, secrets: { GITHUB_CLIENT_SECRET: settings.secrets.GITHUB_CLIENT_SECRET! } }; + } + if (!settings.githubAdmin) { + throw new Error('首次配置 GitHub 登录需要 GITHUB_ADMIN 用户名,或显式设置 GITHUB_ADMIN_ID。'); } const response = await fetcher(`https://api.github.com/users/${settings.githubAdmin}`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' }, diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts index 483fcb3..a9f7d8a 100644 --- a/scripts/deployment-config.ts +++ b/scripts/deployment-config.ts @@ -13,6 +13,7 @@ export interface DeploymentSettings { authProvider: 'cloudflare' | 'github'; githubClientId?: string; githubAdmin?: string; + githubAdminId?: string; adminEmail?: string; identityProviderIds: string[]; secrets: Partial; @@ -29,7 +30,7 @@ export function readDeploymentSettings( ): DeploymentSettings { 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 required = ['CLOUDFLARE_API_TOKEN', ...(authProvider === 'github' ? ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'] : [])]; const missing = required.filter((name) => !env[name]?.trim()); if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`); @@ -67,10 +68,14 @@ export function readDeploymentSettings( throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。'); } const githubClientId = authProvider === 'github' ? env.GITHUB_CLIENT_ID!.trim() : undefined; - const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN!.trim().toLowerCase() : undefined; + const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN?.trim().toLowerCase() || undefined : undefined; if (githubAdmin && !/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(githubAdmin)) { throw new Error('GITHUB_ADMIN 必须是一个 GitHub 用户名。'); } + const githubAdminId = authProvider === 'github' ? env.GITHUB_ADMIN_ID?.trim() || undefined : undefined; + if (githubAdminId && !/^[1-9]\d*$/.test(githubAdminId)) { + throw new Error('GITHUB_ADMIN_ID 必须是 GitHub 个人账号的数字用户 ID。'); + } const selectedSecrets = authProvider === 'github' ? ['ENCRYPTION_KEY', 'GITHUB_CLIENT_SECRET'] : ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']; const secrets = Object.fromEntries(selectedSecrets @@ -93,7 +98,8 @@ export function readDeploymentSettings( return { accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName, - databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, adminEmail, identityProviderIds, secrets, + databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, githubAdminId, + adminEmail, identityProviderIds, secrets, }; } @@ -101,7 +107,7 @@ export function createDeploymentConfig( template: TomlTable, settings: DeploymentSettings, database: Database, - runtime?: { hostname: string; adminAccountId: string; githubAdminId?: string }, + runtime?: { hostname: string; githubAdminId?: string }, ): TomlTable { return { ...template, @@ -113,7 +119,7 @@ export function createDeploymentConfig( vars: { ...template.vars as TomlTable, AUTH_PROVIDER: settings.authProvider, - ...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}`, ADMIN_ACCOUNT_ID: runtime.adminAccountId } : {}), + ...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}` } : {}), ...(settings.authProvider === 'github' ? { GITHUB_CLIENT_ID: settings.githubClientId!, ...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}), diff --git a/scripts/deployment-secrets.ts b/scripts/deployment-secrets.ts index 971461f..e302cff 100644 --- a/scripts/deployment-secrets.ts +++ b/scripts/deployment-secrets.ts @@ -11,6 +11,21 @@ export async function readWorkerSecretNames(api: CloudflareApi, settings: Deploy return new Set(secrets.map((secret) => secret.name)); } +export async function readWorkerVariable( + api: CloudflareApi, + settings: DeploymentSettings, + name: string, +): Promise { + const base = `/accounts/${settings.accountId}/workers/scripts`; + const workers = await api.request<{ id: string }[]>(base); + if (!workers.some((worker) => worker.id === settings.workerName)) return undefined; + const configuration = await api.request<{ bindings: { name: string; type: string; text?: string }[] }>( + `${base}/${settings.workerName}/settings`, + ); + const binding = configuration.bindings.find((entry) => entry.name === name && entry.type === 'plain_text'); + return binding?.text; +} + export async function prepareEncryptionSecret( api: CloudflareApi, settings: DeploymentSettings, diff --git a/scripts/workspace-state.ts b/scripts/workspace-state.ts new file mode 100644 index 0000000..35bf1a1 --- /dev/null +++ b/scripts/workspace-state.ts @@ -0,0 +1,116 @@ +import type { CloudflareApi } from './cloudflare-api.ts'; +import type { Database, DeploymentSettings } from './deployment-config.ts'; + +export interface DeploymentWorkspaceState { + accountId: string; + authProvider?: 'cloudflare' | 'github'; + authRevision: number; + accessNotBefore: number; + githubAdminId?: string; +} + +interface StateRow { + account_id: string; + auth_provider: string; + auth_revision: number; + access_not_before: number; + github_admin_id: string | null; +} + +interface QueryResult { success: boolean; results: T[] } + +function databasePath(settings: DeploymentSettings, database: Database): string { + return `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`; +} + +async function query( + api: CloudflareApi, + settings: DeploymentSettings, + database: Database, + sql: string, + params: unknown[] = [], +): Promise { + const result = await api.request[]>(databasePath(settings, database), 'POST', { sql, params }); + if (result.length !== 1 || !result[0].success) throw new Error('无法读取管理员工作区状态,请重试。'); + return result[0].results; +} + +export async function readWorkspaceState( + api: CloudflareApi, + settings: DeploymentSettings, + database: Database, +): Promise { + const tables = await query<{ name: string }>(api, settings, database, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('hosts', 'workspace_state')"); + const names = new Set(tables.map((table) => table.name)); + const owners = names.has('hosts') + ? await query<{ account_id: string }>(api, settings, database, 'SELECT DISTINCT account_id FROM hosts LIMIT 2') : []; + if (owners.length > 1) { + throw new Error('数据库存在多个资料所有者,无法自动转为单管理员;请先明确资料归属。'); + } + + if (names.has('workspace_state')) { + const rows = await query(api, settings, database, + 'SELECT account_id, auth_provider, auth_revision, access_not_before, github_admin_id FROM workspace_state WHERE id = 1'); + const row = rows[0]; + if (row) { + if (row.auth_provider !== 'cloudflare' && row.auth_provider !== 'github') { + throw new Error('管理员工作区中的认证方式无效,请检查数据库状态。'); + } + if (owners[0] && owners[0].account_id !== row.account_id) { + throw new Error('数据库资料所有者与固定管理员工作区不一致,停止自动部署。'); + } + return { + accountId: row.account_id, + authProvider: row.auth_provider, + authRevision: row.auth_revision, + accessNotBefore: row.access_not_before, + githubAdminId: row.github_admin_id ?? undefined, + }; + } + } + + // 只在首次初始化读取旧 hosts;状态落库后,即使删除最后一台主机也不会改变 owner。 + return { + accountId: owners[0]?.account_id || 'admin', + authRevision: 0, + accessNotBefore: 0, + }; +} + +export async function updateWorkspaceState( + api: CloudflareApi, + settings: DeploymentSettings, + database: Database, + previous: DeploymentWorkspaceState, + githubAdminId?: string, + now = Math.floor(Date.now() / 1000), +): Promise { + const rows = await query(api, settings, database, + 'SELECT account_id, auth_provider, auth_revision, access_not_before, github_admin_id FROM workspace_state WHERE id = 1'); + const current = rows[0]; + if (!current) throw new Error('数据库迁移未建立管理员工作区状态,停止部署。'); + if (current.account_id !== previous.accountId) throw new Error('管理员工作区在部署期间发生变化,停止部署。'); + + const retainedGitHubId = settings.authProvider === 'github' ? githubAdminId : (current.github_admin_id ?? undefined); + if (settings.authProvider === 'github' && !retainedGitHubId) { + throw new Error('GitHub 管理员数字 ID 尚未固定,停止部署。'); + } + const providerChanged = current.auth_provider !== settings.authProvider; + const administratorChanged = settings.authProvider === 'github' + && current.github_admin_id !== null && current.github_admin_id !== retainedGitHubId; + const revision = current.auth_revision + (providerChanged || administratorChanged ? 1 : 0); + const accessNotBefore = providerChanged ? now + 1 : current.access_not_before; + await query(api, settings, database, + `UPDATE workspace_state + SET auth_provider = ?, auth_revision = ?, access_not_before = ?, github_admin_id = ? + WHERE id = 1 AND account_id = ?`, + [settings.authProvider, revision, accessNotBefore, retainedGitHubId ?? null, previous.accountId]); + return { + accountId: previous.accountId, + authProvider: settings.authProvider, + authRevision: revision, + accessNotBefore, + githubAdminId: retainedGitHubId, + }; +} diff --git a/src/accounts/auth-routes.ts b/src/accounts/auth-routes.ts index 051462b..82898ca 100644 --- a/src/accounts/auth-routes.ts +++ b/src/accounts/auth-routes.ts @@ -2,6 +2,8 @@ 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'; +import { currentAccount } from './auth.ts'; +import { revokeWorkspaceSessions, workspaceState } from './workspace.ts'; export async function authRoute(request: Request, env: Env): Promise { const path = new URL(request.url).pathname; @@ -9,6 +11,9 @@ export async function authRoute(request: Request, env: Env): Promise>(); -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 }; +export async function currentAccount(request: Request, env: Env): Promise { + const provider = authProvider(env); + const workspace = await workspaceState(env); + if (workspace.authProvider !== provider) throw new APIError('登录方式切换尚未完成,请重新部署。', 503); + const identity = provider === 'github' + ? await githubAccount(request, env, workspace.authRevision) : await accessAccount(request, env, workspace); + // 登录身份只用于展示;所有获准身份始终进入 D1 中固定的同一个管理员工作区。 + return { id: workspace.accountId, username: identity.username }; } -async function accessAccount(request: Request, env: Env): Promise { +async function accessAccount(request: Request, env: Env, workspace: WorkspaceState): Promise<{ username: string }> { if (!env.ACCESS_AUD || !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(env.ACCESS_TEAM_DOMAIN ?? '')) { throw new APIError('管理员尚未配置 Zero Trust Access。', 503); } @@ -30,10 +34,11 @@ async function accessAccount(request: Request, env: Env): Promise { try { // 不信任邮箱请求头:校验签名、有效期、团队与应用,防止绕过 Access 直连 Worker。 const { payload } = await jwtVerify(token, keys, { - issuer, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub', 'email'], + issuer, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'iat', 'sub', 'email'], }); - if (typeof payload.sub !== 'string' || typeof payload.email !== 'string') throw new Error('Missing identity'); - return { id: payload.sub, username: payload.email }; + if (typeof payload.sub !== 'string' || typeof payload.email !== 'string' + || typeof payload.iat !== 'number' || payload.iat < workspace.accessNotBefore) throw new Error('Missing identity'); + return { username: payload.email }; } catch { throw new APIError('Access 登录已失效,请重新登录。', 401); } diff --git a/src/accounts/github-auth.ts b/src/accounts/github-auth.ts index c1e2032..a222679 100644 --- a/src/accounts/github-auth.ts +++ b/src/accounts/github-auth.ts @@ -2,6 +2,7 @@ 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'; +import { workspaceState } from './workspace.ts'; const sessionCookie = '__Host-edgessh-session'; const flowCookie = '__Host-edgessh-oauth'; @@ -111,7 +112,11 @@ export async function githubCallback(request: Request, env: Env, fetcher: typeof 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); + const workspace = await workspaceState(env); + if (workspace.authProvider !== 'github') throw new APIError('当前未启用 GitHub 登录。', 503); + const session = await sign(settings, 'session', { + sub: String(user.id), username: user.login, revision: workspace.authRevision, + }, sessionSeconds); // GitHub access_token 只在本次验证中使用,不写数据库、Cookie 或日志。 response = redirect('/', [setCookie(sessionCookie, session, sessionSeconds)]); } catch (error) { @@ -121,14 +126,15 @@ export async function githubCallback(request: Request, env: Env, fetcher: typeof return response; } -export async function githubAccount(request: Request, env: Env): Promise<{ id: string; username: string }> { +export async function githubAccount(request: Request, env: Env, authRevision: number): Promise<{ 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 }; + if (payload.sub !== settings.GITHUB_ADMIN_ID || typeof payload.username !== 'string' + || payload.revision !== authRevision) throw new Error('Invalid identity'); + return { username: payload.username }; } catch { throw new APIError('登录已过期,请重新登录。', 401); } diff --git a/src/accounts/workspace.ts b/src/accounts/workspace.ts new file mode 100644 index 0000000..8578bb2 --- /dev/null +++ b/src/accounts/workspace.ts @@ -0,0 +1,40 @@ +import type { Env } from '../types.ts'; +import { APIError } from './http.ts'; + +export interface WorkspaceState { + accountId: string; + authProvider: 'cloudflare' | 'github'; + authRevision: number; + accessNotBefore: number; +} + +interface WorkspaceRow { + account_id: string; + auth_provider: string; + auth_revision: number; + access_not_before: number; +} + +export async function workspaceState(env: Pick): Promise { + const row = await env.DB.prepare( + 'SELECT account_id, auth_provider, auth_revision, access_not_before FROM workspace_state WHERE id = 1', + ).first(); + if (!row || (row.auth_provider !== 'cloudflare' && row.auth_provider !== 'github')) { + throw new APIError('管理员工作区尚未完成部署初始化。', 503); + } + return { + accountId: row.account_id, + authProvider: row.auth_provider, + authRevision: row.auth_revision, + accessNotBefore: row.access_not_before, + }; +} + +export async function revokeWorkspaceSessions(env: Pick, state: WorkspaceState): Promise { + const result = await env.DB.prepare( + `UPDATE workspace_state + SET auth_revision = auth_revision + 1, access_not_before = ? + WHERE id = 1 AND auth_provider = ? AND auth_revision = ?`, + ).bind(Math.floor(Date.now() / 1000) + 1, state.authProvider, state.authRevision).run(); + if (result.meta.changes !== 1) throw new APIError('登录状态已变化,请刷新页面后重试。', 409); +} diff --git a/src/http-security.ts b/src/http-security.ts index aa640eb..954676e 100644 --- a/src/http-security.ts +++ b/src/http-security.ts @@ -43,6 +43,10 @@ export function isProductionHttp(request: Request): boolean { return new URL(request.url).protocol === 'http:' && request.headers.has('CF-Connecting-IP'); } +export function hasValidWebSocketOrigin(request: Request): boolean { + return request.headers.get('Origin') === new URL(request.url).origin; +} + export function httpsRedirect(request: Request): Response { const url = new URL(request.url); url.protocol = 'https:'; diff --git a/src/types.ts b/src/types.ts index ffdeb43..e270ee1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,7 +50,6 @@ export interface Env { DB: D1Database; ENCRYPTION_KEY: string; AUTH_PROVIDER?: string; - ADMIN_ACCOUNT_ID?: string; APP_ORIGIN?: string; ACCESS_TEAM_DOMAIN?: string; ACCESS_AUD?: string; diff --git a/src/worker.ts b/src/worker.ts index f8e66c5..d01711f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,6 @@ import type { Env } from './types'; import { SSHSessionDO } from './backend/durable-object'; -import { corsPreflightResponse, corsResponse, httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security'; +import { corsPreflightResponse, corsResponse, hasValidWebSocketOrigin, httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security'; import { currentAccount } from './accounts/auth'; import { authRoute } from './accounts/auth-routes'; import { authProvider } from './accounts/auth-provider'; @@ -15,11 +15,6 @@ function clientAddress(request: Request): string { return /^[0-9A-Fa-f:.]{2,64}$/.test(value) ? value.toLowerCase() : 'unknown'; } -function hasValidWebSocketOrigin(request: Request): boolean { - const origin = request.headers.get('Origin'); - return origin === null || origin === new URL(request.url).origin; -} - async function sessionTicket(request: Request, env: Env, accountId: string): Promise { if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) return jsonError('Expected application/json', 415); const contentLength = Number(request.headers.get('Content-Length') ?? 0); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index e768731..0552634 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { accessToken } from '../src/accounts/access-token.ts'; +import { hasValidWebSocketOrigin } from '../src/http-security.ts'; test('Access JWT can fall back to the browser authorization cookie', async () => { const request = new Request('https://edgessh.example.workers.dev/api/auth/me', { @@ -23,3 +24,10 @@ test('Access assertion header takes precedence over the browser cookie', async ( }); assert.equal(accessToken(request), 'header-token'); }); + +test('WebSocket upgrades require an explicit same-origin browser header', () => { + const url = 'https://ssh.example.com/api/ssh'; + assert.equal(hasValidWebSocketOrigin(new Request(url)), false); + assert.equal(hasValidWebSocketOrigin(new Request(url, { headers: { Origin: 'https://other.example.com' } })), false); + assert.equal(hasValidWebSocketOrigin(new Request(url, { headers: { Origin: 'https://ssh.example.com' } })), true); +}); diff --git a/tests/deployment-auth.test.ts b/tests/deployment-auth.test.ts index 8f42614..7254456 100644 --- a/tests/deployment-auth.test.ts +++ b/tests/deployment-auth.test.ts @@ -3,9 +3,9 @@ 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'; +import { readWorkspaceState, updateWorkspaceState, type DeploymentWorkspaceState } from '../scripts/workspace-state.ts'; const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8')); const environment = { @@ -14,72 +14,163 @@ const environment = { }; const settings = readDeploymentSettings(template, environment); const database = { uuid: 'test-db', name: settings.databaseName }; -const json = (result: unknown) => Response.json({ success: true, result }); +const envelope = (result: unknown) => Response.json({ success: true, result }); +const queryEnvelope = (results: unknown[]) => envelope([{ success: true, results }]); 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, { ...environment, GITHUB_ADMIN: '' }).githubAdmin, undefined); + assert.equal(readDeploymentSettings(template, { + ...environment, GITHUB_ADMIN: '', GITHUB_ADMIN_ID: '123', ADMIN_EMAIL: 'invalid', ACCESS_TEAM_DOMAIN: 'invalid', ACCESS_IDP_IDS: 'invalid', + }).githubAdminId, '123'); 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']) { + for (const field of ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET']) { assert.throws(() => readDeploymentSettings(template, { ...environment, [field]: '' }), new RegExp(field)); } + assert.throws(() => readDeploymentSettings(template, { ...environment, GITHUB_ADMIN_ID: 'name' }), /GITHUB_ADMIN_ID/); 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 () => { +test('first GitHub deployment resolves a numeric administrator without Zero Trust API', 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' }); + const calls: string[] = []; + const prepared = await prepareAuthentication(cloudflare, settings, 'ssh.example.com', { + fetcher: async (url) => { + calls.push(String(url)); + return String(url).includes('/api/auth/me') + ? new Response('', { status: 401 }) : Response.json({ id: 123, type: 'User' }); + }, }); + assert.deepEqual(calls, ['https://ssh.example.com/api/auth/me', 'https://api.github.com/users/admin']); 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 config = createDeploymentConfig(template, settings, database, { hostname: 'ssh.example.com', 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.ADMIN_ACCOUNT_ID, undefined); 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 () => { +test('ordinary GitHub redeployments retain the fixed ID and explicit numeric changes are visible', async () => { + let githubLookups = 0; + const fetcher: typeof fetch = async (url) => { + if (String(url).includes('/users/')) githubLookups++; + return new Response('', { status: 401 }); + }; + const retained = await prepareAuthentication(new CloudflareApi('token'), settings, 'ssh.example.com', { + fixedGithubAdminId: '123', fetcher, + }); + assert.equal(retained.githubAdminId, '123'); + const explicit = readDeploymentSettings(template, { ...environment, GITHUB_ADMIN_ID: '456', GITHUB_ADMIN: 'renamed-user' }); + assert.equal((await prepareAuthentication(new CloudflareApi('token'), explicit, 'ssh.example.com', { + fixedGithubAdminId: '123', fetcher, + })).githubAdminId, '456'); + assert.equal(githubLookups, 0); +}); + +test('organization names and unresolved first 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/); + let calls = 0; + await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', { + fetcher: async () => (++calls === 1 ? new Response('', { status: 401 }) : response), + }), /GITHUB_ADMIN/); } + const noName = readDeploymentSettings(template, { ...environment, GITHUB_ADMIN: '' }); + await assert.rejects(prepareAuthentication(cloudflare, noName, 'ssh.example.com', { + fetcher: async () => new Response('', { status: 401 }), + }), /首次配置/); }); 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' } })), /解除/); + await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', { + existingSecrets: new Set(['ACCESS_TEAM_DOMAIN']), + previousProvider: 'cloudflare', + fetcher: async () => new Response(null, { + status: 302, headers: { Location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' }, + }), + }), /解除/); + await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', { + existingSecrets: new Set(['ACCESS_AUD']), previousProvider: 'cloudflare', + fetcher: async () => { throw Error('dns failure'); }, + }), /无法确认入口/); }); -test('legacy Cloudflare secrets are retained without requiring GitHub values', async () => { +test('Cloudflare redeployment validates the real Access app instead of trusting secret names', 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' }] }]); + const paths: string[] = []; + const api = new CloudflareApi('token', async (url) => { + const path = new URL(String(url)).pathname; + paths.push(path); + if (path.endsWith('/organizations')) return envelope({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/apps')) return envelope([{ id: 'app', aud: 'current-aud', type: 'self_hosted', domain: 'ssh.example.com' }]); + if (path.endsWith('/policies')) return envelope([{ + id: 'manual', name: 'Manual identities', decision: 'allow', include: [{ email: { email: 'one@example.com' } }], + }]); + throw Error(`unexpected ${path}`); }); - await assert.rejects(administratorAccountId(api, settings, database), /多个资料所有者/); + assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com'), { + secrets: { ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', ACCESS_AUD: 'current-aud' }, + }); + assert.ok(paths.some((path) => path.endsWith('/organizations'))); + assert.ok(paths.some((path) => path.endsWith('/apps'))); + assert.ok(paths.some((path) => path.endsWith('/policies'))); +}); + +function workspaceApi(options: { owners?: string[]; state?: DeploymentWorkspaceState }) { + const memory = options.state ? { + account_id: options.state.accountId, + auth_provider: options.state.authProvider!, + auth_revision: options.state.authRevision, + access_not_before: options.state.accessNotBefore, + github_admin_id: options.state.githubAdminId ?? null, + } : undefined; + const api = new CloudflareApi('token', async (_url, init) => { + const { sql, params = [] } = JSON.parse(String(init?.body)) as { sql: string; params?: unknown[] }; + if (sql.includes('sqlite_master')) { + return queryEnvelope(memory ? [{ name: 'hosts' }, { name: 'workspace_state' }] : [{ name: 'hosts' }]); + } + if (sql.includes('SELECT DISTINCT account_id')) return queryEnvelope((options.owners ?? []).map((account_id) => ({ account_id }))); + if (sql.startsWith('SELECT account_id')) return queryEnvelope(memory ? [{ ...memory }] : []); + if (sql.startsWith('UPDATE workspace_state')) { + memory!.auth_provider = params[0] as 'cloudflare' | 'github'; + memory!.auth_revision = params[1] as number; + memory!.access_not_before = params[2] as number; + memory!.github_admin_id = params[3] as string | null; + return queryEnvelope([]); + } + throw Error(`unexpected SQL: ${sql}`); + }); + return { api, memory }; +} + +test('single-owner legacy databases initialize safely and multiple owners stop', async () => { + for (const owners of [[], ['legacy-sub'], ['admin']]) { + const { api } = workspaceApi({ owners }); + assert.equal((await readWorkspaceState(api, settings, database)).accountId, owners[0] ?? 'admin'); + } + const multiple = workspaceApi({ owners: ['first', 'second'] }); + await assert.rejects(readWorkspaceState(multiple.api, settings, database), /多个资料所有者/); +}); + +test('persisted workspace survives an empty host table and provider changes revoke old sessions', async () => { + const initial: DeploymentWorkspaceState = { + accountId: 'legacy-owner', authProvider: 'cloudflare', authRevision: 4, accessNotBefore: 10, githubAdminId: '123', + }; + const { api } = workspaceApi({ owners: [], state: initial }); + const loaded = await readWorkspaceState(api, settings, database); + assert.equal(loaded.accountId, 'legacy-owner'); + const switched = await updateWorkspaceState(api, settings, database, loaded, '123', 100); + assert.deepEqual(switched, { + accountId: 'legacy-owner', authProvider: 'github', authRevision: 5, + accessNotBefore: 101, githubAdminId: '123', + }); + const renamed = await updateWorkspaceState(api, { ...settings, githubAdminId: '456' }, database, switched, '456', 200); + assert.equal(renamed.accountId, 'legacy-owner'); + assert.equal(renamed.authRevision, 6); + assert.equal(renamed.githubAdminId, '456'); }); diff --git a/tests/deployment-bootstrap.test.ts b/tests/deployment-bootstrap.test.ts index 227ab4d..ab488b9 100644 --- a/tests/deployment-bootstrap.test.ts +++ b/tests/deployment-bootstrap.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { CloudflareApi, resolveAccountId } from '../scripts/cloudflare-api.ts'; import { ensureAccess, resolveWorkerHostname } from '../scripts/cloudflare-access.ts'; -import { prepareEncryptionSecret, readWorkerSecretNames } from '../scripts/deployment-secrets.ts'; +import { prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from '../scripts/deployment-secrets.ts'; import type { DeploymentSettings } from '../scripts/deployment-config.ts'; const settings: DeploymentSettings = { @@ -196,6 +196,19 @@ test('Worker list is unpaginated, including accounts with more than 100 Workers' assert.deepEqual(await readWorkerSecretNames(api, settings), new Set(['ENCRYPTION_KEY'])); }); +test('existing plain-text administrator ID can seed the persistent deployment state', async () => { + const api = new CloudflareApi('token', async (url) => { + if (String(url).endsWith('/settings')) return json({ bindings: [ + { name: 'AUTH_PROVIDER', type: 'plain_text', text: 'github' }, + { name: 'GITHUB_ADMIN_ID', type: 'plain_text', text: '123456' }, + { name: 'GITHUB_CLIENT_SECRET', type: 'secret_text' }, + ] }); + return json([{ id: settings.workerName }]); + }); + assert.equal(await readWorkerVariable(api, settings, 'GITHUB_ADMIN_ID'), '123456'); + assert.equal(await readWorkerVariable(api, settings, 'GITHUB_CLIENT_SECRET'), undefined); +}); + test('existing encryption key is never overwritten, including stale Actions secrets', async () => { const api = new CloudflareApi('token', async () => { throw Error('must not query'); }); assert.deepEqual(await prepareEncryptionSecret(api, { diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index 37e82fc..eadfaf1 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -195,6 +195,7 @@ test('Actions asks for email, keeps Token private and uses the shared deploy ent } assert.ok(workflow.includes('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}')); assert.ok(workflow.includes('inputs.admin_email || secrets.ADMIN_EMAIL || vars.ADMIN_EMAIL')); + assert.ok(workflow.includes('GITHUB_ADMIN_ID: ${{ vars.GITHUB_ADMIN_ID }}')); assert.equal(workflow.includes('secrets.ACCESS_AUD'), false); assert.equal(workflow.includes('secrets.ACCESS_TEAM_DOMAIN'), false); assert.ok(workflow.includes('CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN || vars.CUSTOM_DOMAIN }}')); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 1825f84..b6e743b 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -8,20 +8,60 @@ 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', +const baseEnv = { + AUTH_PROVIDER: 'github', APP_ORIGIN: origin, 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; +}; + +interface MemoryWorkspace { + account_id: string; + auth_provider: 'cloudflare' | 'github'; + auth_revision: number; + access_not_before: number; +} + +function environment(overrides: Partial = {}) { + const state: MemoryWorkspace = { + account_id: 'legacy-access-owner', auth_provider: 'github', auth_revision: 1, access_not_before: 0, + ...overrides, + }; + const DB = { + prepare(sql: string) { + let params: unknown[] = []; + const statement = { + bind(...values: unknown[]) { params = values; return statement; }, + async first() { return { ...state } as T; }, + async run() { + let changes = 0; + if (sql.includes('UPDATE workspace_state') + && params[1] === state.auth_provider && params[2] === state.auth_revision) { + state.auth_revision++; + state.access_not_before = params[0] as number; + changes = 1; + } + return { success: true, meta: { changes } }; + }, + }; + return statement; + }, + } as unknown as D1Database; + return { env: { ...baseEnv, DB } as Env, state }; +} + +const shared = environment(); +const env = shared.env; +const accessKeys = await generateKeyPair('RS256'); +const accessJwk = await exportJWK(accessKeys.publicKey); 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); +async function start(target = env) { + const response = await githubLogin(request('/auth/login'), target); const authorization = new URL(response.headers.get('Location')!); return { response, authorization, cookie: cookieValue(response, '__Host-edgessh-oauth') }; } @@ -31,7 +71,7 @@ function exchange(id = 123): typeof fetch { 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('client_secret'), baseEnv.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' }); @@ -42,13 +82,37 @@ function exchange(id = 123): typeof fetch { }; } -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()); +async function login(target = env) { + const flow = await start(target); + const response = await githubCallback( + request(`/auth/callback?code=valid&state=${flow.authorization.searchParams.get('state')}`, flow.cookie), + target, + exchange(), + ); assert.equal(response.status, 302); return { response, cookie: cookieValue(response, '__Host-edgessh-session') }; } +async function accessToken( + payload: { sub?: string; email?: string } = {}, + options: { issuer?: string; audience?: string; expires?: string; key?: CryptoKey } = {}, +) { + return await new SignJWT({ email: payload.email ?? 'admin@example.com' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test' }) + .setIssuer(options.issuer ?? `https://${baseEnv.ACCESS_TEAM_DOMAIN}`) + .setAudience(options.audience ?? baseEnv.ACCESS_AUD) + .setSubject(payload.sub ?? 'access-subject') + .setIssuedAt() + .setExpirationTime(options.expires ?? '5m') + .sign(options.key ?? accessKeys.privateKey); +} + +async function withAccessKeys(work: () => Promise): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ keys: [{ ...accessJwk, kid: 'test', alg: 'RS256' }] }); + try { return await work(); } finally { globalThis.fetch = originalFetch; } +} + test('OAuth starts with random state, S256 PKCE and no privileged scopes', async () => { const first = await start(); const second = await start(); @@ -62,7 +126,7 @@ test('OAuth starts with random state, S256 PKCE and no privileged scopes', async 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); + assert.equal(first.authorization.toString().includes(baseEnv.GITHUB_CLIENT_SECRET), false); }); test('callback binds state to its browser cookie before any token exchange', async () => { @@ -82,17 +146,17 @@ test('callback binds state to its browser cookie before any token exchange', asy assert.equal(calls, 0); }); -test('only the numeric GitHub administrator receives a session', async () => { +test('only the numeric GitHub administrator receives a revision-bound 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.deepEqual(account, { id: shared.state.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'); + assert.deepEqual({ sub: decodeJwt(cookie.split('=')[1]).sub, revision: decodeJwt(cookie.split('=')[1]).revision }, { sub: '123', revision: 1 }); }); test('wrong signing key, tampered cookies and changed administrator cannot access APIs', async () => { @@ -107,14 +171,16 @@ test('wrong signing key, tampered cookies and changed administrator cannot acces } }); -test('flow cookie is not a session and providers never accept each others credentials', async () => { +test('providers accept only their own credentials and deployment state must match', 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 登录/); + await assert.rejects(currentAccount(request('/api/hosts', 'CF_Authorization=access-token'), env), /GitHub 登录/); const { cookie } = await login(); - await assert.rejects(currentAccount(request('/api/hosts', cookie), { ...env, AUTH_PROVIDER: 'cloudflare' }), /Cloudflare Access/); + const cloudflare = environment({ auth_provider: 'cloudflare' }).env; + await assert.rejects(currentAccount(request('/api/hosts', cookie), { ...cloudflare, AUTH_PROVIDER: 'cloudflare' }), /Cloudflare Access/); + await assert.rejects(currentAccount(request('/api/hosts', cookie), { ...env, AUTH_PROVIDER: 'cloudflare' }), /切换尚未完成/); 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' }); + const callback = await authRoute(request('/auth/callback?code=anything'), { ...cloudflare, AUTH_PROVIDER: 'cloudflare' }); assert.equal(callback?.status, 404); }); @@ -128,42 +194,96 @@ test('OAuth rejects an unexpected callback origin and hides upstream errors', as 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('logout revokes the presented GitHub session instead of only clearing cookies', async () => { + const runtime = environment(); + const { cookie } = await login(runtime.env); + const req = new Request(`${origin}/api/auth/logout`, { + method: 'POST', headers: { Origin: origin, Cookie: cookie }, + }); + const response = (await authRoute(req, runtime.env))!; + assert.equal((await response.json() as { redirect: string }).redirect, '/'); + assert.equal(response.headers.getSetCookie().length, 2); + assert.ok(response.headers.getSetCookie().every((value) => value.includes('Max-Age=0'))); + assert.equal(runtime.state.auth_revision, 2); + await assert.rejects(currentAccount(request('/api/auth/me', cookie), runtime.env), /过期/); + await assert.rejects(authRoute(new Request(`${origin}/api/auth/logout`, { method: 'POST', headers: { Origin: origin } }), runtime.env), /GitHub 登录/); + assert.equal((await authRoute(request('/api/auth/logout'), runtime.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); +test('Cloudflare logout revokes the old Access JWT and delegates the upstream logout', async () => { + await withAccessKeys(async () => { + const runtime = environment({ auth_provider: 'cloudflare' }); + const token = await accessToken(); + const req = new Request(`${origin}/api/auth/logout`, { + method: 'POST', headers: { Origin: origin, 'Cf-Access-Jwt-Assertion': token }, + }); + const response = (await authRoute(req, { ...runtime.env, AUTH_PROVIDER: 'cloudflare' }))!; + assert.equal((await response.json() as { redirect: string }).redirect, '/cdn-cgi/access/logout'); + await assert.rejects(currentAccount(request('/api/auth/me', `CF_Authorization=${token}`), { + ...runtime.env, AUTH_PROVIDER: 'cloudflare', + }), /失效/); + }); +}); + +test('multiple Access identities share one workspace while invalid application tokens are rejected', async () => { + await withAccessKeys(async () => { + const runtime = environment({ auth_provider: 'cloudflare' }); + const cloudflare = { ...runtime.env, AUTH_PROVIDER: 'cloudflare' }; + const first = await accessToken({ sub: 'first-subject', email: 'first@example.com' }); + const second = await accessToken({ sub: 'second-subject', email: 'second@example.com' }); + assert.deepEqual(await currentAccount(request('/api/auth/me', `CF_Authorization=${first}`), cloudflare), { + id: runtime.state.account_id, username: 'first@example.com', + }); + assert.deepEqual(await currentAccount(request('/api/auth/me', `CF_Authorization=${second}`), cloudflare), { + id: runtime.state.account_id, username: 'second@example.com', + }); + + const wrongKeys = await generateKeyPair('RS256'); + for (const token of [ + await accessToken({}, { issuer: 'https://other.cloudflareaccess.com' }), + await accessToken({}, { audience: 'another-application' }), + await accessToken({}, { expires: '0s' }), + await accessToken({}, { key: wrongKeys.privateKey }), + ]) { + await assert.rejects(currentAccount(request('/api/auth/me', `CF_Authorization=${token}`), cloudflare), /失效/); + } + }); +}); + +test('provider round trips preserve encrypted data but never revive an old session', async () => { + await withAccessKeys(async () => { + const runtime = environment(); + const { cookie } = await login(runtime.env); + const github = await currentAccount(request('/api/auth/me', cookie), runtime.env); 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; } + const ciphertext = await encryptHost(secret, runtime.env.ENCRYPTION_KEY, github.id, 'host-1'); + + runtime.state.auth_provider = 'cloudflare'; + runtime.state.auth_revision++; + runtime.state.access_not_before = Math.floor(Date.now() / 1000) - 1; + const token = await accessToken({ sub: 'another-access-identity', email: 'second@example.com' }); + const cloudflareEnv = { ...runtime.env, AUTH_PROVIDER: 'cloudflare' }; + const cloudflare = await currentAccount(request('/api/auth/me', `CF_Authorization=${token}`), cloudflareEnv); + assert.equal(cloudflare.id, github.id); + assert.deepEqual(await decryptHost(ciphertext, runtime.env.ENCRYPTION_KEY, cloudflare.id, 'host-1'), secret); + + runtime.state.auth_provider = 'github'; + runtime.state.auth_revision++; + await assert.rejects(currentAccount(request('/api/auth/me', cookie), runtime.env), /过期/); + const fresh = await login(runtime.env); + const restored = await currentAccount(request('/api/auth/me', fresh.cookie), runtime.env); + assert.equal(restored.id, github.id); + assert.deepEqual(await decryptHost(ciphertext, runtime.env.ENCRYPTION_KEY, restored.id, 'host-1'), secret); + }); }); -test('expired sessions are rejected even with a correct signature', async () => { +test('expired sessions are rejected even with a correct signature and revision', 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' }) + const token = await new SignJWT({ sub: '123', username: 'admin', revision: 1 }).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), /过期/);