fix(v0.2): align Keycloak auth to HTTPS session API key

This commit is contained in:
Codex Agent
2026-06-04 15:01:26 +08:00
parent 683d7b7459
commit e64ee92fb1
8 changed files with 242 additions and 83 deletions
+5 -3
View File
@@ -126,10 +126,12 @@
"sourceBranch": "v0.2",
"gitopsBranch": "v0.2-gitops",
"namespace": "hwlab-v02",
"endpoint": "http://74.48.78.17:19667",
"endpoint": "https://hwlab.74-48-78-17.nip.io",
"publicEndpoints": {
"frontend": "http://74.48.78.17:19666",
"api": "http://74.48.78.17:19667"
"frontend": "https://hwlab.74-48-78-17.nip.io",
"api": "https://hwlab.74-48-78-17.nip.io",
"legacyFrontend": "http://74.48.78.17:19666",
"legacyApi": "http://74.48.78.17:19667"
},
"artifactCatalog": "deploy/artifact-catalog.v02.json",
"runtimePath": "deploy/gitops/g14/runtime-v02",
@@ -58,9 +58,9 @@ spec:
- name: CLIENT_ID
value: hwlab-cloud-web
- name: REDIRECT_URI_DEV
value: http://74.48.78.17:19667/auth/oidc/callback
value: https://hwlab.74-48-78-17.nip.io/auth/oidc/callback
- name: WEB_ORIGIN_DEV
value: http://74.48.78.17:19666
value: https://hwlab.74-48-78-17.nip.io
- name: ACCESS_TOKEN_LIFESPAN
value: "1440"
- name: SSO_SESSION_MAX_LIFESPAN
@@ -133,7 +133,7 @@ spec:
-s 'redirectUris=["'"$REDIRECT_URI_DEV"'"]' \
-s 'webOrigins=["'"$WEB_ORIGIN_DEV"'"]' \
-s standardFlowEnabled=true \
-s directAccessGrantsEnabled=true
-s directAccessGrantsEnabled=false
echo "client-updated"
else
echo "creating-client"
@@ -145,7 +145,7 @@ spec:
-s 'redirectUris=["'"$REDIRECT_URI_DEV"'"]' \
-s 'webOrigins=["'"$WEB_ORIGIN_DEV"'"]' \
-s standardFlowEnabled=true \
-s directAccessGrantsEnabled=true
-s directAccessGrantsEnabled=false
echo "client-created"
fi
echo "keycloak-bootstrap-complete"
+12
View File
@@ -37,3 +37,15 @@ auth.74-48-78-17.nip.io {
}
}
}
hwlab.74-48-78-17.nip.io {
encode zstd gzip
reverse_proxy 127.0.0.1:19666 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
dial_timeout 5s
response_header_timeout 30s
}
}
}
+3 -3
View File
@@ -4,7 +4,7 @@
本规格与 [spec-device-pod.md](spec-device-pod.md) 配套:用户和权限规格定义谁可以看见、创建和使用 device poddevice-pod 规格定义 profile authority、REST/job 和硬件执行边界。
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。本文只定义认证完成后的角色、资源归属、device pod grant 和 code agent owner 授权;除非明确标注为 bootstrap/legacy,不再把本地账号密码登录作为目标登录体验。
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。本文只定义认证完成后的角色、资源归属、device pod grant 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类,除非明确标注为 bootstrap/legacy,不再把本地账号密码登录或 device-pod 系统 key 作为目标登录体验。
实施跟踪见 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531),原 `docs/plan/v02-multi-user-migration.md` 迁移计划全文已迁入该 issue 评论。
@@ -182,7 +182,7 @@ cli
-> AuthPrincipal(authMethod='api-key')
```
登录、Keycloak issuer、CLI API key 和 24 小时 Web session 规则见 [spec-v02-auth.md](spec-v02-auth.md)。资源授权模块只消费已经恢复出的 actor/AuthPrincipal。`/auth/session` 使用 cookie 查 `user_sessions`,再查 `users` 得到 `actor`API key 认证直接从 `api_keys -> users` 得到同一 actor。`/auth/logout` 标记 `user_sessions.revoked_at`
登录、Keycloak issuer、CLI API key 和 24 小时 Web session 规则见 [spec-v02-auth.md](spec-v02-auth.md)。资源授权模块只消费已经恢复出的 actor/AuthPrincipal。`/auth/session` 使用 cookie 查 `user_sessions`,再查 `users` 得到 `actor`API key 认证直接从 `api_keys -> users` 得到同一 actor。`/auth/logout` 标记 `user_sessions.revoked_at`Keycloak access token、refresh token、realm role 和 device-pod 内部系统 key 都不能绕过这里的 actor 恢复与资源授权。
### admin 创建用户
@@ -257,7 +257,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
`hwlab-cloud-api` 内部应按 auth/session/API key、authorization、agent session owner、device-pod grant 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。
`user_sessions` 存浏览器 session token hash`api_keys` 存映射到用户的 CLI/runner API key`agent_sessions.owner_user_id` 绑定 Code Agent session`device_pods` 存 profile authority`device_pod_grants` 表示普通用户对 device pod 的完整使用权。不得再引入对所有正式 device pod 授权的 `HWLAB_DEVICE_POD_API_KEY`
`user_sessions` 存浏览器 session token hash`api_keys` 存映射到用户的 CLI/runner API key`agent_sessions.owner_user_id` 绑定 Code Agent session`device_pods` 存 profile authority`device_pod_grants` 表示普通用户对 device pod 的完整使用权。不得再引入对所有正式 device pod 授权的共享用户 key`HWLAB_DEVICE_POD_API_KEY` 如仍存在,只能作为 cloud-api 到 device-pod 的内部服务凭据
## API 接口说明
+33 -29
View File
@@ -1,6 +1,6 @@
# v0.2 登录与鉴权规格
本文是 HWLAB `v0.2` 登录、认证和应用层鉴权入口的长期规格。它把 Keycloak OIDC、浏览器 Web sessionCLI/API/AgentRun 共用的用户 API key 和 HWLAB 内部 `AuthPrincipal` 归一到同一套口径,避免把本地账号密码、浏览器 cookie 和设备专用系统 key 混用。
本文是 HWLAB `v0.2` 登录、认证和应用层鉴权入口的长期规格。正式用户鉴权只保留两类凭据:浏览器使用 `hwlab_session` Web sessionCLI/API/AgentRun runner 使用用户级 `HWLAB_API_KEY`。Keycloak 只负责 Web 身份认证和注册,HWLAB 在 callback 后发行自己的 session、API key 和 `AuthPrincipal`,避免把 Keycloak token、本地密码、浏览器 cookie 和设备内部系统 key 混用。
基础设施实施跟踪见 [pikasTech/HWLAB#788](https://github.com/pikasTech/HWLAB/issues/788)Keycloak 到 HWLAB 的接入收口见 [pikasTech/HWLAB#814](https://github.com/pikasTech/HWLAB/issues/814)。用户角色、Code Agent session owner、device pod grant 和资源授权矩阵见 [spec-user-access.md](spec-user-access.md);本文只定义“如何登录、如何恢复 actor、如何把请求归一成 actor”。
@@ -8,8 +8,8 @@
- Web 用户通过 Keycloak OIDC 登录或注册,HWLAB 不再把本地账号密码表单作为目标登录体验。
- Web 使用 `hwlab-cloud-api` 发行的 httpOnly `hwlab_session`,每个 session token 最长 24 小时;第一版不做复杂 refresh token 管理,但必须可撤销、可重新登录轮换。
- CLI 是纯 CLI 体验,不打开浏览器、不跳转 Web、不做 device-code flow;标准凭据是环境变量 `HWLAB_API_KEY`
- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`不再引入单独的 `HWLAB_DEVICE_POD_API_KEY`
- CLI 是纯 CLI 体验,不打开浏览器、不跳转 Web、不做 device-code flow,也不直接消费 Keycloak access token;标准凭据是环境变量 `HWLAB_API_KEY`
- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id``HWLAB_DEVICE_POD_API_KEY` 只能作为 cloud-api 到 device-pod 的内部服务凭据或迁移期兼容项,不能作为正式用户鉴权方式
- 每个用户在首次登录后自动拥有一个默认 API key;用户也可以在 Web 中创建、查看、失效或重新生成 API key。
- API key 长效有效,除非用户或管理员手动 revoke/regenerate;它不跟 Web session 的 24 小时过期绑定。
- Keycloak 只做身份认证和账号注册;HWLAB 的 `users.role``users.status`、device pod grant 和 Code Agent owner 仍是应用层授权 source of truth。
@@ -22,7 +22,7 @@
| Keycloak | 独立 `keycloak` namespace 中的身份提供方,提供公网 HTTPS OIDC issuer、登录页、注册页和用户 subject。 |
| Keycloak Postgres | Keycloak 专用数据库,短期可与 Keycloak 同在 `keycloak` namespace;不复用 HWLAB v0.2 业务 Postgres。 |
| `hwlab-cloud-api` | OIDC callback、session token、API key、用户映射、`AuthPrincipal` 和应用层授权收口。 |
| `hwlab-cloud-web` | 浏览器 UI 和同源代理;未登录时引导到 Keycloak,不持有 Keycloak client secret,不直接访问 Postgres。 |
| `hwlab-cloud-web` | 浏览器 UI 和同源代理;未登录时通过 `/auth/oidc/login` 引导到 Keycloak,不持有 Keycloak client secret,不直接访问 Postgres。 |
| `hwlab-cli` / AgentRun `hwpod` | 读取 `HWLAB_API_KEY`,向 Cloud Web 同源 API 或 Cloud API 发送 `Authorization: Bearer hwl_live_...`,恢复到同一个用户 actor。 |
| `hwlab-edge-proxy` / FRP | 只转发 cookie/header 和 HTTP 流量,不注入伪 actor,不做业务授权。 |
| HWLAB v0.2 Postgres | 保存 HWLAB 用户映射、session、API key、resource grant 和 runtime durable state。 |
@@ -31,12 +31,13 @@ Keycloak 部署不进入 `hwlab-v02` namespace。`hwlab-v02` 只把 Keycloak 当
## 公网 HTTPS 与 issuer
Keycloak 必须有稳定公网 HTTPS issuerOIDC redirect URI、cookie secure policy 和浏览器登录都依赖这一点。短期全自动方案使用主 server Caddy + Let's Encrypt + nip.io 域名,并通过 FRP 把 G14 `keycloak` namespace 的 Keycloak Service 暴露到主 server 本地端口。
Keycloak 和 HWLAB 浏览器入口都必须使用公网 HTTPSOIDC redirect URI、cookie `Secure` policy 和浏览器登录都依赖这一点。短期全自动方案使用主 server Caddy + Let's Encrypt + nip.io 域名Keycloak 通过 FRP 暴露到 `auth.74-48-78-17.nip.io`HWLAB Cloud Web 通过 Caddy 反向代理到 v0.2 Cloud Web FRP 入口。
目标 issuer
目标公网入口
```text
https://auth.74-48-78-17.nip.io/realms/hwlab
Keycloak issuer: https://auth.74-48-78-17.nip.io/realms/hwlab
HWLAB Web/API origin: https://hwlab.74-48-78-17.nip.io
```
约束:
@@ -44,8 +45,9 @@ https://auth.74-48-78-17.nip.io/realms/hwlab
- Keycloak admin console、account console、OIDC discovery 和 JWKS 都必须走 HTTPS;不要用裸 HTTP issuer。
- `hwlab-cloud-api` 只记录 issuer、client id、redirect URI 和 SecretRef 名称,不在日志、issue 或 ConfigMap 中输出 client secret。
- Keycloak 的 `hostname`、proxy header 和 frontend URL 必须与公网 issuer 一致,避免 callback 后 token issuer mismatch。
- `hwlab-cloud-api``hwlab-cloud-web` 和 CLI 仍保持 v0.2 原入口 `19666/19667`Keycloak 只新增 auth 域名,不替代 HWLAB Cloud Web
- Keycloak OIDC client 的 redirect URI 必须指向 `hwlab-cloud-api` callback(当前 v0.2 为 `http://74.48.78.17:19667/auth/oidc/callback``https://auth.74-48-78-17.nip.io` 只作为 Keycloak issuer/authorization host,不能配置成 HWLAB callback 目标。
- 浏览器和 CLI 默认使用 `https://hwlab.74-48-78-17.nip.io``http://74.48.78.17:19666/19667` 只作为 legacy/debug 入口,不作为 OIDC callback、cookie 或用户文档默认入口
- Keycloak OIDC client 的 redirect URI 必须指向 HWLAB callback`https://hwlab.74-48-78-17.nip.io/auth/oidc/callback``https://auth.74-48-78-17.nip.io` 只作为 Keycloak issuer/authorization host,不能配置成 HWLAB callback 目标。
- `https://hwlab.74-48-78-17.nip.io/auth/*` 由 Cloud Web 同源代理到 Cloud APICloud Web 不读取 client secrettoken exchange 只在 Cloud API 内完成。
## Keycloak 当前部署基线
@@ -71,7 +73,7 @@ browser
- 公网 issuer 是 `https://auth.74-48-78-17.nip.io/realms/hwlab`discovery 与 JWKS 必须全部返回 HTTPS URL。
- Keycloak management 端口 `9000` 不作为公网入口暴露;健康和管理探测留在集群内或受控透传内完成。
- `hwlab` realm 必须 enabled,并在小范围测试阶段允许 self-registration;新注册用户在 HWLAB 应用层只能默认成为普通 `user`,不自动拥有 device pod grant。
- `hwlab-cloud-web` client 的 redirect URI 指向 `http://74.48.78.17:19667/auth/oidc/callback`web origin 指向 `http://74.48.78.17:19666`
- `hwlab-cloud-web` client 的 redirect URI 指向 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`web origin 指向 `https://hwlab.74-48-78-17.nip.io`Direct Access Grants 必须关闭,避免把 Keycloak password grant 变成第三种 CLI 鉴权方式
管理员凭据边界:
@@ -131,7 +133,7 @@ type AuthPrincipal = {
displayName: string;
role: "admin" | "user";
status: "active" | "disabled";
authMethod: "web-session" | "api-key" | "legacy-local-session";
authMethod: "web-session" | "api-key";
sessionId?: string;
apiKeyId?: string;
keycloakIssuer?: string;
@@ -140,13 +142,12 @@ type AuthPrincipal = {
};
```
认证顺序必须显式区分两类正式用户凭据和 legacy fallback
正式用户态请求只接受两类凭据
1. `Authorization: Bearer hwl_live_...``HWLAB_API_KEY` 映射出的 header 是用户 API key,适用于 CLI 和 AgentRun runner 内 `hwpod`
2. `hwlab_session` cookie 是 Web session。
3. 本地账号密码 session token 只作为 legacy/debug fallback。
现有 `Authorization: Bearer <session-token>` 兼容入口只能保留为 legacy/debug,不得阻塞 `hwl_live_` 前缀的用户 API key 识别
本地 `/auth/login` 只用于空库 bootstrap 和 legacy/debug;它发行的 session 也必须走同一个 `hwlab_session` cookie,不再通过 `Authorization: Bearer <session-token>` 恢复用户。device-pod 内部服务 key 只能在 cloud-api 到 device-pod 的受控链路内使用,不出现在用户 API、CLI、AgentRun runner 或浏览器文档中
## Web 登录流程
@@ -156,7 +157,9 @@ browser -> Cloud Web /
-> GET /auth/oidc/login?returnTo=/
-> 302 Keycloak login/register
-> Keycloak callback with code
-> hwlab-cloud-api validates OIDC code/issuer/JWKS
-> hwlab-cloud-api exchanges authorization code with confidential client
-> validate signed id_token via Keycloak JWKS, iss/aud/azp/exp/iat/nonce/sub
-> fetch userinfo only as profile enrichment and require userinfo.sub == id_token.sub
-> upsert users by issuer+sub
-> ensure default API key exists for the user
-> create user_sessions token hash, expires_at <= now + 24h
@@ -164,14 +167,15 @@ browser -> Cloud Web /
-> redirect returnTo
```
Web 页面不要求用户输入或复制 API key。浏览器请求通过 `hwlab_session` 恢复 `AuthPrincipal`;需要 CLI 时,用户在 Web 的 API key 页面查看或重新生成自己的 key。
Web 页面不要求用户输入或复制 API key。浏览器请求通过 `hwlab_session` 恢复 `AuthPrincipal`;需要 CLI 时,用户在 Web 的 API key 页面生成或重新生成自己的 key。
Session 规则:
- 每次 OIDC 登录或 callback 成功都发行新的 session token,数据库只保存 token hash。
- 单个 session token 最长 24 小时;过期、用户 disabled 或 logout 后必须返回 `401 auth_session_invalid`
- `/auth/logout` 撤销 HWLAB session,并可选择跳转 Keycloak logout;即使 Keycloak 全局会话仍有效,HWLAB 已撤销的 session 也不能继续使用。
- 第一版不实现长期 refresh token 存储;用户过期后重新走 Keycloak 登录。
- 第一版不实现长期 refresh token 存储,也不把 Keycloak access token 或 refresh token 写入 HWLAB 数据库;用户过期后重新走 Keycloak 登录。
- `returnTo` 只允许同站相对路径,外站 URL、scheme-relative URL、反斜杠或控制字符必须回退到 `/workbench`
## CLI API key 流程
@@ -186,7 +190,7 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami
- CLI 不跳转浏览器,不依赖 Web cookie,不要求 username/password 交互。
- `hwlab-cli` 默认从 `HWLAB_API_KEY` 读取 key,并发送 `Authorization: Bearer <key>`
- AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;可以使用该用户默认 key,也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key,但绝不能使用跨用户共享的 device-pod 系统 key。
- AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;可以使用该用户默认 key,也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key,但绝不能使用跨用户共享的 device-pod 系统 key 或 Keycloak token
- `client auth status` 必须显示 endpoint、是否检测到 `HWLAB_API_KEY`、key prefix 和用户摘要;不得输出完整 API key。
- `client auth whoami``client request GET /v1/users/me` 必须能用 API key 返回与 Web 同一用户的 `AuthPrincipal` 摘要。
- `client auth login --username ...` 和本地 cookie profile 属于 legacy 兼容入口;目标体验不再把它作为一等 CLI 登录。
@@ -212,7 +216,7 @@ CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id, status, create
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
```
短期小范围测试允许 `display_secret` 保存可重复展示的 API key 明文,满足“用户不必只在生成时保存一次”的体验;但完整 key 不得写入日志、issue、ConfigMap、Kubernetes Secret、trace 或 CLI 默认输出。生产化时应删除 `display_secret`,改成只保存 `key_hash`,并只在生成时显示一次。
目标状态只长期保存 `key_hash``key_prefix`,完整 API key 只在创建或 regenerate 响应中显示一次;用户丢失 key 时通过 regenerate 获取新 key。`display_secret` 只允许作为迁移期开发字段存在,不能作为生产化长期口径;完整 key 不得写入日志、issue、ConfigMap、Kubernetes Secret、trace 或 CLI 默认输出。
API key 行为:
@@ -227,17 +231,17 @@ API key 行为:
| --- | --- |
| `GET /auth/oidc/login?returnTo=...` | 发起 Keycloak OIDC 登录,保存 state/nonce 并 302 到 Keycloak。 |
| `GET /auth/oidc/callback` | 校验 code、issuer、nonce 和 state,映射用户,发行 24 小时 Web session。 |
| `GET /auth/session` | 从 Web cookie 或兼容 session 恢复 actorAPI key 请求可返回 `authMethod='api-key'` 摘要。 |
| `GET /auth/session` | 从 Web cookie 恢复 actorAPI key 用户摘要使用 `/v1/users/me`。 |
| `POST /auth/logout` | revoke 当前 Web session 并清 cookie。 |
| `POST /auth/login` | 本地账号密码 legacy/bootstrap fallback;目标 Web/CLI 登录不依赖该入口。 |
| `GET /v1/users/me` | 返回当前 `AuthPrincipal` 的脱敏摘要。 |
| `GET /v1/api-keys` | 列出当前用户 API key metadata短期测试可返回默认 key 的 displayable 状态。 |
| `GET /v1/api-keys/default` | 返回或创建默认 API key短期测试允许返回完整 key 明文给当前用户本人。 |
| `GET /v1/api-keys` | 列出当前用户 API key metadata目标状态不返回完整 key。 |
| `GET /v1/api-keys/default` | 返回或创建默认 API key目标状态只在新建时返回一次完整 key。 |
| `POST /v1/api-keys` | 创建一个用户 API key。 |
| `POST /v1/api-keys/{id}/regenerate` | 重新生成指定 API key,旧 secret 立即失效。 |
| `DELETE /v1/api-keys/{id}` | revoke 指定 API key。 |
所有 API key 管理接口都要求当前 `AuthPrincipal` 是同一用户本人或 `admin`;普通用户不能查看或生成其他人的 key。响应默认只返回 `key_prefix``id``name``status``last_used_at` 和短期测试允许的本人完整 key
所有 API key 管理接口都要求当前 `AuthPrincipal` 是同一用户本人或 `admin`;普通用户不能查看或生成其他人的 key。常规列表响应只返回 `key_prefix``id``name``status``last_used_at`,完整 key 只在创建或 regenerate 的一次性响应中出现
## 与资源授权的关系
@@ -256,11 +260,11 @@ API key 行为:
## T2
阅读 docs/reference/spec-v02-auth.md,然后从浏览器访问 `http://74.48.78.17:19666/`。未登录时必须进入 Keycloak 登录/注册;注册新用户不要求邮箱或手机验证,callback 后回到 Workbench`GET /auth/session` 返回 active `user` actor 和 24 小时内过期的 session,不返回 session token 原文。
阅读 docs/reference/spec-v02-auth.md,然后从浏览器访问 `https://hwlab.74-48-78-17.nip.io/`。未登录时必须进入 Keycloak 登录/注册;注册新用户不要求邮箱或手机验证,callback 后回到 Workbench`GET /auth/session` 返回 active `user` actor 和 24 小时内过期的 session,不返回 session token 原文。
## T3
阅读 docs/reference/spec-v02-auth.md,然后登录 Web 后打开 API key 管理入口,确认默认 API key 已自动存在;短期测试中可重复查看完整 key。用 `HWLAB_API_KEY=<key> bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami` 验证 CLI 无浏览器跳转即可恢复同一用户。
阅读 docs/reference/spec-v02-auth.md,然后登录 Web 后打开 API key 管理入口,确认可以创建或 regenerate 用户 API key;完整 key 只在创建或 regenerate 响应中显示一次。用 `HWLAB_API_KEY=<key> bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami` 验证 CLI 无浏览器跳转即可恢复同一用户。
## T4
@@ -280,11 +284,11 @@ API key 行为:
| --- | --- | --- |
| Keycloak 独立 namespace 与公网 HTTPS issuer | 部署已完成 | `keycloak` namespace、Caddy/FRP HTTPS、`hwlab` issuer、admin console 和 bootstrap Job 已形成部署基线;后续只按本文件继续硬化。 |
| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试;HWLAB 应用层仍必须默认 `user`、无 device pod grant。 |
| Web OIDC login/callback | 待 HWLAB 接入收口 | Keycloak 侧 redirect URI 已配置,Cloud API/Web rollout 和浏览器 callback 验收见 #814。 |
| Web OIDC login/callback | 待 HWLAB 接入收口 | Keycloak client、Cloud API/Web rollout 和浏览器 callback 验收见 #814redirect URI 必须使用 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`。 |
| Web session 24 小时轮换 | 待 HWLAB 接入收口 | 当前目标是 callback 成功后由 `hwlab-cloud-api` 发行 24 小时 `hwlab_session`;验收见 #814。 |
| CLI/AgentRun `HWLAB_API_KEY` 一等登录 | 待 HWLAB 接入收口 | 目标是统一 env API key 映射到用户,无浏览器跳转,无跨用户 device-pod key;验收见 #814。 |
| 默认 API key 自动生成和 Web 可查看 | 待 HWLAB 接入收口 | 小范围测试允许本人重复查看明文;生产化再改为 hash-only。 |
| `AuthPrincipal` 归一 | 待 HWLAB 接入收口 | 后续实现必须把 Web session、CLI API key 和 AgentRun `hwpod` API key 都归一成同一用户 actor。 |
| CLI/AgentRun `HWLAB_API_KEY` 一等登录 | 待 HWLAB 接入收口 | 目标是统一 env API key 映射到用户,无浏览器跳转,无 Keycloak token,无跨用户 device-pod key;验收见 #814。 |
| API key 一次性显示和 revoke/regenerate | 待 HWLAB 接入收口 | 目标状态只在创建或 regenerate 时显示完整 key;列表和已存在默认 key 只返回 metadata。 |
| `AuthPrincipal` 归一 | 待 HWLAB 接入收口 | 后续实现必须把 Web session、CLI API key 和 AgentRun `hwpod` API key 都归一成同一用户 actorlegacy/internal key 不作为正式用户方法。 |
| `admin/user` 与 device pod grant 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、grant 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 |
## Keycloak 部署纪律
+95 -22
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto";
import { mkdtemp } from "node:fs/promises";
import { createServer } from "node:http";
import { test } from "bun:test";
@@ -8,6 +9,17 @@ import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { createCloudApiServer } from "./server.ts";
const INTERNAL_TOKEN = "test-internal-token";
const OIDC_ISSUER = "https://auth.74-48-78-17.nip.io/realms/hwlab";
const OIDC_CLIENT_ID = "hwlab-cloud-web";
const HWLAB_PUBLIC_ENDPOINT = "https://hwlab.74-48-78-17.nip.io";
const OIDC_TEST_KEY_ID = "hwlab-test-key";
const OIDC_TEST_KEYS = generateKeyPairSync("rsa", { modulusLength: 2048 });
const OIDC_TEST_JWK = Object.freeze({
...OIDC_TEST_KEYS.publicKey.export({ format: "jwk" }),
kid: OIDC_TEST_KEY_ID,
alg: "RS256",
use: "sig"
});
test("Postgres workspace update query preserves parameter numbering", async () => {
const calls = [];
@@ -186,7 +198,7 @@ test("workbench workspace is account-scoped, persistent, and permits independent
test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => {
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" },
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_PUBLIC_ENDPOINT },
now: () => "2026-06-03T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -197,6 +209,7 @@ test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client
const location = response.headers.get("location");
assert.ok(location?.startsWith("https://auth.74-48-78-17.nip.io/realms/hwlab/protocol/openid-connect/auth"), `unexpected location: ${location}`);
assert.ok(location?.includes("client_id=hwlab-cloud-web"));
assert.ok(location?.includes("redirect_uri=https%3A%2F%2Fhwlab.74-48-78-17.nip.io%2Fauth%2Foidc%2Fcallback"));
assert.ok(location?.includes("response_type=code"));
assert.ok(location?.includes("state="));
assert.ok(location?.includes("nonce="));
@@ -205,9 +218,33 @@ test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client
}
});
test("cloud api /auth/oidc/login stores only same-origin relative returnTo", async () => {
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_PUBLIC_ENDPOINT },
now: () => "2026-06-03T00:00:00.000Z"
});
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
accessController,
now: () => "2026-06-03T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example%2F`, { redirect: "manual" });
assert.equal(response.status, 302);
const state = new URL(response.headers.get("location") ?? "http://127.0.0.1").searchParams.get("state");
assert.ok(state);
const stored = await accessController.store.consumeOidcState(state);
assert.equal(stored.returnTo, "/workbench");
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api /auth/oidc/callback upserts user by issuer+sub and issues 24h web session", async () => {
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" },
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT },
fetchImpl: makeOidcFetchMock(),
now: () => "2026-06-03T00:00:00.000Z"
});
@@ -240,21 +277,24 @@ test("cloud api /auth/oidc/callback upserts user by issuer+sub and issues 24h we
}
});
function makeOidcFetchMock() {
function makeOidcFetchMock({ nonce = "test-nonce", subject = "keycloak-sub-1234", issuer = OIDC_ISSUER, clientId = OIDC_CLIENT_ID } = {}) {
return async (url) => {
const u = new URL(String(url));
if (u.pathname.endsWith("/protocol/openid-connect/token")) {
return new Response(JSON.stringify({
access_token: "oidc-access-token",
refresh_token: "oidc-refresh-token",
id_token: "oidc-id-token",
id_token: signedOidcIdToken({ issuer, clientId, nonce, subject }),
token_type: "Bearer",
expires_in: 300
}), { status: 200, headers: { "content-type": "application/json" } });
}
if (u.pathname.endsWith("/protocol/openid-connect/certs")) {
return new Response(JSON.stringify({ keys: [OIDC_TEST_JWK] }), { status: 200, headers: { "content-type": "application/json" } });
}
if (u.pathname.endsWith("/protocol/openid-connect/userinfo")) {
return new Response(JSON.stringify({
sub: "keycloak-sub-1234",
sub: subject,
preferred_username: "oidc-user",
email: "oidc-user@hwlab.local",
name: "OIDC User",
@@ -265,6 +305,28 @@ function makeOidcFetchMock() {
};
}
function signedOidcIdToken({ issuer = OIDC_ISSUER, clientId = OIDC_CLIENT_ID, nonce = "test-nonce", subject = "keycloak-sub-1234", nowSeconds = 1_780_444_800 } = {}) {
const header = base64UrlEncodeJson({ alg: "RS256", typ: "JWT", kid: OIDC_TEST_KEY_ID });
const payload = base64UrlEncodeJson({
iss: issuer,
aud: clientId,
sub: subject,
nonce,
exp: nowSeconds + 600,
iat: nowSeconds,
preferred_username: "oidc-user",
email: "oidc-user@hwlab.local",
name: "OIDC User"
});
const body = `${header}.${payload}`;
const signature = sign("RSA-SHA256", Buffer.from(body), OIDC_TEST_KEYS.privateKey).toString("base64url");
return `${body}.${signature}`;
}
function base64UrlEncodeJson(value) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => {
const agentRunCalls = [];
const agentSessions = new Map();
@@ -2590,22 +2652,29 @@ test("cloud api issues a default user API key and authenticates Bearer hwl_live_
assert.equal(firstAdmin.status, 201);
assert.equal(firstAdmin.body.actor.role, "admin");
const cookie = firstAdmin.cookie;
const defaultKey = await getJson(port, "/v1/api-keys/default", cookie);
assert.equal(defaultKey.status, 200);
assert.equal(defaultKey.body.created, false);
assert.equal(defaultKey.body.key.displaySecret, undefined);
const createdKey = await postJson(port, "/v1/api-keys", { name: "CLI test key" }, cookie);
assert.equal(createdKey.status, 201);
const apiKey = createdKey.body.key;
assert.equal(apiKey.name, "CLI test key");
assert.match(apiKey.keyPrefix, /^hwl_live_/u);
assert.ok(apiKey.displaySecret, "created key should expose display secret once");
assert.equal(apiKey.status, "active");
const listed = await getJson(port, "/v1/api-keys", cookie);
assert.equal(listed.status, 200);
assert.equal(listed.body.contractVersion, "user-api-key-v1");
assert.equal(listed.body.keys.length, 1);
const apiKey = listed.body.keys[0];
assert.equal(apiKey.name, "Default API key");
assert.match(apiKey.keyPrefix, /^hwl_live_/u);
assert.ok(apiKey.displaySecret, "default key should expose display secret for short test window");
assert.equal(apiKey.status, "active");
assert.equal(listed.body.keys.length, 2);
assert.equal(listed.body.keys.find((key) => key.id === apiKey.id)?.displaySecret, undefined);
const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey.displaySecret}` });
assert.equal(me.status, 200);
assert.equal(me.body.actor.id, firstAdmin.body.actor.id);
assert.equal(me.body.actor.username, "apikey-admin");
assert.equal(me.body.actor.role, "admin");
assert.equal(me.body.authMethod, "api-key");
assert.match(JSON.stringify(me.body), /hwl_live_[A-Za-z0-9_-]+/u);
assert.equal(JSON.stringify(me.body).includes(apiKey.displaySecret), false);
const bad = await getJson(port, "/v1/users/me", null, { authorization: "Bearer hwl_live_not-a-real-key" });
assert.equal(bad.status, 401);
assert.equal(bad.body.error.code, "api_key_invalid");
@@ -2627,14 +2696,16 @@ test("cloud api can list, create, regenerate, and revoke a user API key", async
const setup = await postJson(port, "/v1/setup/first-admin", { username: "rotate-admin", password: "rotate-pass" });
assert.equal(setup.status, 201);
const cookie = setup.cookie;
const initial = await getJson(port, "/v1/api-keys/default", cookie);
const original = initial.body.key.displaySecret;
const originalKey = await postJson(port, "/v1/api-keys", { name: "Original CLI key" }, cookie);
assert.equal(originalKey.status, 201);
const original = originalKey.body.key.displaySecret;
const created = await postJson(port, "/v1/api-keys", { name: "AgentRun runner" }, cookie);
assert.equal(created.status, 201);
assert.equal(created.body.key.name, "AgentRun runner");
const createdSecret = created.body.key.displaySecret;
const listAfterCreate = await getJson(port, "/v1/api-keys", cookie);
assert.equal(listAfterCreate.body.keys.length, 2);
assert.equal(listAfterCreate.body.keys.length, 3);
assert.equal(listAfterCreate.body.keys.some((key) => key.displaySecret), false);
const regenerate = await postJson(port, `/v1/api-keys/${created.body.key.id}/regenerate`, {}, cookie);
assert.equal(regenerate.status, 200);
assert.notEqual(regenerate.body.key.displaySecret, createdSecret);
@@ -2668,7 +2739,7 @@ test("cloud api rejects API key auth for a disabled user", async () => {
const userLogin = await postJson(port, "/auth/login", { username: "soon-disabled", password: "pw" });
assert.equal(userLogin.status, 200);
const userCookie = userLogin.cookie;
const issued = await getJson(port, "/v1/api-keys/default", userCookie);
const issued = await postJson(port, "/v1/api-keys", { name: "Disabled user CLI key" }, userCookie);
const apiKey = issued.body.key.displaySecret;
const ok = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` });
assert.equal(ok.status, 200);
@@ -2732,7 +2803,7 @@ test("cloud api /v1/users/me returns actor with authMethod=api-key for HWLAB_API
const { port } = server.address();
const setup = await postJson(port, "/v1/setup/first-admin", { username: "actor-admin", password: "actor-pass" });
const cookie = setup.cookie;
const apiKey = (await getJson(port, "/v1/api-keys/default", cookie)).body.key.displaySecret;
const apiKey = (await postJson(port, "/v1/api-keys", { name: "Actor CLI key" }, cookie)).body.key.displaySecret;
const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` });
assert.equal(me.status, 200);
assert.equal(me.body.authMethod, "api-key");
@@ -2745,8 +2816,8 @@ test("cloud api /v1/users/me returns actor with authMethod=api-key for HWLAB_API
test("cloud api AgentRun trace records real actor.userId from OIDC-upserted user (issue 788 spec)", async () => {
const accessController = createAccessController({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_KEYCLOAK_CLIENT_SECRET: "secret" },
fetchImpl: makeOidcFetchMock(),
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT },
fetchImpl: makeOidcFetchMock({ nonce: "actor-nonce" }),
now: () => "2026-06-03T00:00:00.000Z"
});
const state = "actor-state";
@@ -2765,9 +2836,11 @@ test("cloud api AgentRun trace records real actor.userId from OIDC-upserted user
const actor = session.body.actor;
assert.equal(actor.username, "oidc-user");
assert.equal(actor.role, "user");
const apiKeys = await getJson(port, "/v1/api-keys", cookie);
assert.equal(apiKeys.body.keys.length, 1);
const apiKey = apiKeys.body.keys[0].displaySecret;
const defaultApiKey = await getJson(port, "/v1/api-keys/default", cookie);
assert.equal(defaultApiKey.body.created, false);
assert.equal(defaultApiKey.body.key.displaySecret, undefined);
const runnerKey = await postJson(port, "/v1/api-keys", { name: "OIDC runner key" }, cookie);
const apiKey = runnerKey.body.key.displaySecret;
const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` });
assert.equal(me.body.actor.id, actor.id);
assert.equal(me.body.authMethod, "api-key");
+85 -17
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createHash, createPublicKey, randomBytes, randomUUID, verify as verifySignature } from "node:crypto";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import {
@@ -317,7 +317,7 @@ class AccessController {
if (existing) return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(existing), created: false });
const issued = await this.store.ensureDefaultApiKeyForUser?.({ userId: auth.actor.id, now: this.now() }) ?? null;
if (!issued?.key) return sendJson(response, 500, errorPayload("api_key_issue_failed", "Failed to issue default API key", 500));
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(issued.key), created: true });
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(issued.key, { includeSecret: true }), created: true });
}
if (request.method === "POST" && path === "/v1/api-keys") {
const body = await jsonBody(request);
@@ -327,7 +327,7 @@ class AccessController {
const hash = hashApiKey(secret);
const created = await this.store.createApiKey?.({ userId: auth.actor.id, name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: this.now() });
if (!created) return sendJson(response, 500, errorPayload("api_key_create_failed", "Failed to create API key", 500));
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(created), created: true });
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(created, { includeSecret: true }), created: true });
}
const regenerateMatch = path.match(/^\/v1\/api-keys\/([^/]+)\/regenerate$/u);
if (request.method === "POST" && regenerateMatch) {
@@ -339,7 +339,7 @@ class AccessController {
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const fresh = await this.store.createApiKey?.({ userId: auth.actor.id, name: existing.name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: existing.scopes, now: this.now() });
return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(fresh), regenerated: true, oldKeyId: keyId });
return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(fresh, { includeSecret: true }), regenerated: true, oldKeyId: keyId });
}
const deleteMatch = path.match(/^\/v1\/api-keys\/([^/]+)$/u);
if (request.method === "DELETE" && deleteMatch) {
@@ -595,7 +595,7 @@ class AccessController {
if (devicePodApiKey) return this.authenticateDevicePodApiKey(devicePodApiKey);
const apiKeySecret = apiKeyFromRequest(request);
if (apiKeySecret) return this.authenticateApiKey(apiKeySecret);
const token = sessionTokenFromRequest(request);
const token = sessionCookieFromRequest(request);
if (!token) {
return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null };
}
@@ -645,7 +645,7 @@ class AccessController {
if (!issuer || !clientId) {
return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503));
}
const returnTo = textOr(url.searchParams.get("returnTo"), "/");
const returnTo = safeReturnTo(url.searchParams.get("returnTo"));
const state = randomBytes(24).toString("base64url");
const nonce = randomBytes(24).toString("base64url");
const authorizeUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/auth`);
@@ -692,14 +692,18 @@ class AccessController {
if (!tokenResponse.ok) {
return sendJson(response, tokenResponse.status, errorPayload(tokenResponse.code, tokenResponse.message, tokenResponse.status));
}
const idToken = await verifyOidcIdToken(tokenResponse.idToken, { issuer, clientId, nonce: stored.nonce, now: this.now(), fetchImpl: this.fetchImpl });
if (!idToken.ok) {
return sendJson(response, 400, errorPayload(idToken.code, idToken.message, 400));
}
const userinfo = await this.fetchOidcUserinfo({ issuer, accessToken: tokenResponse.accessToken });
if (!userinfo.ok) {
return sendJson(response, userinfo.status, errorPayload(userinfo.code, userinfo.message, userinfo.status));
}
if (textOr(userinfo.claims.nonce, "") && textOr(userinfo.claims.nonce, "") !== textOr(stored.nonce, "")) {
return sendJson(response, 400, errorPayload("oidc_nonce_mismatch", "OIDC nonce mismatch", 400));
if (textOr(userinfo.claims.sub, "") !== textOr(idToken.claims.sub, "")) {
return sendJson(response, 400, errorPayload("oidc_subject_mismatch", "OIDC userinfo subject does not match ID token", 400));
}
const actor = await this.upsertOidcUser({ issuer, claims: userinfo.claims, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken });
const actor = await this.upsertOidcUser({ issuer, claims: { ...idToken.claims, ...userinfo.claims, sub: idToken.claims.sub }, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken });
if (!actor) {
return sendJson(response, 500, errorPayload("oidc_user_upsert_failed", "Failed to upsert OIDC user", 500));
}
@@ -731,10 +735,10 @@ class AccessController {
});
const text = await response.text();
const json = parseJson(text, {});
if (response.status >= 400 || !json.access_token) {
if (response.status >= 400 || !json.access_token || !json.id_token) {
return { ok: false, status: response.status, code: "oidc_token_exchange_failed", message: textOr(json.error_description, text) };
}
return { ok: true, accessToken: json.access_token, refreshToken: json.refresh_token ?? null, idTokenClaims: json };
return { ok: true, accessToken: json.access_token, refreshToken: json.refresh_token ?? null, idToken: json.id_token };
} catch (error) {
return { ok: false, status: 500, code: "oidc_token_exchange_error", message: error?.message ?? String(error) };
}
@@ -815,7 +819,7 @@ class AccessController {
}
async handleLogout(request, response) {
const token = sessionTokenFromRequest(request);
const token = sessionCookieFromRequest(request);
if (token) await this.store.revokeSessionByTokenHash(sha256(token), this.now());
clearSessionCookie(response);
return sendJson(response, 200, { authenticated: false, loggedOut: true });
@@ -2215,7 +2219,7 @@ function publicActor(user) { return user ? { id: user.id, username: user.usernam
function publicAuthMethod(method) {
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_DEVICE_POD_API_KEY].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
}
function publicApiKey(key) {
function publicApiKey(key, { includeSecret = false } = {}) {
if (!key) return null;
return {
id: key.id,
@@ -2227,7 +2231,7 @@ function publicApiKey(key) {
createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt ?? null,
revokedAt: key.revokedAt ?? null,
...(key.displaySecret ? { displaySecret: key.displaySecret } : {})
...(includeSecret && key.displaySecret ? { displaySecret: key.displaySecret } : {})
};
}
function redactedUser(user) { return publicActor(user); }
@@ -2565,10 +2569,74 @@ function boundedText(value, maxBytes) {
return buffer.length > maxBytes ? buffer.subarray(0, maxBytes).toString("utf8") : text;
}
function pruneEmpty(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); }
function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function safeReturnTo(value) {
const text = textOr(value, "/workbench").trim() || "/workbench";
if (!text.startsWith("/") || text.startsWith("//") || text.includes("\\") || /[\u0000-\u001f\u007f]/u.test(text)) return "/workbench";
try {
const url = new URL(text, "https://hwlab.local");
if (url.origin !== "https://hwlab.local") return "/workbench";
return `${url.pathname}${url.search}${url.hash}` || "/workbench";
} catch {
return "/workbench";
}
}
async function verifyOidcIdToken(idToken, { issuer, clientId, nonce, now, fetchImpl }) {
const parts = String(idToken ?? "").split(".");
if (parts.length !== 3) return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token is missing or malformed" };
const header = parseJson(Buffer.from(base64UrlToBase64(parts[0]), "base64").toString("utf8"), null);
const claims = parseJson(Buffer.from(base64UrlToBase64(parts[1]), "base64").toString("utf8"), null);
if (!header || typeof header !== "object") return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token header is invalid" };
if (!claims || typeof claims !== "object") return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token payload is invalid" };
if (textOr(header.alg, "") !== "RS256") return { ok: false, code: "oidc_id_token_alg_invalid", message: "OIDC ID token must be signed with RS256" };
const kid = textOr(header.kid, "");
if (!kid) return { ok: false, code: "oidc_id_token_kid_missing", message: "OIDC ID token key id is missing" };
const issuerBase = issuer.replace(/\/$/, "");
const keyResult = await fetchOidcJwksKey({ issuer: issuerBase, kid, fetchImpl });
if (!keyResult.ok) return keyResult;
let signatureOk = false;
try {
const key = createPublicKey({ key: keyResult.jwk, format: "jwk" });
signatureOk = verifySignature("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(base64UrlToBase64(parts[2]), "base64"));
} catch (error) {
return { ok: false, code: "oidc_id_token_signature_error", message: error?.message ?? "OIDC ID token signature verification failed" };
}
if (!signatureOk) return { ok: false, code: "oidc_id_token_signature_invalid", message: "OIDC ID token signature is invalid" };
if (textOr(claims.iss, "") !== issuerBase) return { ok: false, code: "oidc_issuer_mismatch", message: "OIDC ID token issuer mismatch" };
const aud = Array.isArray(claims.aud) ? claims.aud.map(String) : [textOr(claims.aud, "")];
if (!aud.includes(clientId)) return { ok: false, code: "oidc_audience_mismatch", message: "OIDC ID token audience mismatch" };
if (aud.length > 1 && textOr(claims.azp, "") !== clientId) return { ok: false, code: "oidc_authorized_party_mismatch", message: "OIDC ID token authorized party mismatch" };
if (!textOr(claims.sub, "")) return { ok: false, code: "oidc_subject_missing", message: "OIDC ID token subject is missing" };
if (textOr(claims.nonce, "") !== textOr(nonce, "")) return { ok: false, code: "oidc_nonce_mismatch", message: "OIDC ID token nonce mismatch" };
const nowSeconds = Math.floor(Date.parse(now) / 1000);
const exp = Number(claims.exp);
const iat = Number(claims.iat);
const nbf = Number(claims.nbf);
if (!Number.isFinite(exp)) return { ok: false, code: "oidc_id_token_exp_missing", message: "OIDC ID token expiration is missing" };
if (exp <= nowSeconds) return { ok: false, code: "oidc_id_token_expired", message: "OIDC ID token is expired" };
if (!Number.isFinite(iat)) return { ok: false, code: "oidc_id_token_iat_missing", message: "OIDC ID token issued-at is missing" };
if (iat > nowSeconds + 300) return { ok: false, code: "oidc_id_token_iat_invalid", message: "OIDC ID token issued-at is in the future" };
if (Number.isFinite(nbf) && nbf > nowSeconds + 300) return { ok: false, code: "oidc_id_token_nbf_invalid", message: "OIDC ID token not-before is in the future" };
return { ok: true, claims };
}
async function fetchOidcJwksKey({ issuer, kid, fetchImpl }) {
try {
const response = await (fetchImpl ?? fetch)(`${issuer}/protocol/openid-connect/certs`, { headers: { accept: "application/json" } });
const text = await response.text();
const json = parseJson(text, {});
if (response.status >= 400) return { ok: false, code: "oidc_jwks_fetch_failed", message: textOr(json.error_description, text) };
const key = (Array.isArray(json.keys) ? json.keys : []).find((item) => textOr(item?.kid, "") === kid && textOr(item?.kty, "") === "RSA");
if (!key) return { ok: false, code: "oidc_jwks_key_not_found", message: "OIDC signing key was not found in JWKS" };
return { ok: true, jwk: key };
} catch (error) {
return { ok: false, code: "oidc_jwks_fetch_error", message: error?.message ?? String(error) };
}
}
function base64UrlToBase64(value) {
const text = String(value ?? "").replace(/-/gu, "+").replace(/_/gu, "/");
return text.padEnd(Math.ceil(text.length / 4) * 4, "=");
}
function sessionCookieFromRequest(request) { const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); }
function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); }
function clearSessionCookie(response) { response.setHeader("set-cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); }
function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", buildSessionCookie(token, maxAge)); }
function clearSessionCookie(response) { response.setHeader("set-cookie", buildClearSessionCookie()); }
function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; }
+5 -5
View File
@@ -22,8 +22,8 @@ const defaultRuntimeEndpoint = process.env.HWLAB_G14_RUNTIME_ENDPOINT || "http:/
const defaultWebEndpoint = process.env.HWLAB_G14_WEB_ENDPOINT || "http://74.48.78.17:17666";
const defaultProdRuntimeEndpoint = process.env.HWLAB_G14_PROD_RUNTIME_ENDPOINT || "http://74.48.78.17:18667";
const defaultProdWebEndpoint = process.env.HWLAB_G14_PROD_WEB_ENDPOINT || "http://74.48.78.17:18666";
const defaultV02RuntimeEndpoint = process.env.HWLAB_V02_RUNTIME_ENDPOINT || "http://74.48.78.17:19667";
const defaultV02WebEndpoint = process.env.HWLAB_V02_WEB_ENDPOINT || "http://74.48.78.17:19666";
const defaultV02RuntimeEndpoint = process.env.HWLAB_V02_RUNTIME_ENDPOINT || "https://hwlab.74-48-78-17.nip.io";
const defaultV02WebEndpoint = process.env.HWLAB_V02_WEB_ENDPOINT || "https://hwlab.74-48-78-17.nip.io";
const defaultProxyUrl = process.env.HWLAB_G14_PROXY_URL || "http://127.0.0.1:10808";
const defaultAllProxyUrl = process.env.HWLAB_G14_ALL_PROXY_URL || "socks5h://127.0.0.1:10808";
const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc,git-mirror-http,git-mirror-http.devops-infra,git-mirror-http.devops-infra.svc,git-mirror-http.devops-infra.svc.cluster.local,git-mirror-write,git-mirror-write.devops-infra,git-mirror-write.devops-infra.svc,git-mirror-write.devops-infra.svc.cluster.local";
@@ -271,8 +271,8 @@ function usage() {
` --gitops-branch BRANCH default: ${defaultGitopsBranch}`,
" --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD",
` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`,
` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}`,
` --web-endpoint URL default: ${defaultWebEndpoint}`,
` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}; v02 default: ${defaultV02RuntimeEndpoint}`,
` --web-endpoint URL default: ${defaultWebEndpoint}; v02 default: ${defaultV02WebEndpoint}`,
` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`,
` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`,
" --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service",
@@ -1269,7 +1269,7 @@ function prepareSourceScript() {
" return service;",
"});",
"fs.mkdirSync(path.dirname(catalogPath), { recursive: true });",
"fs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: 'http://74.48.78.17:19667', commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\\n');",
`fs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: ${JSON.stringify(defaultV02RuntimeEndpoint)}, commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\\n');`,
"NODE",
" echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seeded-v02-skeleton\"}'",
"fi",