fix(v0.2): align Keycloak auth to HTTPS session API key
This commit is contained in:
+5
-3
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
本规格与 [spec-device-pod.md](spec-device-pod.md) 配套:用户和权限规格定义谁可以看见、创建和使用 device pod;device-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 接口说明
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# v0.2 登录与鉴权规格
|
||||
|
||||
本文是 HWLAB `v0.2` 登录、认证和应用层鉴权入口的长期规格。它把 Keycloak OIDC、浏览器 Web session、CLI/API/AgentRun 共用的用户 API key 和 HWLAB 内部 `AuthPrincipal` 归一到同一套口径,避免把本地账号密码、浏览器 cookie 和设备专用系统 key 混用。
|
||||
本文是 HWLAB `v0.2` 登录、认证和应用层鉴权入口的长期规格。正式用户鉴权只保留两类凭据:浏览器使用 `hwlab_session` Web session,CLI/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 issuer;OIDC redirect URI、cookie secure policy 和浏览器登录都依赖这一点。短期全自动方案使用主 server Caddy + Let's Encrypt + nip.io 域名,并通过 FRP 把 G14 `keycloak` namespace 的 Keycloak Service 暴露到主 server 本地端口。
|
||||
Keycloak 和 HWLAB 浏览器入口都必须使用公网 HTTPS;OIDC 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 API;Cloud Web 不读取 client secret,token 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 恢复 actor;API key 请求可返回 `authMethod='api-key'` 摘要。 |
|
||||
| `GET /auth/session` | 从 Web cookie 恢复 actor;API 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 验收见 #814;redirect 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 都归一成同一用户 actor;legacy/internal key 不作为正式用户方法。 |
|
||||
| `admin/user` 与 device pod grant 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、grant 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 |
|
||||
|
||||
## Keycloak 部署纪律
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } }; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user