fix(auth): 固定单管理员工作区并撤销旧会话 / stabilize admin workspace and revoke stale sessions

This commit is contained in:
gdz66601 committed 2026-09-23 07:08:40 +08:00
1 parent 3e794e123e
commit c6e5786bba
24 files changed
+617 -162

No files matched your search

+2 -3
View File
@@ -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=
+1
View File
@@ -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 }}
+14 -10
View File
@@ -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 | `<Worker 名>-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`。
+7 -5
View File
@@ -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` | 默认 `<Worker 名>-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 端口。
+2 -2
View File
@@ -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 部署命令。
+17
View File
@@ -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;
-20
View File
@@ -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<string> {
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';
}
+16 -5
View File
@@ -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<void> {
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<void> {
// 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)]) {
+27 -10
View File
@@ -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<string>;
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<string>,
fetcher: typeof fetch = fetch,
options: AuthenticationOptions = {},
): Promise<{ secrets: Partial<RuntimeSecrets>; 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' },
+11 -5
View File
@@ -13,6 +13,7 @@ export interface DeploymentSettings {
authProvider: 'cloudflare' | 'github';
githubClientId?: string;
githubAdmin?: string;
githubAdminId?: string;
adminEmail?: string;
identityProviderIds: string[];
secrets: Partial<RuntimeSecrets>;
@@ -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 } : {}),
+15
View File
@@ -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<string | undefined> {
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,
+116
View File
@@ -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<T> { success: boolean; results: T[] }
function databasePath(settings: DeploymentSettings, database: Database): string {
return `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`;
}
async function query<T>(
api: CloudflareApi,
settings: DeploymentSettings,
database: Database,
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const result = await api.request<QueryResult<T>[]>(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<DeploymentWorkspaceState> {
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<StateRow>(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<DeploymentWorkspaceState> {
const rows = await query<StateRow>(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,
};
}
+5
View File
@@ -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<Response | null> {
const path = new URL(request.url).pathname;
@@ -9,6 +11,9 @@ export async function authRoute(request: Request, env: Env): Promise<Response |
const provider = authProvider(env);
if (path === '/api/auth/logout') {
if (request.method !== 'POST') return json({ error: '请使用 POST 退出登录。' }, 405);
await currentAccount(request, env);
const workspace = await workspaceState(env);
await revokeWorkspaceSessions(env, workspace);
return clearGithubCookies(json({ redirect: provider === 'cloudflare' ? '/cdn-cgi/access/logout' : '/' }));
}
if (request.method !== 'GET') return json({ error: 'Method not allowed' }, 405);
+15 -10
View File
@@ -4,18 +4,22 @@ import { accessToken } from './access-token.ts';
import { APIError } from './http.ts';
import { authProvider } from './auth-provider.ts';
import { githubAccount } from './github-auth.ts';
import { workspaceState, type WorkspaceState } from './workspace.ts';
export interface Account { id: string; username: string }
export interface WorkspaceAccount { id: string; username: string }
const resolvers = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
export async function currentAccount(request: Request, env: Env): Promise<Account> {
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<WorkspaceAccount> {
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<Account> {
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<Account> {
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);
}
+10 -4
View File
@@ -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);
}
+40
View File
@@ -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<Env, 'DB'>): Promise<WorkspaceState> {
const row = await env.DB.prepare(
'SELECT account_id, auth_provider, auth_revision, access_not_before FROM workspace_state WHERE id = 1',
).first<WorkspaceRow>();
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<Env, 'DB'>, state: WorkspaceState): Promise<void> {
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);
}
+4
View File
@@ -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:';
-1
View File
@@ -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;
+1 -6
View File
@@ -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<Response> {
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);
+8
View File
@@ -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);
});
+128 -37
View File
@@ -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<string, string>;
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');
});
+14 -1
View File
@@ -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, {
+1
View File
@@ -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 }}'));
+163 -43
View File
@@ -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<MemoryWorkspace> = {}) {
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<T>() { 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<T>(work: () => Promise<T>): Promise<T> {
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), /过期/);