fix(auth): decode GitHub signing keys as standard Base64 / 修复 GitHub 登录密钥解码

This commit is contained in:
gdz66601 committed 2026-10-07 23:26:42 +08:00
1 parent 93f2685140
commit e0eaa361a2
8 files changed
+222 -11

No files matched your search

+85 -6
View File
@@ -36,16 +36,89 @@ Access 应用的策略是唯一授权名单。可以在控制台添加多个明
## GitHub 模式准备 ## GitHub 模式准备
1. 打开 GitHub **设置(Settings)> 开发者设置(Developer settings)> OAuth 应用(OAuth Apps)> 新建 OAuth 应用(New OAuth App)**。 ### 第 1 步:Fork 并确定唯一入口
2. **应用名称(Application name)**自定;**主页 URL(Homepage URL)**填 EdgeSSH 地址,**授权回调 URL(Authorization callback URL)**填 `https://你的入口/auth/callback`。
3. 保存 Client ID,生成一个 Client Secret,按上表分别保存到 Actions Variable 和 Secret。 1. Fork 本仓库,打开**自己的 Fork** 的 **Actions** 页面,按页面提示启用工作流。首次部署使用最新 `main`,不要检出独立文档分支。
4. 设置 `AUTH_PROVIDER=github`、`CUSTOM_DOMAIN=你的主机名`、首次使用的 `GH_ADMIN=你的GitHub用户名`,保存 Cloudflare API Token,然后运行 **Actions > Deploy**。使用 `workers.dev` 时才省略 `CUSTOM_DOMAIN`;邮箱输入框留空。 2. 二选一确定入口,不要把两种方案混填:
5. 若首次部署前不知道入口,可先为 OAuth App 使用占位 URL;部署后将 Action 摘要中的正式入口与回调地址复制回 OAuth App 设置,再登录。
| 方案 | Actions Variable `CUSTOM_DOMAIN` | OAuth App 的主页与回调 |
| --- | --- | --- |
| 自定义域名 | 如 `ssh.example.com`,**只填主机名** | `https://ssh.example.com` 和 `https://ssh.example.com/auth/callback` |
| 免费 workers.dev | **不创建或留空**,不能填 workers.dev 地址 | 部署摘要里的正式入口,以及该入口加 `/auth/callback` |
自定义域名必须已由部署所用 Cloudflare 账户托管,建议使用尚未绑定其他应用的子域名;不需要手动新建 Worker、D1 或为本项目抄写 DNS 路由。若旧入口已经启用 Access,先按下文“切换登录方式”解除旧网关,不要叠加两层认证。
**本步检查**:你知道将来打开哪个入口;`CUSTOM_DOMAIN` 中没有 `https://`、斜杠、占位符或文档站域名。配置自定义域名后,备用 workers.dev 入口会关闭。
### 第 2 步:创建 GitHub OAuth App
在 GitHub **个人头像 > Settings > Developer settings > OAuth Apps > New OAuth App** 创建应用。注意是 **OAuth Apps**,不是 GitHub Apps,也不是 Personal access tokens。
| 表单字段 | 填写示例 |
| --- | --- |
| Application name | `My EdgeSSH`(名字自定) |
| Homepage URL | `https://ssh.example.com` |
| Authorization callback URL | `https://ssh.example.com/auth/callback` |
使用 workers.dev 且首次不知道地址时,可以先用 `https://example.com` 和 `https://example.com/auth/callback` 占位;**第 5 步必须替换为你自己的真实入口后才能登录**。
保存应用,复制 **Client ID**;点击 **Generate a new client secret**,安全保存刚生成的 **Client Secret**。二者必须来自同一个 OAuth App。不要把 Client ID、Client Secret、Cloudflare API Token 相互混用。
GitHub OAuth App 必须由用户在 GitHub 创建;普通 GitHub Token 没有官方“创建 OAuth App”的 REST 接口,工作流不会假装自动完成它。 GitHub OAuth App 必须由用户在 GitHub 创建;普通 GitHub Token 没有官方“创建 OAuth App”的 REST 接口,工作流不会假装自动完成它。
### 第 3 步:在 Fork 的 Actions 保存配置
进入**仓库**(不是个人 OAuth App 页面)的 **Settings > Secrets and variables > Actions**。在两个页签分别点击 **New repository variable** / **New repository secret**;不要建到未被工作流引用的 Environment 中。
| 页签 | Name(原样复制) | Value / Secret |
| --- | --- | --- |
| Variables | `AUTH_PROVIDER` | `github`(小写) |
| Variables | `GH_CLIENT_ID` | 上一步的 Client ID |
| Variables | `GH_ADMIN` | 唯一允许登录的个人 GitHub 用户名 |
| Variables | `CUSTOM_DOMAIN` | 仅自定义域名方案填写,如 `ssh.example.com` |
| Secrets | `GH_CLIENT_SECRET` | 同一 OAuth App 的 Client Secret |
| Secrets | `CLOUDFLARE_API_TOKEN` | 按下一节最小权限创建的 Cloudflare API Token |
例如管理员个人主页是 `https://github.com/example-user`,`GH_ADMIN` 就填 `example-user`,不是完整 URL、邮箱、组织名或显示昵称。Value 不加引号、尖括号或注释。通常不要填 `GH_ADMIN_ID`、`ADMIN_EMAIL`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 或 `ENCRYPTION_KEY`,也不需要额外的 GitHub PAT。
如需指定账户,在 **Variables** 保存 `CLOUDFLARE_ACCOUNT_ID`(32 位 Account ID,不是 Zone ID)。这种情况下 Token 可省略 Account Settings Read;不指定则需该读取权限且 Token 只应覆盖一个账户。
**本步检查**:两个 Secret 都在 **Secrets** 页签;`AUTH_PROVIDER`、`GH_CLIENT_ID`、`GH_ADMIN` 在 **Variables** 页签。工作流不会从 Secret 读取后三者。旧版 `CUSTOM_DOMAIN` Secret 优先于同名 Variable;若更换域名,务必同步处理旧值,避免仍部署到旧入口。不要手动在 Cloudflare 创建同名运行时覆盖配置。
### 第 4 步:运行 Deploy,检查整次任务而非单个步骤
打开 **Actions > Deploy > Run workflow**,选择 **main**,管理员邮箱框留空,点击运行。等待整次任务变绿,打开运行摘要。
首次部署会复用或创建 Worker 和 D1、迁移数据库、固定管理员数字 ID,并安全生成/保存加密密钥;重跑保留原密钥与资料。GitHub 模式部署结束前,还会实际访问正式入口:
- `/auth/login` 必须返回指向 GitHub 的 **302**,且能够签发 OAuth 临时 Cookie;
- 未携带登录 Cookie 的 `/api/auth/me` 必须返回 **401**,证明 D1 工作区与认证配置可读取且未开放匿名访问;
- 验收失败会让 Deploy 失败,而不是只凭“上传 Worker 成功”宣告可用。
**本步检查**:整次 Deploy 成功,日志有“GitHub 登录入口签名与 D1 工作区验收通过”,摘要显示 `github`、你的正式入口和回调地址。`/api/health` 返回正常或首页能打开,不能替代登录验收。此检查不向 GitHub提交授权,不验证 Client Secret 是否被撤销,仍须完成下一步。
### 第 5 步:回填回调地址并完成真实登录
1. 回到 **个人 Settings > Developer settings > OAuth Apps > 你的应用**。
2. 把摘要中的入口填入 **Homepage URL**;把完整回调地址填入 **Authorization callback URL**,保存。协议必须为 `https://`,域名必须相同,路径精确为 `/auth/callback`,不要多加末尾斜杠。
3. 打开摘要中的正式入口,点击 **登录**,使用 `GH_ADMIN` 指定的账号授权。若浏览器登录了别人的 GitHub,先切换账号。
4. 成功后应回到主机总览;同一浏览器访问 `/api/auth/me` 应返回 **200** 和 `provider: "github"`。随后用自己授权的服务器验证 SSH;没有 SSH 主机不会影响 GitHub 登录验证。
不要收藏或重新使用带 `code`、`state` 的 callback 地址;每次失败都从首页重新点登录。上面的占位 URL 只解决“先取得正式地址”的顺序问题,不是有效登录配置。
登录时仅读取 GitHub 公开身份,不申请仓库、组织或私人邮箱权限。首次部署将用户名解析为数字用户 ID 并固定在 D1;后续普通部署直接复用该 ID,不会因用户名改名或易主而改变管理员。仅在明确更换管理员时设置 `GH_ADMIN_ID` 为新的数字 ID 并部署,部署会同时撤销旧会话;完成后可保留该值作为显式配置。 登录时仅读取 GitHub 公开身份,不申请仓库、组织或私人邮箱权限。首次部署将用户名解析为数字用户 ID 并固定在 D1;后续普通部署直接复用该 ID,不会因用户名改名或易主而改变管理员。仅在明确更换管理员时设置 `GH_ADMIN_ID` 为新的数字 ID 并部署,部署会同时撤销旧会话;完成后可保留该值作为显式配置。
### 已部署用户:修复“服务暂时不可用,请稍后重试”
2026-10-07 修复了一个可复现的 GitHub 登录代码缺陷:自动生成的 `ENCRYPTION_KEY` 是标准 Base64,但旧代码按 Base64URL 解码;密钥包含 `+` 或 `/` 时,点击登录可能直接返回 **500**,甚至还没跳到 GitHub。原测试密钥不含这些字符,所以没拦住它。**该缺陷不代表你填错配置,也不是缺少 Access 权限。**
1. 在 Fork 执行 **Sync fork > Update branch**,确认已包含本次 Base64 解码修复。只重新运行旧提交对应的任务,不会获得新代码。
2. 保留现有 Worker、D1、OAuth App、`GH_CLIENT_SECRET` 和 `ENCRYPTION_KEY`。不要删除资源、清库、反复生成新密钥来碰运气。
3. 对更新后的 **main** 运行一次 **Deploy**,确认整次任务成功并通过上述入口验收。
4. 从摘要中的入口重新登录,不使用旧 callback 链接。
修复使用原密钥字节,不迁移或重加密已有主机资料。若更新后仍失败,按下文的“失败阶段”排查;同一句通用 500 也可能来自其他异常,不能只凭文案认定是这一原因。
## API Token 权限 ## API Token 权限
在 Cloudflare **我的个人资料(My Profile)> API 令牌(API Tokens)> 创建令牌(Create Token)**,以 **编辑 Cloudflare Workers(Edit Cloudflare Workers)** 模板为起点,再按下表删减或补齐权限。Cloudflare 中文界面可能仍显示部分英文;范围、权限名和级别均同时列出中英文。控制台的编辑(Edit)/读取(Read)对应 API 文档的 Write/Read。 在 Cloudflare **我的个人资料(My Profile)> API 令牌(API Tokens)> 创建令牌(Create Token)**,以 **编辑 Cloudflare Workers(Edit Cloudflare Workers)** 模板为起点,再按下表删减或补齐权限。Cloudflare 中文界面可能仍显示部分英文;范围、权限名和级别均同时列出中英文。控制台的编辑(Edit)/读取(Read)对应 API 文档的 Write/Read。
@@ -157,7 +230,7 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP
## 排障 ## 排障
- **403**:检查 Token 权限及账户/Zone 范围,不是重新生成加密密钥。 - **Deploy 中 Cloudflare API 返回 403**:检查 API Token 的权限、账户范围与有效期;若自行改成普通路由再检查 Zone 范围。浏览器 OAuth 回调返回 403“不是管理员”是另一回事,不需要扩大 Cloudflare Token 权限。
- **找不到唯一账户**:限定 Token 到一个账户,或配置 `CLOUDFLARE_ACCOUNT_ID`。 - **找不到唯一账户**:限定 Token 到一个账户,或配置 `CLOUDFLARE_ACCOUNT_ID`。
- **组织读取失败**:先完成 Zero Trust 开通和团队域设置。 - **组织读取失败**:先完成 Zero Trust 开通和团队域设置。
- **首次部署缺少邮箱**:在 Run workflow 输入,或设置 `ADMIN_EMAIL`。 - **首次部署缺少邮箱**:在 Run workflow 输入,或设置 `ADMIN_EMAIL`。
@@ -165,9 +238,15 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP
- **更换域名后无法登录**:带邮箱重新运行以配置新 hostname 的应用;不要仅改路由而沿用旧 AUD。 - **更换域名后无法登录**:带邮箱重新运行以配置新 hostname 的应用;不要仅改路由而沿用旧 AUD。
- **OTP 未收到**:确认输入邮箱完全匹配 Allow 策略,检查垃圾邮件。GitHub 等其他 IdP 的账户邮箱同样必须匹配授权。 - **OTP 未收到**:确认输入邮箱完全匹配 Allow 策略,检查垃圾邮件。GitHub 等其他 IdP 的账户邮箱同样必须匹配授权。
- **GitHub 回调失败**:检查 OAuth App 回调地址是否精确为 `https://实际入口/auth/callback`,Client ID/Secret 是否来自同一 OAuth App;重新从首页登录,不复用旧回调链接。 - **GitHub 回调失败**:检查 OAuth App 回调地址是否精确为 `https://实际入口/auth/callback`,Client ID/Secret 是否来自同一 OAuth App;重新从首页登录,不复用旧回调链接。
- **点击登录后、跳到 GitHub 前返回 500**:先同步含 Base64 解码修复的 `main` 并重新 Deploy,保留原 `ENCRYPTION_KEY`;不要授予额外 Access 权限。
- **提示“请从配置的正式入口登录”**:打开 Deploy 摘要的正式入口;核对 `CUSTOM_DOMAIN` 的 Secret/Variable 是否冲突,不要手工覆盖 Worker 的 `APP_ORIGIN`。
- **GitHub 授权返回后仍报 500/502**:检查失败请求是否为 `/auth/callback`、GitHub 连通性、OAuth App 的 Client Secret 与 D1 状态;从首页重新发起流程。不要把 callback URL、Cookie 或 Secret 发给他人。
- **Deploy 的入口验收无法连接**:检查自定义域名是否位于同一 Cloudflare 账户、DNS/证书是否就绪,待就绪后重跑;不要跳过验收或因超时重建数据库。
- **GitHub 拒绝管理员**:`GH_ADMIN` 应填个人用户名,不是邮箱或组织;用该账号重新授权。 - **GitHub 拒绝管理员**:`GH_ADMIN` 应填个人用户名,不是邮箱或组织;用该账号重新授权。
- 手工配置、截图与 Access JWT 排查见 [Zero Trust 指南](docs/ZERO_TRUST.md)。 - 手工配置、截图与 Access JWT 排查见 [Zero Trust 指南](docs/ZERO_TRUST.md)。
需要反馈时只提供:出错的步骤、正式入口(如可公开)、失败请求的**路径和 HTTP 状态码**、Deploy 提交 SHA/运行链接、脱敏错误截图。不要提供 API Token、Client Secret、ENCRYPTION_KEY、完整回调查询参数、请求 Cookie 或未经脱敏的整份网络日志。第三方服务故障、账户限制或 DNS 尚未生效时,应先解决对应问题;文档与自动检查不能替代真实授权验收。
## 验收清单 ## 验收清单
- `npm run check`:类型检查、测试、前端构建与部署 dry-run。 - `npm run check`:类型检查、测试、前端构建与部署 dry-run。
+4
View File
@@ -429,6 +429,10 @@ GH_CLIENT_SECRET=<Secret> # Secret
GH_ADMIN=<GitHub 用户名> # Variable GH_ADMIN=<GitHub 用户名> # Variable
``` ```
首次部署请按 [GitHub OAuth 五步完整教程](DEPLOYMENT.md#github-模式准备)操作:创建的是 **OAuth App**,Client ID 放 Variable,Client Secret 放 Secret,回调必须是正式入口加 `/auth/callback`。无需 Zero Trust、Access 权限或额外 GitHub PAT。
**旧版点击登录报“服务暂时不可用”**:请同步含 2026-10-07 Base64 解码修复的代码,再对最新 `main` 运行 Deploy。保留原 Worker、D1 和 `ENCRYPTION_KEY`;不是靠扩大 Token 权限或换密钥解决。新版 Deploy 会自动检查 GitHub 登录入口与工作区状态。
或者 Cloudflare Access: 或者 Cloudflare Access:
```text ```text
+5
View File
@@ -10,6 +10,7 @@ import { previewOrigin } from '../src/forwarding/security.ts';
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts'; import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts';
import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts'; import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts';
import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts'; import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts';
import { verifyGithubDeployment } from './deployment-health.ts';
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const generatedConfig = '.wrangler.generated.toml'; const generatedConfig = '.wrangler.generated.toml';
@@ -90,6 +91,10 @@ async function main(): Promise<void> {
for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) { for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) {
if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`); if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`);
} }
if (settings.authProvider === 'github') {
await verifyGithubDeployment(hostname, settings.githubClientId!);
console.log('GitHub 登录入口签名与 D1 工作区验收通过(真实 GitHub 授权仍需管理员登录完成)。');
}
// 预览 Worker 只做代理入口和 DO 绑定,不执行迁移、不写入任何 Secret。 // 预览 Worker 只做代理入口和 DO 绑定,不执行迁移、不写入任何 Secret。
if (settings.deployPreview) { if (settings.deployPreview) {
const previewConfig = createPreviewDeploymentConfig(template, settings, preview!); const previewConfig = createPreviewDeploymentConfig(template, settings, preview!);
+34
View File
@@ -0,0 +1,34 @@
/**
* 在正式入口验证 GitHub 登录,不跟随跳转、不访问用户资料。
* Secret 名称齐全不代表运行时可用:必须实际签发 flow Cookie 并读取 D1 工作区。
*/
export async function verifyGithubDeployment(
hostname: string,
clientId: string,
fetcher: typeof fetch = fetch,
): Promise<void> {
const origin = `https://${hostname}`;
async function probe(path: string): Promise<Response> {
try {
return await fetcher(`${origin}${path}`, { redirect: 'manual', signal: AbortSignal.timeout(30_000) });
} catch {
// 不把网络异常的原始内容或带 OAuth state 的响应头写入部署日志。
throw new Error(`GitHub 登录入口验收无法连接 ${path},请检查正式域名的 DNS、证书及网络后重跑 Deploy。`);
}
}
const login = await probe('/auth/login');
const location = login.headers.get('Location');
const target = location && URL.canParse(location, origin) ? new URL(location, origin) : null;
if (login.status !== 302 || target?.origin !== 'https://github.com'
|| target.pathname !== '/login/oauth/authorize'
|| target.searchParams.get('client_id') !== clientId
|| target.searchParams.get('redirect_uri') !== `${origin}/auth/callback`
|| !login.headers.getSetCookie().some((value) => value.startsWith('__Host-edgessh-oauth='))) {
throw new Error(`GitHub 登录入口验收失败(/auth/login HTTP ${login.status})。请检查 Worker 配置与正式入口,修复后重跑 Deploy;不要更换 ENCRYPTION_KEY。`);
}
const account = await probe('/api/auth/me');
// 无 Cookie 的请求必须拒绝授权;500/503 表示数据库或认证状态仍不可用。
if (account.status !== 401) {
throw new Error(`GitHub 工作区验收失败(/api/auth/me HTTP ${account.status})。请检查 D1 迁移与认证方式是否部署完成,再重跑 Deploy。`);
}
}
+6 -2
View File
@@ -12,10 +12,14 @@ export async function digest(value: string): Promise<string> {
return base64(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value)))); return base64(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value))));
} }
async function encryptionKey(secret: string): Promise<CryptoKey> { export function encryptionKeyBytes(secret: string): Uint8Array<ArrayBuffer> {
const key = unbase64(secret); const key = unbase64(secret);
if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be 32 bytes encoded as base64'); if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be 32 bytes encoded as base64');
return crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt', 'decrypt']); return key;
}
async function encryptionKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', encryptionKeyBytes(secret), 'AES-GCM', false, ['encrypt', 'decrypt']);
} }
export async function encryptHost(value: unknown, secret: string, accountId: string, hostId: string): Promise<string> { export async function encryptHost(value: unknown, secret: string, accountId: string, hostId: string): Promise<string> {
+4 -1
View File
@@ -3,6 +3,7 @@ import type { Env } from '../types.ts';
import { APIError, apiFailure } from './http.ts'; import { APIError, apiFailure } from './http.ts';
import { secureResponse } from '../http-security.ts'; import { secureResponse } from '../http-security.ts';
import { workspaceState } from './workspace.ts'; import { workspaceState } from './workspace.ts';
import { encryptionKeyBytes } from './crypto.ts';
const sessionCookie = '__Host-edgessh-session'; const sessionCookie = '__Host-edgessh-session';
const flowCookie = '__Host-edgessh-oauth'; const flowCookie = '__Host-edgessh-oauth';
@@ -30,7 +31,9 @@ function setCookie(name: string, value: string, maxAge: number): string {
// 使用 HKDF 做用途隔离,不直接把资料加密密钥用于签名,也不新增用户需要维护的密钥。 // 使用 HKDF 做用途隔离,不直接把资料加密密钥用于签名,也不新增用户需要维护的密钥。
async function signingKey(secret: string): Promise<Uint8Array> { async function signingKey(secret: string): Promise<Uint8Array> {
const material = await crypto.subtle.importKey('raw', new Uint8Array(base64url.decode(secret)), 'HKDF', false, ['deriveBits']); // 部署生成的是标准 Base64(可能含 +、/),不是 OAuth/JWT 使用的 Base64URL。
// 与资料加密共用解码,保留原始密钥字节,修复登录时无需轮换密钥或重加密数据。
const material = await crypto.subtle.importKey('raw', encryptionKeyBytes(secret), 'HKDF', false, ['deriveBits']);
return new Uint8Array(await crypto.subtle.deriveBits({ return new Uint8Array(await crypto.subtle.deriveBits({
name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'),
info: encoder.encode('github-oauth-cookie'), info: encoder.encode('github-oauth-cookie'),
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { verifyGithubDeployment } from '../scripts/deployment-health.ts';
const hostname = 'ssh.example.com';
const clientId = 'test-client';
const location = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=https%3A%2F%2F${hostname}%2Fauth%2Fcallback`;
const redirect = (target = location, cookie = '__Host-edgessh-oauth=test; Path=/; Secure; HttpOnly') =>
new Response(null, { status: 302, headers: { Location: target, 'Set-Cookie': cookie } });
test('GitHub deployment probes only its own origin without following OAuth or calling Access', async () => {
const calls: string[] = [];
await verifyGithubDeployment(hostname, clientId, async (url, init) => {
assert.equal(init?.redirect, 'manual');
assert.equal(init?.headers, undefined);
const request = new Request(url, init);
const path = new URL(request.url).pathname;
calls.push(path);
assert.equal(new URL(request.url).origin, `https://${hostname}`);
return path === '/auth/login' ? redirect() : new Response(null, { status: 401 });
});
assert.deepEqual(calls, ['/auth/login', '/api/auth/me']);
});
test('deployment rejects broken signing, wrong OAuth configuration and stale Access protection', async () => {
for (const response of [
Response.json({ error: '服务暂时不可用,请稍后重试。' }, { status: 500 }),
redirect('https://team.cloudflareaccess.com/cdn-cgi/access/login'),
redirect(location.replace(clientId, 'old-client')),
redirect(location.replace('ssh.example.com', 'old.example.com')),
redirect(location, ''),
redirect('http://['),
]) {
await assert.rejects(verifyGithubDeployment(hostname, clientId, async () => response), /登录入口验收失败/);
}
});
test('deployment rejects uninitialized D1, provider mismatch and anonymous access', async () => {
for (const status of [500, 503, 200, 302]) {
await assert.rejects(verifyGithubDeployment(hostname, clientId, async (url) =>
String(url).endsWith('/auth/login') ? redirect() : new Response(null, { status })),
/工作区验收失败/);
}
});
test('deployment network failures never expose upstream errors or cookie values', async () => {
await assert.rejects(verifyGithubDeployment(hostname, clientId, async () => {
throw new Error('private-client-secret');
}), (error: Error) => /DNS/.test(error.message) && !error.message.includes('private-client-secret'));
});
+34 -2
View File
@@ -5,7 +5,9 @@ import type { Env } from '../src/types.ts';
import { currentAccount } from '../src/accounts/auth.ts'; import { currentAccount } from '../src/accounts/auth.ts';
import { authRoute } from '../src/accounts/auth-routes.ts'; import { authRoute } from '../src/accounts/auth-routes.ts';
import { githubCallback, githubLogin } from '../src/accounts/github-auth.ts'; import { githubCallback, githubLogin } from '../src/accounts/github-auth.ts';
import { encryptHost, decryptHost } from '../src/accounts/crypto.ts'; import { encryptHost, decryptHost, encryptionKeyBytes } from '../src/accounts/crypto.ts';
import { apiFailure } from '../src/accounts/http.ts';
import { verifyGithubDeployment } from '../scripts/deployment-health.ts';
const origin = 'https://ssh.example.com'; const origin = 'https://ssh.example.com';
const baseEnv = { const baseEnv = {
@@ -130,6 +132,36 @@ test('OAuth starts with random state, S256 PKCE and no privileged scopes', async
assert.equal(first.authorization.toString().includes(baseEnv.GH_CLIENT_SECRET), false); assert.equal(first.authorization.toString().includes(baseEnv.GH_CLIENT_SECRET), false);
}); });
test('OAuth accepts standard Base64 encryption keys containing + and / without changing stored data', async () => {
// 原固定密钥只有字母数字,掩盖了将标准 Base64 错当 Base64URL 解码的线上故障。
for (const byte of [7, 251, 255]) {
for (const padded of [true, false]) {
const runtime = environment();
const encoded = Buffer.alloc(32, byte).toString('base64');
runtime.env.ENCRYPTION_KEY = padded ? encoded : encoded.replace(/=+$/, '');
const secret = { password: 'existing-host-password' };
const ciphertext = await encryptHost(secret, runtime.env.ENCRYPTION_KEY, runtime.state.account_id, 'host-1');
const { cookie } = await login(runtime.env);
const account = await currentAccount(request('/api/auth/me', cookie), runtime.env);
assert.equal(account.username, 'administrator');
assert.deepEqual(await decryptHost(ciphertext, runtime.env.ENCRYPTION_KEY, account.id, 'host-1'), secret);
}
}
});
test('deployment smoke test exercises the OAuth signer and D1 workspace', async () => {
const runtime = environment();
runtime.env.ENCRYPTION_KEY = Buffer.alloc(32, 251).toString('base64');
await verifyGithubDeployment(new URL(origin).hostname, baseEnv.GH_CLIENT_ID, async (url, init) => {
const req = new Request(url, init);
if (new URL(req.url).pathname === '/auth/login') return githubLogin(req, runtime.env);
try {
await currentAccount(req, runtime.env);
return Response.json({ authenticated: true });
} catch (error) { return apiFailure(error); }
});
});
test('callback binds state to its browser cookie before any token exchange', async () => { test('callback binds state to its browser cookie before any token exchange', async () => {
const flow = await start(); const flow = await start();
let calls = 0; let calls = 0;
@@ -297,7 +329,7 @@ test('provider round trips preserve encrypted data but never revive an old sessi
test('expired sessions are rejected even with a correct signature and revision', async () => { test('expired sessions are rejected even with a correct signature and revision', async () => {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const material = await crypto.subtle.importKey('raw', base64url.decode(env.ENCRYPTION_KEY), 'HKDF', false, ['deriveBits']); const material = await crypto.subtle.importKey('raw', encryptionKeyBytes(env.ENCRYPTION_KEY), 'HKDF', false, ['deriveBits']);
const key = new Uint8Array(await crypto.subtle.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'), name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), info: encoder.encode('github-oauth-cookie'),
}, material, 256)); }, material, 256));