fix(auth): decode GitHub signing keys as standard Base64 / 修复 GitHub 登录密钥解码
This commit is contained in:
1 parent
93f2685140
commit
e0eaa361a2
8 files changed
+222
-11
No files matched your search
+85
-6
@@ -36,16 +36,89 @@ Access 应用的策略是唯一授权名单。可以在控制台添加多个明
|
||||
|
||||
## 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=你的主机名`、首次使用的 `GH_ADMIN=你的GitHub用户名`,保存 Cloudflare API Token,然后运行 **Actions > Deploy**。使用 `workers.dev` 时才省略 `CUSTOM_DOMAIN`;邮箱输入框留空。
|
||||
5. 若首次部署前不知道入口,可先为 OAuth App 使用占位 URL;部署后将 Action 摘要中的正式入口与回调地址复制回 OAuth App 设置,再登录。
|
||||
### 第 1 步:Fork 并确定唯一入口
|
||||
|
||||
1. Fork 本仓库,打开**自己的 Fork** 的 **Actions** 页面,按页面提示启用工作流。首次部署使用最新 `main`,不要检出独立文档分支。
|
||||
2. 二选一确定入口,不要把两种方案混填:
|
||||
|
||||
| 方案 | 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 接口,工作流不会假装自动完成它。
|
||||
|
||||
### 第 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 并部署,部署会同时撤销旧会话;完成后可保留该值作为显式配置。
|
||||
|
||||
### 已部署用户:修复“服务暂时不可用,请稍后重试”
|
||||
|
||||
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 权限
|
||||
|
||||
在 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`。
|
||||
- **组织读取失败**:先完成 Zero Trust 开通和团队域设置。
|
||||
- **首次部署缺少邮箱**:在 Run workflow 输入,或设置 `ADMIN_EMAIL`。
|
||||
@@ -165,9 +238,15 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP
|
||||
- **更换域名后无法登录**:带邮箱重新运行以配置新 hostname 的应用;不要仅改路由而沿用旧 AUD。
|
||||
- **OTP 未收到**:确认输入邮箱完全匹配 Allow 策略,检查垃圾邮件。GitHub 等其他 IdP 的账户邮箱同样必须匹配授权。
|
||||
- **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` 应填个人用户名,不是邮箱或组织;用该账号重新授权。
|
||||
- 手工配置、截图与 Access JWT 排查见 [Zero Trust 指南](docs/ZERO_TRUST.md)。
|
||||
|
||||
需要反馈时只提供:出错的步骤、正式入口(如可公开)、失败请求的**路径和 HTTP 状态码**、Deploy 提交 SHA/运行链接、脱敏错误截图。不要提供 API Token、Client Secret、ENCRYPTION_KEY、完整回调查询参数、请求 Cookie 或未经脱敏的整份网络日志。第三方服务故障、账户限制或 DNS 尚未生效时,应先解决对应问题;文档与自动检查不能替代真实授权验收。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- `npm run check`:类型检查、测试、前端构建与部署 dry-run。
|
||||
|
||||
@@ -429,6 +429,10 @@ GH_CLIENT_SECRET=<Secret> # Secret
|
||||
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:
|
||||
|
||||
```text
|
||||
|
||||
@@ -10,6 +10,7 @@ import { previewOrigin } from '../src/forwarding/security.ts';
|
||||
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts';
|
||||
import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts';
|
||||
import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts';
|
||||
import { verifyGithubDeployment } from './deployment-health.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const generatedConfig = '.wrangler.generated.toml';
|
||||
@@ -90,6 +91,10 @@ async function main(): Promise<void> {
|
||||
for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) {
|
||||
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。
|
||||
if (settings.deployPreview) {
|
||||
const previewConfig = createPreviewDeploymentConfig(template, settings, preview!);
|
||||
|
||||
@@ -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。`);
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
async function encryptionKey(secret: string): Promise<CryptoKey> {
|
||||
export function encryptionKeyBytes(secret: string): Uint8Array<ArrayBuffer> {
|
||||
const key = unbase64(secret);
|
||||
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> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Env } from '../types.ts';
|
||||
import { APIError, apiFailure } from './http.ts';
|
||||
import { secureResponse } from '../http-security.ts';
|
||||
import { workspaceState } from './workspace.ts';
|
||||
import { encryptionKeyBytes } from './crypto.ts';
|
||||
|
||||
const sessionCookie = '__Host-edgessh-session';
|
||||
const flowCookie = '__Host-edgessh-oauth';
|
||||
@@ -30,7 +31,9 @@ function setCookie(name: string, value: string, maxAge: number): string {
|
||||
|
||||
// 使用 HKDF 做用途隔离,不直接把资料加密密钥用于签名,也不新增用户需要维护的密钥。
|
||||
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({
|
||||
name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'),
|
||||
info: encoder.encode('github-oauth-cookie'),
|
||||
|
||||
@@ -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
@@ -5,7 +5,9 @@ import type { Env } from '../src/types.ts';
|
||||
import { currentAccount } from '../src/accounts/auth.ts';
|
||||
import { authRoute } from '../src/accounts/auth-routes.ts';
|
||||
import { githubCallback, githubLogin } from '../src/accounts/github-auth.ts';
|
||||
import { encryptHost, decryptHost } from '../src/accounts/crypto.ts';
|
||||
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 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const flow = await start();
|
||||
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 () => {
|
||||
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({
|
||||
name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('edgessh:v1'), info: encoder.encode('github-oauth-cookie'),
|
||||
}, material, 256));
|
||||
|
||||
Reference in new issue
Block a user