Skip to content
Clone

refactor: remove OAuth-related modules and update references for identity service integration

ZhenYi committed
parent b6f7f73 commit 9362a10 Browse files

112 files changed

+653 −8067

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 112
Modified app/src/main.rs View app/src/main.rs
@@ -39,6 +39,10 @@
3939
4040 let cancel = CancellationToken::new();
4141 let app_config = cfg.clone().into_app_config();
42 let session_config = app_config.session.clone();
43 let session_store = auth::session::RedisStore::connect(&app_config.redis)
44 .await
45 .context("connecting the session store")?;
4246 let database = std::sync::Arc::new(
4347 export::AppDatabase::connect(app_config.database.clone())
4448 .await
@@ -49,7 +53,7 @@
4953 let db =
5054 auth::db::DbConn::new(database.primary().clone(), database.replicas().to_vec());
5155 Some(
52 auth::oauth::start(&auth_config, db)
56 auth::runtime::start(&auth_config, db)
5357 .await
5458 .context("starting auth service")?,
5559 )
@@ -59,16 +63,9 @@
5963 let client = auth_runtime.as_ref().map(|runtime| {
6064 std::sync::Arc::new(auth::client::KenAuthClient::local(
6165 runtime.service().clone(),
62 cfg.oauth.http_timeout_ms,
66 2_000,
6367 ))
6468 });
65 if let Some(runtime) = &auth_runtime
66 && cfg.oauth.enabled
67 && runtime.service().signing().issuer().trim_end_matches('/')
68 != cfg.oauth.issuer.trim_end_matches('/')
69 {
70 anyhow::bail!("oauth.issuer must match jwt.issuer");
71 }
7269 let state = export::AppState::with_identity(app_config, database, client)
7370 .await
7471 .context("building AppState")?;
@@ -115,10 +112,6 @@
115112 }
116113
117114 let verifier = webapi::verifier(&state);
118 if let Some(verifier) = &verifier {
119 let warm = verifier.clone();
120 tokio::spawn(async move { warm.warm_up().await });
121 }
122115 let doc = api_doc();
123116 let app_state = state.clone();
124117
@@ -141,10 +134,14 @@
141134 if let Some(verifier) = verifier.clone() {
142135 app = app.app_data(web::Data::new(verifier));
143136 }
144 app.wrap(webapi::build_cors(&state.config.web))
145 .wrap(from_fn(export::middleware::trace_request))
137 app.wrap(auth::session::SessionMiddleware::new(
138 session_config.clone(),
139 session_store.clone(),
140 ))
141 .wrap(webapi::build_cors(&state.config.web))
142 .wrap(from_fn(export::middleware::trace_request))
146143 .wrap(from_fn(export::telemetry::http_telemetry))
147 .wrap(from_fn(webapi::auth::oauth::revalidate::revalidate))
144 .wrap(from_fn(webapi::auth::session::revalidate::revalidate))
148145 .service(web::resource("/api/docs").route(web::get().to(|| async {
149146 actix_web::HttpResponse::Found()
150147 .insert_header((actix_web::http::header::LOCATION, "/api/docs/"))
Modified charts/core/templates/NOTES.txt View charts/core/templates/NOTES.txt
@@ -13,7 +13,7 @@
1313 kubectl -n {{ .Release.Namespace }} logs job/{{ include "gitdata.fullname" . }}-migrate
1414
1515Reminders:
16 - secret.env 中 APP_OAUTH__RPC_KEY / APP_INTERNAL__SERVICE_KEY / APP_POW__MAC_KEY / APP_AUTH__TOTP_ENCRYPTION_KEY 必填;
16 - secret.env 中 APP_AUTH__RPC_KEY / APP_INTERNAL__SERVICE_KEY / APP_POW__MAC_KEY / APP_AUTH__TOTP_ENCRYPTION_KEY 必填;
1717 生产环境建议 --set secret.existingSecret=<name> 并用外部 Secret 管理。
1818 - KenApi 不再持有 bare 仓库;clone/push 由 KenShell 提供,需配置 kenshell.endpoint。
1919 - 磁盘上的 config/local/ 含真实密钥,部署前请轮换。
Deleted charts/core/templates/oauth.yaml
@@ -1,75 +0,0 @@
1{{- if .Values.oauth.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-oauth" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: oauth
9spec:
10 replicas: {{ .Values.oauth.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: oauth
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: oauth
21 {{- with .Values.podAnnotations }}
22 annotations:
23 {{- toYaml . | nindent 8 }}
24 {{- end }}
25 spec:
26 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
27 containers:
28 - name: oauth
29 image: {{ .Values.oauth.image }}
30 imagePullPolicy: {{ .Values.oauth.pullPolicy }}
31 ports:
32 - name: http
33 containerPort: {{ .Values.oauth.httpPort }}
34 - name: grpc
35 containerPort: {{ .Values.oauth.grpcPort }}
36 {{- with .Values.oauth.extraEnv }}
37 env:
38 {{- toYaml . | nindent 12 }}
39 {{- end }}
40 {{- if .Values.oauth.existingSecret }}
41 volumeMounts:
42 - name: config
43 mountPath: /app/config
44 readOnly: true
45 {{- end }}
46 resources:
47 {{- toYaml .Values.oauth.resources | nindent 12 }}
48 {{- if .Values.oauth.existingSecret }}
49 volumes:
50 - name: config
51 secret:
52 secretName: {{ .Values.oauth.existingSecret }}
53 {{- end }}
54{{- end }}
55{{- if .Values.oauth.enabled }}
56---
57apiVersion: v1
58kind: Service
59metadata:
60 name: {{ printf "%s-oauth" (include "gitdata.fullname" .) }}
61 labels:
62 {{- include "gitdata.labels" . | nindent 4 }}
63 app.kubernetes.io/component: oauth
64spec:
65 selector:
66 {{- include "gitdata.selectorLabels" . | nindent 4 }}
67 app.kubernetes.io/component: oauth
68 ports:
69 - name: http
70 port: {{ .Values.oauth.httpPort }}
71 targetPort: http
72 - name: grpc
73 port: {{ .Values.oauth.grpcPort }}
74 targetPort: grpc
75{{- end }}
No newline at end of file
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -39,13 +39,9 @@
3939 APP_ROCKETMQ__ENABLED: "true"
4040 APP_ROCKETMQ__NAME_SERVER: "rocketmq-namesrv:9876"
4141 APP_ROCKETMQ__TOPIC_PREFIX: "gitdata"
42 # 认证中心:浏览器走 OIDC,服务间走 gRPC(x-rpc-key)。
43 APP_OAUTH__ENABLED: "true"
44 APP_OAUTH__ISSUER: "https://auth.gitdata.ai"
45 APP_OAUTH__CLIENT_ID: "gitdata-web"
46 APP_OAUTH__REDIRECT_URI: "https://gitdata.ai/api/v1/auth/callback"
47 APP_OAUTH__AUDIENCE: "gitdata.ai"
48 APP_OAUTH__GRPC_ENDPOINT: "http://kenoauth:18082"
42 # 认证中心:浏览器走同域 session,服务间走 gRPC(x-rpc-key)。
43 APP_JWT__ISSUER: "https://auth.gitdata.ai"
44 APP_JWT__SESSION_COOKIE: "gitdata_session"
4945 RUST_LOG: "info"
5046
5147# 敏感配置(渲染为 Secret)。注意 AppConfig 的 Debug 会带出密钥,
@@ -56,7 +52,7 @@
5652 existingSecret: ""
5753 env:
5854 APP_DATABASE__URL: "postgres://postgres:CHANGE_ME@postgres:5432/gitdata"
59 APP_OAUTH__RPC_KEY: "" # 与 KenOAuth auth.oauth.rpc_key 一致,>=32 字节
55 APP_AUTH__RPC_KEY: "" # 与 identity auth.rpc_key 一致,>=32 字节
6056 APP_INTERNAL__SERVICE_KEY: "" # /api/v1/internal/* 服务密钥,>=32 字节
6157 APP_POW__MAC_KEY: "" # base64 >=32 字节
6258 APP_AUTH__TOTP_ENCRYPTION_KEY: "" # base64 >=32 字节(实测必填)
@@ -114,19 +110,6 @@
114110 enabled: true
115111 minAvailable: 1
116112
117oauth:
118 enabled: false
119 image: harbor.gitdata.me/app/kenoauth:latest
120 pullPolicy: IfNotPresent
121 replicaCount: 1
122 httpPort: 18081
123 grpcPort: 18082
124 # Secret containing config.yaml (mounted at /app/config). Empty = env-only config.
125 existingSecret: ""
126 extraEnv: []
127 resources:
128 requests: { cpu: 100m, memory: 128Mi }
129 limits: { memory: 512Mi }
130113
131114kenshell:
132115 # KenShell 是 Git 数据面:对象库、引用、pack、LFS、smart HTTP 与 SSH 都由它提供。
Modified charts/prod/values/prod.yaml View charts/prod/values/prod.yaml
@@ -23,7 +23,7 @@
2323 APP_PUSH__VAPID_SUBJECT: "mailto:bot@mail.gitdata.ai"
2424 APP_WEB__WEB_BASE_URL: "https://gitdata.ai"
2525 APP_WEB__CORS_ALLOW_ORIGINS: '["https://gitdata.ai"]'
26 APP_OAUTH__COOKIE_SECURE: "true"
26 APP_JWT__COOKIE_SECURE: "true"
2727 APP_GIT__BASE_URL: "https://gitdata.ai"
2828 APP_LIVEKIT__RECORDING_ENABLED: "false"
2929 RUST_LOG: "info"
Modified config/default/auth.json View config/default/auth.json
@@ -29,52 +29,8 @@
2929 "content_md": "Placeholder privacy policy."
3030 }
3131 ],
32 "oauth": {
33 "public_url": "https://oauth.gitdata.ai",
34 "rpc_key": "REPLACE_WITH_RPC_KEY_AT_LEAST_32_CHARS",
35 "socket_origins": [
36 "https://gitdata.ai"
37 ],
38 "clients": [
39 {
40 "client_id": "api",
41 "name": "GitDataAi API",
42 "kind": "public",
43 "redirect_uris": [
44 "https://gitdata.ai/oauth/callback"
45 ],
46 "post_logout_redirect_uris": [
47 "https://gitdata.ai/"
48 ],
49 "scopes": [
50 "openid",
51 "user_name",
52 "ssh_key",
53 "access_key",
54 "assets",
55 "email",
56 "profile",
57 "theme",
58 "guild",
59 "event",
60 "avatar"
61 ]
62 },
63 {
64 "client_id": "api-service",
65 "name": "GitDataAi Internal Service",
66 "kind": "confidential",
67 "enabled": false,
68 "secret": "REPLACE_WITH_CLIENT_SECRET_AT_LEAST_32_CHARS",
69 "scopes": [
70 "access_key",
71 "repo_read",
72 "repo_write"
73 ],
74 "service_scopes": [
75 "access_key"
76 ]
77 }
78 ]
79 }
32 "rpc_key": "REPLACE_WITH_RPC_KEY_AT_LEAST_32_CHARS",
33 "socket_origins": [
34 "https://gitdata.ai"
35 ]
8036}
Modified config/default/jwt.json View config/default/jwt.json
@@ -3,5 +3,11 @@
33 "leeway_secs": 30,
44 "key_id": "k1",
55 "private_key": "REPLACE_WITH_JWT_ED25519_PRIVATE_KEY_BASE64URL",
6 "retired": []
6 "retired": [],
7 "session_cookie": "gitdata_session",
8 "cookie_domain": "",
9 "cookie_path": "/",
10 "cookie_secure": false,
11 "cookie_same_site": "Lax",
12 "recheck_secs": 60
713}
Deleted config/default/oauth.json
@@ -1,32 +0,0 @@
1{
2 "enabled": false,
3 "issuer": "http://127.0.0.1:7021",
4 "client_id": "gitdata-web",
5 "client_secret": "",
6 "redirect_uri": "http://localhost:5173/api/v1/auth/callback",
7 "redirect_uri_hosts": [
8 "127.0.0.1:5173",
9 "localhost:5173",
10 "127.0.0.1:7041",
11 "localhost:7041"
12 ],
13 "token_endpoint": "",
14 "jwks_url": "",
15 "audience": "gitdata.ai",
16 "grpc_endpoint": "http://127.0.0.1:7022",
17 "rpc_key": "",
18 "session_cookie": "gitdata_session",
19 "refresh_cookie": "gitdata_refresh",
20 "cookie_domain": "",
21 "cookie_path": "/",
22 "cookie_secure": false,
23 "cookie_same_site": "lax",
24 "access_ttl_secs": 600,
25 "refresh_cookie_max_age_secs": 2592000,
26 "recheck_secs": 60,
27 "leeway_secs": 30,
28 "jwks_cache_secs": 3600,
29 "jwks_min_refresh_secs": 30,
30 "http_timeout_ms": 2000,
31 "chat_audience": "chat.gitdata.ai"
32}
Modified deploy/compose.yaml View deploy/compose.yaml
@@ -3,7 +3,7 @@
33# docker compose up -d # 用已有镜像启动
44# docker compose up -d --build # 从仓库根目录 Dockerfile 重新构建
55#
6# 端口:7051 = API;7021 = OAuth HTTP/登录页;7022 = 兼容外部服务的认证 gRPC。
6# 端口:7051 = API;7021 = identity HTTP/登录页;7022 = 兼容外部服务的认证 gRPC。
77# config 里另留了 7052(githost HTTP)与 7053(git SSH)两个口,但当前 apihost
88# 二进制不监听它们(git 读写走 KenShell),改由 KenShell 的 7011/7012 承担。
99#
@@ -16,7 +16,7 @@
1616# docker run --rm --network host -v "$PWD/config:/app/config:ro" \
1717# --entrypoint /usr/local/bin/kenapi kenapi:local migrate
1818# 2. S3 桶(RustFS):gitdata(与 config/local/s3.json 的 bucket 一致)
19# 3. [identity.auth.oauth] 配置 gitdata-web 客户端;OAuth 已内嵌,无需独立服务。
19# 3. identity 已内嵌,无需独立服务。
2020#
2121# 配置:镜像内 config/default/ + 挂载的 config/local/(本机地址与密钥)。
2222
@@ -35,13 +35,11 @@
3535 RUST_LOG: ${RUST_LOG:-info}
3636 TZ: ${TZ:-Asia/Shanghai}
3737 KENAPI_HEALTH_PORT: ${KENAPI_HEALTH_PORT:-7051}
38 # 兜底回调地址:请求 Host 命中 oauth.redirect_uri_hosts(5173 / 7041)时,
39 # KenApi 会按该主机改写;命中不了才用这个值。
40 APP_OAUTH__REDIRECT_URI: http://127.0.0.1:7041/api/v1/auth/callback
38 APP_WEB__WEB_BASE_URL: http://127.0.0.1:7051
4139 # 进程内没有家目录,避免各 SDK 往 / 下写缓存。
4240 HOME: /tmp
4341 volumes:
44 # config/default/ 已烘进镜像;config/local/ 提供本机数据库/Redis/S3/OAuth 地址。
42 # config/default/ 已烘进镜像;config/local/ 提供本机数据库/Redis/S3/identity 地址。
4543 # 整个 config 目录只读挂载,顺便保留未来新增的本地配置文件。
4644 - ../config:/app/config:ro
4745 stop_grace_period: 120s
Deleted docs/oauth.md
@@ -1,1256 +0,0 @@
1# KenOAuth + JWT 迁移总计划
2
3- 状态:执行中
4- 涉及仓库:`C:\工程\KenApi`(GitData)、`C:\工程\KenOAuth`、`C:\工程\KenAdmin`
5- 目标:GitData 的「用户认证」「会话」「space 身份/加入流程」由 KenOAuth 唯一持有;GitData 用 JWT 验签,不再维护任何服务端会话存储
6- 前置结论:**空库对接**,不做存量数据迁移、不兼容旧口令
7- 本文取代旧版计划,是唯一执行依据
8
9## 0. 执行进度(2026-10-24)
10
11| 批次 | 状态 | 已落地内容 |
12| --- | --- | --- |
13| B0.5 | ✅ | 删除 `app/adminhost`/`lib/admin`/`lib/admin-rpc`;`[internal] service_key` + `x-gitdata-service-key` 守卫 |
14| B0 | ✅ | 新增 `lib/kenauth`(proto + 客户端 + 拦截器);`[oauth]` 配置;compose `nats`/`oauth`;Helm `oauth.yaml`;Dockerfile 清理 |
15| B1 | ✅ | `users.uid` 及全部用户外键改为 `i64`;`jwt::Session::uid() -> i64`;OpenAPI 与 web 类型重新生成 |
16| B2.0 | ✅ | `lib/jwt`:EdDSA + JWKS 缓存、access/service claims、Session 提取器、cookie、guard、share 兼容层 |
17| B2.1 | ✅ | webapi 注入 `JwtVerifier` + 全局 `revalidate`(Redis 60s → `GetSession`) |
18| B2.2 | ✅ | `GET /auth/login`、`GET /auth/callback`、`POST /auth/refresh`(Redis 单飞锁)、`POST /auth/logout` |
19| B2.3 | ✅ | 357 个文件 `session_layer` → `jwt`;删除 8 处 `impl SessionGuard`;`api_error!` 改 `::jwt::SessionError` |
20| B2.4 | ✅ | 删除旧 register/login/2FA/PoW/邮箱/口令/AccessKey/SSHKey/设备/注销的 handler 与 service |
21| B2.5 | ✅ | 删除 `lib/session`、`lib/session-layer`、`[session]` 配置与依赖;`SameSite` 并入 `[oauth]` |
22| B2.6 | ✅ | `app/im` 改 KenOAuth service token(`TokenService`)+ `IntrospectToken`;`/auth/jwt-request` 改 `IssueServiceToken`;删除 `export::jwt` 与 `auth.chat_jwt_sec` |
23| B2.7 | ✅ | 前端 401 `token_expired` 单飞 refresh + 登录跳转;删除 6 个认证页面与旧 PoW wasm |
24| B2.8 | ✅ | Helm/compose/NOTES/RUST_LOG 已改;`/readyz` 增加 KenOAuth gRPC 健康探针;JWKS 拉取/刷新失败改为 `error!`(target `gitdata::jwks`)供告警 |
25| B3 | ✅ | `verify_access_key`、SSH 公钥校验改 gRPC;删除 `user_access_keys`/`user_ssh_keys` 及会话/设备/恢复码/注销/邮箱验证码模型 |
26| K1 | ✅ | KenOAuth `users.bot/system`、`EnsureBotUser`;Principal/UserPublic/AuthorityUser 透出 bot/system |
27| K4 | ✅ | 表结构 + JoinRule/JoinRequest RPC + `EnsureBotMember`(支持 role_keys/role_ids);`CreateOrganization/UpdateOrganization` 支持 join 字段;邀请支持 email 绑定与多角色(`role_ids`) |
28| B4 | ✅ | space/member/ban/group/invite/join-rule/join-request 全部写透 KenOAuth;`apply_space_join_rules` 经 `EnsureBotMember(role_ids)` 投影;权限判定改 `CheckPermissions`(300s Redis 投影) |
29| B5 | ✅ | `/internal` 服务密钥守卫复核;未使用依赖清理;`openapi.json` + web 客户端重新生成;`openapi --check`、web build/lint 通过;全仓代码无 `session_layer`/`lib/session`/`admin_rpc` 引用 |
30
31> 当前 `cargo check --workspace`、KenOAuth `cargo check` 与 `cargo run -p kenapi -- openapi --check` 均通过;`bun run --cwd web build` 通过。
32>
33> 已知待收尾:
34> - B4.3 的物理删表改为「保留本地表作为投影缓存」:读路径与 `dir` 覆盖仍依赖这些表,事实源已全部在 KenOAuth。
35> - K2/K3/K5(协议接受、MFA 注册/恢复码、账号删除)与 §9 中标注「代理」的自助端点(2FA、session、access key、ssh key、deletion)尚未落地;前端对应设置分区暂不可用,属于后续批次。
36> - 邮件邀请:KenOAuth 负责 email 绑定与兑换校验,邮件投递仍由 GitData 发送(链接指向 GitData SPA)。
37> - `user_emails` 投影表暂留(单邮箱模型待 K5 完成后删除)。
38
39---
40
41## 1. 目标与非目标
42
43### 1.1 目标
44
451. KenOAuth 成为唯一认证源:账号、口令、邮箱、TOTP、会话、Access Token、SSH 公钥、协议接受。
462. KenOAuth 成为唯一组织事实源:组织、成员、角色、角色权限、邀请、封禁、加入规则、加入申请。
473. GitData 浏览器侧改为 OIDC 授权码 + PKCE,cookie 中只放 KenOAuth 颁发的 **access token**。
484. 删除 `lib/session` 与 `lib/session-layer`,新 `lib/jwt` 只做 EdDSA/JWKS 验签。
495. GitData 用户主键统一为 `i64`,与 `auth.common.v1.Principal.user_id` 对齐。
506. KenShell(Git 数据面)通过 gRPC 校验 Access Key / SSH 公钥。
517. 删除 GitData 自带管理后台,admin 用户与 RBAC 完全由 `C:\工程\KenAdmin` 管理。
528. `/api/v1/internal/*` 改用服务密钥鉴权。
53
54### 1.2 非目标
55
561. 不迁移存量用户、口令、会话、Access Key、space 数据。
572. 不把 `rooms`、`dir`、目录覆盖、space 图片、`space_warn` 迁入 KenOAuth。
583. 不把仓库、issue、PR、chat 消息迁入 KenOAuth。
594. GitData 已移除 `mails` 服务,非认证邮件(issue/PR/chat/space 邀请)改走 KenOAuth 内部 `EmailService.SendEmail`。
605. 不在本期实现 WebAuthn / SMS 多因子(KenOAuth 表结构已就绪)。
616. 不在本期把 GitData 的资源管理能力迁入 KenAdmin(见 §16 能力缺口)。
62
63---
64
65## 2. 决策记录
66
67| 编号 | 决策 | 结论 | 影响 |
68| --- | --- | --- | --- |
69| D1 | 用户主键 | GitData 全量 `Uuid → i64` | 87 个 model、全部用户外键、OpenAPI、前端类型 |
70| D2 | 浏览器认证 | OIDC 授权码 + PKCE;cookie 存 KenOAuth access token | 删除 GitData 登录/注册/重置/2FA 页面 |
71| D3 | Space 归属 | 身份事实 + 加入流程 → KenOAuth | 新建 2 张表 + 10 个 RPC |
72| D4 | 数据库 | 独立 `auth` 库 | GitData 摘掉跨库外键,保留投影表 |
73| D5 | 存量数据 | 空库对接 | 不做迁移脚本、不做口令兼容 |
74| D6 | 邮件邀请 | KenOAuth 生成 token 并直接发信 | invite 增 `email`、新增模板 |
75| D7 | Bot 账号 | KenOAuth 建 bot 账号 | users 增 `bot/system`、新增 2 个 RPC |
76| D8 | 多邮箱 | 改为单邮箱 | 删除 `user_emails` 及 6 个接口 |
77| D9 | 组织 ID | 保持 UUID,与 `spaces.id` 1:1 | 资源表外键不变 |
78| D10 | 权限判定 | 角色权限同名映射 + `CheckPermission`(300s 缓存) | 删除 `space_group_permissions` 等 4 张表 |
79| D11 | 会话实现 | 删除 `lib/session` / `lib/session-layer`,新建 `lib/jwt` | 412 个文件 import 改名 |
80| D12 | 令牌校验 | 只验签 KenOAuth 令牌(EdDSA + JWKS),GitData 不自签 | 不再需要会话签名密钥 |
81| D13 | 续期 | refresh token 放独立 HttpOnly cookie | 新增 `/auth/refresh` + Redis 单飞锁 |
82| D14 | access TTL | 保持 KenOAuth 默认 600s | 约每 9 分钟刷新一次 |
83| D15 | Admin | 完全由 `C:\工程\KenAdmin` 管理 | 删除 `adminhost` / `lib/admin` / `lib/admin-rpc` |
84| D16 | `/internal` | 服务密钥头 `x-gitdata-service-key` | 新增 `[internal] service_key` |
85| D17 | 聊天令牌 | KenOAuth `TokenService.IssueServiceToken`(audience=CHAT) | 删除 `auth.chat_jwt_sec` 与 `export::jwt` |
86
87---
88
89## 3. 目标架构
90
91### 3.1 组件图
92
93```
94 ┌──────────────────────────────┐
95 │ Browser SPA │
96 │ (web, Vite/React) │
97 └──────────┬───────────────────┘
98 │ cookie: gitdata_session / gitdata_refresh
99 ▼
100 ┌──────────────────────────────┐
101 │ KenOAuth (oauth) │
102 │ HTTP 18081 / gRPC 18082 │
103 │ 账号 / 会话 / 令牌 / 组织 │
104 │ PostgreSQL(auth) + Redis │
105 │ + NATS + SMTP │
106 └──────▲──────────────▲────────┘
107 OIDC code/token │ │ gRPC + x-rpc-key
108 │ │
109┌───────────────────────────┴──┐ ┌───────┴───────────────────────┐
110│ apihost (:3000 / dev 4081) │ │ KenShell (gRPC 50091) │
111│ - OIDC RP / refresh │ │ - smart HTTP / SSH │
112│ - lib/jwt 验签 │ └───────────────────────────────┘
113│ - 业务 REST / SPA │
114│ - 本地投影读写 │
115└──────────┬───────────────────┘
116 │ 本地投影:users / spaces / space_members
117 ▼
118 PostgreSQL(gitdata) + Redis + RustFS
119 ▲
120 │
121 im
122```
123
124### 3.2 令牌流转
125
126```
127登录:
128 浏览器 → /api/v1/auth/login → 302 KenOAuth /api/v1/oauth/authorize (PKCE)
129 → /api/v1/auth/callback?code
130 → 后端 POST /api/v1/oauth/token
131 → 验 id_token(JWKS + nonce)
132 → Set-Cookie:
133 gitdata_session = access_token (600s, Path=/)
134 gitdata_refresh = refresh_token (30d, Path=/api/v1/auth)
135
136请求:
137 cookie → lib/jwt 验签(EdDSA + JWKS)→ AccessClaims{sub, sid, sv, username}
138 → revalidate(Redis 60s → AuthorityService.GetSession)
139 → handler require_uid
140
141过期:
142 API 401 token_expired
143 → POST /api/v1/auth/refresh(Redis 单飞锁)
144 → POST /api/v1/oauth/token (refresh_token, 轮换)
145 → 覆盖两个 cookie → 重放原请求
146```
147
148### 3.3 服务边界
149
150| 关注点 | 归属 | 访问方式 |
151| --- | --- | --- |
152| 凭据是否正确 | KenOAuth | `IdentityService.Authenticate` |
153| 账号是否可用 | KenOAuth | `IdentityService.GetUserStatus` / `AuthorityService.GetUser` |
154| 会话是否存活 | KenOAuth | `AuthorityService.GetSession` |
155| 组织内能否做某动作 | KenOAuth | `OrganizationService.CheckPermission` |
156| 令牌签名 | KenOAuth | `/.well-known/jwks.json` |
157| 仓库/房间/工单准入 | GitData | 本地表 + `space_members` 投影 |
158| 平台管理 | KenAdmin | 独立系统,与 GitData 无用户体系耦合 |
159
160---
161
162## 4. 数据归属
163
164### 4.1 KenOAuth 唯一持有
165
166`users`、`user_sessions`、`user_access_tokens`、`user_ssh_keys`、`user_mfa`、`user_mfa_backup_codes`、`user_verification_tokens`、`user_events`、`oauth_clients`、`oauth_consents`、`oauth_refresh_tokens`、`service_token_revocations`、`organizations`、`organization_members`、`organization_roles`、`organization_role_permissions`、`organization_member_roles`、`organization_member_permissions`、`organization_invites`、`organization_invite_redemptions`、`organization_join_rules`(新增)、`organization_join_requests`(新增)、`user_agreement_acceptances`(新增)。
167
168### 4.2 GitData 只读投影
169
170| 表 | 关键字段 | 来源 |
171| --- | --- | --- |
172| `users` | `uid i64`、`username`、`lower_name`、`display_name`、`avatar_url`、`is_active`、`is_restricted`、`prohibit_login`、`bot`、`system`、`visibility`、`version` | OIDC 回调 + `GetUserPublic` / `BatchGetUserPublic` + 对账 |
173| `spaces` | `id uuid`(= organization id)、`lower_name`、`name`、`description`、`owner_id i64`、`avatar_url`、`is_private`、`allow_join`、`allow_join_level`、`member_must_enable_2fa`、`welcome_*`、`banner_*`、`nums_members`、`version` | `OrganizationService` 写成功后同请求双写 + 对账 |
174| `space_members` | `(space_id, user_id)`、`alias_name`、`alias_avatar_url`、`status`、`joined_at`、`left_at`、`banned_at`、`ban_reason`、`ban_expires_at`、`ban_allow_*`、`message_notifications`、`muted`、`mute_until`、`mobile_push`、`suppress_*`、`hide_muted_channels`、`last_active_at` | `OrganizationService` 写成功后同请求双写 + 对账 |
175
176> 投影表只允许 GitData 内部写入,禁止绕过 gRPC 直接改。写入口集中在 `lib/space/src/sync.rs` 与 `lib/kenauth` 调用点。
177
178### 4.3 GitData 完全自有
179
180`user_profile`、`user_preferences`、`user_notifications`、`user_saved_message`、`user_recent_mention`、`user_reports`、`user_push_subscription`、`user_blocks`、`user_followers`、`user_favorite_meme`、`user_suspensions`、`rooms`、`room_*`、`dir`、`dir_overwrite_group`、`dir_overwrite_user`、`space_warn`、`repos`、`issues`、`pull_request*`、`chat_*`、`ci_*`、`agent*`、`search_*`。
181
182### 4.4 删除的数据
183
184`user_sessions`、`user_access_key`、`user_ssh_key`、`user_email`、`user_email_change_ticket`、`user_email_verification_codes`、`user_recovery_codes`、`user_agreement_acceptances`、`user_devices`、`space_bans`、`space_groups`、`space_group_permissions`、`space_member_groups`、`space_invite`、`space_invite_groups`、`space_join_rule`、`space_join_request`。
185
186---
187
188## 5. `lib/jwt` 设计
189
190### 5.1 定位
191
192`lib/jwt` **只验签,不签发**:
193
194- 验证 KenOAuth **access token**(浏览器 cookie)。
195- 验证 KenOAuth **service token**(`app/im` 聊天握手)。
196- 不持有任何签名私钥,不依赖 `models` / `sea-orm` / `redis` / `kenauth`。
197
198### 5.2 结构
199
200```
201lib/jwt/
202 Cargo.toml
203 src/lib.rs
204 src/verifier.rs # JWKS 缓存 + EdDSA 验签
205 src/access.rs # AccessClaims
206 src/service.rs # ServiceClaims
207 src/extract.rs # Session(FromRequestParts<S>)
208 src/user.rs # SessionUser trait(仅 uid())
209 src/cookie.rs # session / refresh cookie
210 src/guard.rs # require_uid / session_guard
211 src/error.rs # SessionError
212 src/share.rs # 兼容路径
213```
214
215依赖:`actix-web`、`serde`、`serde_json`、`jsonwebtoken`、`reqwest`、`tokio`、`time`、`tracing`、`config`、`export`。
216
217### 5.3 Claims
218
219```rust
220#[derive(Clone, Deserialize)]
221pub struct AccessClaims {
222 pub iss: String,
223 #[serde(deserialize_with = "subject::deserialize")]
224 pub sub: i64,
225 #[serde(rename = "client_id", alias = "app_id")]
226 pub client_id: String,
227 pub aud: String,
228 pub jti: String,
229 pub iat: i64,
230 pub exp: i64,
231 pub security_version: i32,
232 pub sid: Option<String>,
233 #[serde(default, with = "scopes")]
234 pub scope: Option<Vec<String>>,
235 pub username: Option<String>,
236 pub token_type: String,
237}
238
239#[derive(Clone, Deserialize)]
240pub struct ServiceClaims {
241 pub iss: String,
242 #[serde(deserialize_with = "subject::deserialize")]
243 pub sub: i64,
244 pub aud: String,
245 pub jti: String,
246 pub iat: i64,
247 pub exp: i64,
248 pub security_version: i32,
249 #[serde(default)]
250 pub scopes: Vec<i32>,
251 pub token_type: String,
252}
253```
254
255`sub` 在 KenOAuth 中序列化为字符串,必须自定义反序列化;`scope` 支持空格分隔字符串与数组两种形态。
256
257### 5.4 验签规则
258
259| 检查 | access | service |
260| --- | --- | --- |
261| 算法 | `EdDSA` | `EdDSA` |
262| 签名键 | JWKS 按 `kid` 选取 | 同 |
263| `iss` | `[oauth] issuer` | 同 |
264| `aud` | `[oauth] audience`(默认 `gitdata.ai`) | `chat.gitdata.ai` |
265| `client_id` | 必须等于 `gitdata-web` | 不适用 |
266| `token_type` | `"access"` | `"service"` |
267| JOSE `typ` | `at+jwt` | `JWT` |
268| `exp` / `iat` | `leeway_secs`(默认 30) | 同 |
269| 未知 `kid` | 限速刷新 JWKS(≥30s) | 同 |
270| JWKS 不可用 | 有缓存用缓存;无缓存返回 `Verifier` | 同 |
271
272### 5.5 `Session` 提取器
273
274```rust
275enum SessionState { Anonymous, Expired, Valid(AccessClaims) }
276
277pub struct Session {
278 state: SessionState,
279 raw: Option<String>,
280}
281
282impl Session {
283 pub fn uid(&self) -> Option<i64>;
284 pub fn username(&self) -> Option<&str>;
285 pub fn session_id(&self) -> Option<&str>;
286 pub fn security_version(&self) -> Option<i32>;
287 pub fn scopes(&self) -> &[String];
288 pub fn token(&self) -> Option<&str>;
289 pub fn is_authenticated(&self) -> bool;
290 pub fn is_expired(&self) -> bool;
291}
292```
293
294- 来源:cookie `gitdata_session`,回退 `Authorization: Bearer <access token>`。
295- 无 cookie / 验签失败 → `Anonymous`。
296- 签名有效但 `exp` 已过 → `Expired`。
297- 提取器永不返回错误;鉴权由 `require_uid` / 中间件决定。
298
299### 5.6 `SessionUser` 与 `require_uid`
300
301```rust
302pub trait SessionUser {
303 fn uid(&self) -> Option<i64>;
304}
305
306impl SessionUser for Session { ... }
307
308pub fn require_uid(session: &Session) -> Result<i64, SessionError>;
309```
310
311全仓 17 处 `SessionUser` 导入、204 处 `require_uid` 调用保持原样,仅 import 路径改为 `jwt`。
312
313### 5.7 Cookie
314
315| Cookie | 值 | HttpOnly | SameSite | Path | Max-Age |
316| --- | --- | --- | --- | --- | --- |
317| `gitdata_session` | KenOAuth access token | 是 | Lax | `/` | token 剩余 TTL |
318| `gitdata_refresh` | KenOAuth refresh token | 是 | Strict | `/api/v1/auth` | 30 天 |
319
320- `Secure` 由 `[oauth] cookie_secure` 控制;生产必须为 true。
321- `Domain` 由 `[oauth] cookie_domain` 控制,默认空(host-only)。
322
323### 5.8 错误
324
325```rust
326pub enum SessionError {
327 Unauthorized, // 401 unauthenticated
328 Expired, // 401 token_expired
329 Verifier(String), // 401 invalid_token
330}
331```
332
333`export::api_error!` 宏中的 `::session_layer::SessionError` 改为 `::jwt::SessionError`,删除 `Database` 分支。
334
335### 5.9 兼容层
336
337```rust
338pub mod share {
339 pub mod session { pub use crate::require_uid; }
340 pub mod guard { pub use crate::guard::session_guard; }
341}
342```
343
344迁移时只需把 412 个文件中的 `session_layer` 替换为 `jwt`。
345
346### 5.10 JWKS 缓存
347
348- `RwLock<CachedJwks>` + `tokio::sync::Mutex` 串行刷新。
349- 正常 TTL `jwks_cache_secs`(3600)。
350- 遇到未知 `kid` 时立即刷新,但两次刷新间隔不小于 `jwks_min_refresh_secs`(30)。
351- 启动时尝试加载;失败只告警,首个请求再重试(开发环境容忍 KenOAuth 未启动)。
352
353---
354
355## 6. 认证流程
356
357### 6.1 OIDC 登录
358
359```
360GET /api/v1/auth/login?redirect=/app
361 1. 校验 redirect 为站内相对路径
362 2. 生成 state / nonce / code_verifier,写 Redis 600s
363 3. 302 {issuer}/api/v1/oauth/authorize
364 ?response_type=code
365 &client_id=gitdata-web
366 &redirect_uri={redirect_uri}
367 &scope=openid profile email
368 &state=&nonce=&code_challenge_method=S256&code_challenge=
369
370GET /api/v1/auth/callback?code&state
371 1. state 校验并删除
372 2. POST {issuer}/api/v1/oauth/token (authorization_code + code_verifier)
373 3. 验 id_token:JWKS、iss、aud、exp、nonce、at_hash
374 4. 验 access_token:JWKS、iss、aud、client_id、typ、exp
375 5. sub 解析为 i64
376 6. Set-Cookie session + refresh
377 7. 302 redirect 或 /app
378```
379
380异常处理:
381
382| 情况 | 响应 |
383| --- | --- |
384| state 缺失/过期 | 400 |
385| token 端点 `invalid_grant` | 400 |
386| token 端点 5xx | 502 |
387| id_token 验签失败 / nonce 不匹配 | 401 |
388| `sub` 非整数 | 401 |
389| 用户被禁用 / 已删除 | 403 |
390
391### 6.2 刷新
392
393```
394POST /api/v1/auth/refresh
395 1. 读 gitdata_refresh;缺失 → 401 unauthenticated
396 2. 从 access token 读 sid(签名有效即可,忽略 exp)
397 3. Redis SET oauth:refresh:{sid} NX EX 10
398 4. POST {issuer}/api/v1/oauth/token (grant_type=refresh_token)
399 5. 覆盖 gitdata_session / gitdata_refresh
400 6. 204
401 - invalid_grant → 清两个 cookie → 401 refresh_expired
402 - 拿不到锁 → 等待 ≤2s 后重试一次
403```
404
405前端策略:
406
407- 401 `token_expired` → 单飞调用 `/auth/refresh` → 重放原请求。
408- 401 `refresh_expired` / `unauthenticated` → 跳 `/api/v1/auth/login?silent=1`。
409- `silent=1` 时授权请求带 `prompt=none`;`login_required` → 清 cookie → 交互登录。
410
411### 6.3 吊销复核
412
413全局中间件 `lib/auth/src/oauth/revalidate.rs`:
414
415- 跳过路径:`/healthz`、`/readyz`、`/api/v1/auth/*`、静态资源、SPA fallback。
416- Redis key `oauth:recheck:{sid}:{sv}`,TTL 60s。
417- 未命中 → `AuthorityService.GetSession(user_id, version, sid)`。
418- 失败 → 清 cookie,返回 401 `session_revoked`。
419
420### 6.4 登出
421
422```
423POST /api/v1/auth/logout
424 1. 读 claims.sid
425 2. SessionService.RevokeSession(uid, sid, "user_logout")
426 3. 清 gitdata_session + gitdata_refresh
427 4. 302 /
428```
429
430### 6.5 敏感操作 MFA
431
432- 改口令、增删 Access Token、增删 SSH Key、管理类操作:额外调用 `AuthorityService.GetSession`,检查 `mfa_verified`。
433- 未通过 → 403 `mfa_required`。
434
435### 6.6 聊天令牌
436
437```
438apihost GET /api/v1/auth/jwt-request?appid=chat
439 → TokenService.IssueServiceToken(
440 audience = TOKEN_AUDIENCE_CHAT,
441 subject_user_id = uid,
442 scopes = [event, guild],
443 ttl_seconds = 900)
444 → { jwt, expires_in_secs }
445
446im 握手:
447 lib/jwt::service::verify(token, aud="chat.gitdata.ai")
448 → consume_jti(claims.jti)(Redis 单次)
449 → TokenService.IntrospectToken(token)
450 → handle_socket(uid)
451```
452
453删除 `lib/export/src/jwt.rs` 与 `auth.chat_jwt_sec`。
454
455---
456
457## 7. KenOAuth 改造(K 系列)
458
459> 约定:所有新增 RPC 仍走 `auth.oauth.rpc_key`(`x-rpc-key`),mutation 需 `gitdata-request-id`。
460
461### K0 配置与客户端注册(0.5 人日)
462
463- 新增 `gitdata-web` public 客户端(PKCE)。
464- 新增 `auth.oauth.invite_accept_url`。
465- 新增组织邀请邮件模板与 i18n 文案。
466
467```yaml
468auth:
469 oauth:
470 public_url: "http://127.0.0.1:18081"
471 rpc_key: "<与 GitData oauth.rpc_key 一致,>=32 字符>"
472 invite_accept_url: "http://localhost:5173/spaces/invites/accept?token={token}"
473 clients:
474 - client_id: gitdata-web
475 name: "GitData Web"
476 kind: public
477 redirect_uris:
478 - "http://localhost:5173/api/v1/auth/callback"
479 post_logout_redirect_uris:
480 - "http://localhost:5173/"
481 scopes: [openid, profile, email, user_name, guild, event]
482```
483
484验收:`GET /.well-known/openid-configuration` 正常;未注册 client 返回 `invalid_client`。
485
486### K1 用户模型与 Bot 账号(2 人日)
487
488表变更:
489
490- `users` 新增 `bot bool default false`、`system bool default false`。
491
492proto:
493
494```proto
495message Principal {
496 // ...
497 bool bot = 14;
498 bool system = 15;
499}
500message UserPublic {
501 // ...
502 bool bot = 8;
503 bool system = 9;
504}
505message AuthorityUser {
506 // ...
507 bool bot = 19;
508 bool system = 20;
509}
510
511service IdentityService {
512 rpc EnsureBotUser(EnsureBotUserRequest) returns (EnsureBotUserResponse);
513}
514message EnsureBotUserRequest {
515 string username = 1;
516 string displayname = 2;
517 bool system = 3;
518}
519message EnsureBotUserResponse {
520 auth.common.v1.UserPublic user = 1;
521 bool created = 2;
522}
523```
524
525验收:连续两次 `EnsureBotUser("service-bot")` 返回同一 `user_id`。
526
527### K2 协议接受(1 人日)
528
529新增表 `user_agreement_acceptances`(user_id、key、version、accepted_at,唯一键 `(user_id, key)`)。
530
531```proto
532service AgreementService {
533 rpc AcceptAgreement(AcceptAgreementRequest) returns (AcceptAgreementResponse);
534}
535message AcceptAgreementRequest {
536 int64 user_id = 1;
537 string key = 2;
538 string version = 3;
539}
540message AcceptAgreementResponse {
541 bool accepted = 1;
542 google.protobuf.Timestamp accepted_at = 2;
543}
544```
545
546验收:接受后 `CheckAgreementsAccepted` 返回 `accepted=true`;重启后仍为 true。
547
548### K3 MFA 注册与恢复码(3 人日)
549
550```proto
551service MfaService {
552 rpc EnrollTotp(EnrollTotpRequest) returns (EnrollTotpResponse);
553 rpc ConfirmTotp(ConfirmTotpRequest) returns (ConfirmTotpResponse);
554 rpc DisableTotp(DisableTotpRequest) returns (DisableTotpResponse);
555 rpc RegenerateRecoveryCodes(RegenerateRecoveryCodesRequest)
556 returns (RegenerateRecoveryCodesResponse);
557}
558message EnrollTotpRequest { int64 user_id = 1; }
559message EnrollTotpResponse {
560 string factor_id = 1;
561 string secret_base32 = 2;
562 string otpauth_url = 3;
563 google.protobuf.Timestamp expires_at = 4;
564}
565message ConfirmTotpRequest { int64 user_id = 1; string factor_id = 2; string code = 3; }
566message ConfirmTotpResponse { bool enabled = 1; repeated string recovery_codes = 2; }
567message DisableTotpRequest { int64 user_id = 1; string code = 2; }
568message DisableTotpResponse { bool disabled = 1; }
569message RegenerateRecoveryCodesRequest { int64 user_id = 1; string code = 2; }
570message RegenerateRecoveryCodesResponse { repeated string recovery_codes = 1; }
571```
572
573要点:secret 用 `auth.totp_encryption_key` 加密落库;恢复码只返回一次;关闭与重生成需校验动态码并推进 `security_version`。
574
575### K4 组织扩展(6 人日)
576
577#### K4.1 表变更
578
579```sql
580ALTER TABLE organizations ADD COLUMN join_policy text NOT NULL DEFAULT 'invite_only';
581ALTER TABLE organizations ADD COLUMN allow_join boolean NOT NULL DEFAULT false;
582ALTER TABLE organizations ADD COLUMN require_email_verified boolean NOT NULL DEFAULT true;
583ALTER TABLE organizations ADD COLUMN require_mfa boolean NOT NULL DEFAULT false;
584ALTER TABLE organizations ADD COLUMN welcome_message text;
585ALTER TABLE organizations ADD COLUMN member_must_enable_2fa boolean NOT NULL DEFAULT false;
586
587ALTER TABLE organization_roles ADD COLUMN color text;
588ALTER TABLE organization_roles ADD COLUMN emoji text;
589ALTER TABLE organization_roles ADD COLUMN icon_hash text;
590ALTER TABLE organization_roles ADD COLUMN position bigint NOT NULL DEFAULT 0;
591
592ALTER TABLE organization_members ADD COLUMN last_active_at timestamptz;
593
594ALTER TABLE organization_invites ADD COLUMN email text;
595```
596
597`join_policy`:`open` / `approval` / `invite_only` / `private`。
598
599#### K4.2 新增表
600
601- `organization_join_rules`:id、organization_id、role_id、trigger、matcher、enabled、timestamps。
602- `organization_join_requests`:id、organization_id、user_id、status、note、decided_by、decided_at、timestamps。
603
604#### K4.3 新增 RPC
605
606```proto
607service OrganizationService {
608 rpc CreateJoinRule(CreateJoinRuleRequest) returns (CreateJoinRuleResponse);
609 rpc UpdateJoinRule(UpdateJoinRuleRequest) returns (UpdateJoinRuleResponse);
610 rpc DeleteJoinRule(DeleteJoinRuleRequest) returns (DeleteJoinRuleResponse);
611 rpc ListJoinRules(ListJoinRulesRequest) returns (ListJoinRulesResponse);
612
613 rpc CreateJoinRequest(CreateJoinRequestRequest) returns (CreateJoinRequestResponse);
614 rpc ListJoinRequests(ListJoinRequestsRequest) returns (ListJoinRequestsResponse);
615 rpc WithdrawJoinRequest(WithdrawJoinRequestRequest) returns (WithdrawJoinRequestResponse);
616 rpc ApproveJoinRequest(ApproveJoinRequestRequest) returns (ApproveJoinRequestResponse);
617 rpc RejectJoinRequest(RejectJoinRequestRequest) returns (RejectJoinRequestResponse);
618
619 rpc EnsureBotMember(EnsureBotMemberRequest) returns (EnsureBotMemberResponse);
620}
621```
622
623`ApproveJoinRequest` 与 `AddMember` 共用同一事务函数;`EnsureBotMember` 跳过 actor 权限判定。
624
625#### K4.4 邮件邀请
626
627- `CreateInviteRequest` 增 `string email = 8`。
628- `OrganizationJoinMethod` 增 `ORGANIZATION_JOIN_METHOD_INVITE_EMAIL = 4`。
629- `CreateInvite` 当 `method=INVITE_EMAIL` 时:生成一次性 token(只存哈希)、落库、通过内嵌 `EmailService` 发送 `mail_organization_invite`。
630- 链接来自 `auth.oauth.invite_accept_url`。
631
632验收:建组织 → `join_policy=approval` → 申请 → 批准 → 成员列表出现;邮件邀请可兑换。
633
634### K5 账号自助(4 人日)
635
636| 能力 | 新增 |
637| --- | --- |
638| 登录后改口令 | `POST /auth/password/change` + `ChangePassword` RPC |
639| 改邮箱 | `POST /auth/email/change`、`/auth/email/change/confirm`、`DELETE /auth/email/change/{ticket_id}` |
640| 注销账号 | `POST/DELETE /auth/deletion`、`GET /auth/deletion` + 延迟清理 worker |
641| Access Token 更新/轮换 | `PATCH /auth/access-tokens/{id}`、`POST /auth/access-tokens/{id}/rotate` |
642| 会话列表/吊销 HTTP | `GET /auth/sessions`、`DELETE /auth/sessions/{sid}`、`DELETE /auth/sessions` |
643
644要点:改口令后 `security_version + 1` 并吊销其他会话;改邮箱复用 `VerificationPurpose::EmailChange`;注销软删 + 宽限期。
645
646---
647
648## 8. GitData 改造(B 系列)
649
650### B0 通道与基础设施(3 人日)
651
652新增 `lib/kenauth`:
653
654```
655lib/kenauth/
656 Cargo.toml
657 build.rs
658 proto/** # 从 KenOAuth 复制
659 src/lib.rs
660 src/client.rs # identity/authority/session/org/token/access_key/ssh_key
661 src/error.rs # ErrorDetail -> ApiError
662 src/convert.rs # Principal / UserPublic -> 本地 DTO
663```
664
665要点:
666
667- `tonic_prost_build` 编译 proto,只生成 client。
668- 拦截器注入 `x-rpc-key`、`gitdata-request-id`。
669- 超时:读 300ms,写 2s。
670- `AppState` 增加 `kenauth: Option<Arc<KenAuthClient>>`(`kenauth` 不依赖 `export`)。
671
672基础设施:
673
674- `docker-compose.dev.yml` 增加 `nats`、`oauth`。
675- `charts/core` 增加 `oauth.yaml`(Deployment/Service/ConfigMap/Secret)。
676- `Dockerfile` 增加 oauth 镜像阶段。
677- `scripts/dev.*`、`justfile` 增加 oauth。
678- `lib/health` 增加 oauth 探测。
679
680验收:`grpcurl -H 'x-rpc-key: ...' 127.0.0.1:18082 auth.v1.IdentityService/GetUserStatus` 可用。
681
682### B0.5 删除管理后台(2 人日)
683
684删除:
685
686| 路径 | 说明 |
687| --- | --- |
688| `lib/admin/` | ~7.5k 行 |
689| `lib/admin-rpc/` | ~2.5k 行 + proto |
690| `app/adminhost/` | 73 行 |
691| `charts/core/templates/adminhost.yaml` | — |
692| `charts/core/values.yaml` 的 `adminhost` 段 | — |
693| `charts/prod/values/prod.yaml` 的 `adminhost` 段 | — |
694| `lib/config/src/admin.rs` | — |
695| `config/default/` | — |
696| `apihost.admin_uids` | — |
697| `lib/auth/src/share/guard.rs::admin_guard` | — |
698| `scripts/dev.ps1`、`scripts/dev.sh` 中 adminhost | — |
699| `justfile` urls 中 adminhost | — |
700| `Dockerfile` 中 adminhost 二进制 | — |
701| `Cargo.toml` 的 `admin` / `admin-rpc` workspace 依赖 | — |
702
703新增 `[internal] service_key`,把 `/api/v1/internal/*` 改为服务密钥守卫:
704
705- 请求头 `x-gitdata-service-key`。
706- 常量时间比较。
707- 仅内网可达。
708- 实施时审计路由集合,删除无消费者的 admin-only 端点。
709
710验收:全仓无 `adminhost` / `admin_rpc` 引用;`/api/v1/internal/*` 无密钥 401。
711
712### B1 用户 ID 全面 i64(8 人日)
713
714原则:只改用户标识,不改 `space/dir/repo/issue/room` 等资源主键。
715
716model 层:
717
718| 文件 | 改动 |
719| --- | --- |
720| `lib/models/src/users/user.rs` | `uid: Uuid → i64`;删除 `passwd_hash`、`passwd_version`、`password_last_changed_at`、`totp_*` |
721| `lib/models/src/**`(87 个文件) | 用户外键 `Uuid → i64` |
722| `lib/models/src/space/space_members.rs` | `user_id: i64` |
723
724非 model 层:
725
726| 文件 | 改动 |
727| --- | --- |
728| `lib/export/src/authz.rs` | `OnceLock<i64>`、`HashSet<i64>` |
729| `lib/export/src/space_token.rs` | `bot_uid: i64` |
730| `lib/session-layer/src/user.rs` | 随 B2 删除 |
731| `lib/auth/src/share/guard.rs` | 随 B2 删除 |
732| `lib/config/src/apihost.rs` | 删除 `admin_uids` |
733| `lib/models/src/**` | 同步索引与关系 |
734
735前端:`just openapi` + `bun run --cwd web gen:api`;把 `user_id` 当字符串处理的地方改为 number。
736
737执行方式:先改 `users.uid`,`cargo check` 按编译错误逐个修复。
738
739验收:`cargo check --workspace` 全绿;`openapi-check` 通过。
740
741### B2 `lib/jwt` 与认证替换(21.5 人日)
742
743#### B2.0 `lib/jwt` 实现(4 人日)
744
745按 §5 实现。
746
747#### B2.1 `webapi` 接入(1.5 人日)
748
749```rust
750let verifier = Arc::new(JwtVerifier::new(&state.config.oauth).await?);
751...
752.layer(Extension(verifier))
753.layer(from_fn_with_state(state.clone(), auth::oauth::revalidate))
754```
755
756删除 `session_layer::{SessionLayer, store}` 与 `store::connect`。
757
758#### B2.2 OIDC 回调 / refresh / logout(4 人日)
759
760新增:
761
762```
763lib/auth/src/oauth/
764 mod.rs
765 state.rs # Redis: oauth:state:{state}
766 jwks.rs # 由 lib/jwt 提供
767 callback.rs
768 refresh.rs
769 logout.rs
770 revalidate.rs
771```
772
773#### B2.3 全局替换(1.5 人日)
774
775- 412 个文件 `session_layer` → `jwt`。
776- 8 个 `share/guard.rs` 保留,`session_guard` 改为非泛型或空泛型。
777- 删除 8 个 `impl SessionGuard` 块。
778- `app/im` 的 `SessionGuard` 引用改为 `lib/jwt::service`。
779- `lib/export/src/api_error.rs` 宏引用 `::jwt::SessionError`。
780
781#### B2.4 删除旧登录链路(4 人日)
782
783删除:
784
785- `lib/auth/src/handlers/*` 中 register/login/login-2fa/logout/email/password/2fa/agreements/access-keys/ssh-keys/jwt-request。
786- `lib/auth/src/services/{login,register,password,two_factor,email,sessions,devices,agreements,access_keys,ssh_keys,pow,jwt}.rs`。
787- `lib/auth/src/pow/**`、`lib/auth/src/templates/**`、`lib/auth/src/share/{crypto,totp,rate}.rs`。
788- `lib/pow-solver`。
789- `lib/user/src/services/{emails,sessions}.rs` 及对应 handlers。
790
791#### B2.5 删除两个 crate(1 人日)
792
793- 删除 `lib/session/`、`lib/session-layer/`。
794- `Cargo.toml` 移除 `session`、`session-layer`。
795- 14 个 crate 的 `Cargo.toml`:`session-layer` → `jwt`。
796- `lib/config`:`session.rs` → 删除;`[session]` → `[oauth]`。
797- `lib/webapi/Cargo.toml` 移除 `session`。
798
799#### B2.6 `app/im` 改造(2 人日)
800
801- `GET /api/v1/auth/jwt-request` 改为 `TokenService.IssueServiceToken`。
802- `app/im/src/gateway.rs` 改为 `lib/jwt::service::verify` + `consume_jti` + `IntrospectToken`。
803- 删除 `lib/export/src/jwt.rs`。
804
805#### B2.7 前端(2 人日)
806
807- `web/src/shared/api/createApis.ts`:401 拦截 + 单飞 refresh。
808- `web/src/features/auth/model/session.ts`:401 跳转逻辑。
809- 删除登录/注册/重置/2FA 页面与 `features/auth` 中相关逻辑。
810- `web/src/app/routes/router.tsx` 去掉 6 个路由。
811- `web/src/shared/lib/pow-solver` 删除。
812
813#### B2.8 配置 / Helm(1.5 人日)
814
815见 §12。
816
817验收:
818
819- 登录 → 回调 → cookie 写入 → `/me` 正确
820- 401 → refresh → 重放成功
821- 吊销会话 60s 内 401
822- 全仓无 `session_layer`
823
824### B3 Git 凭据与令牌(5 人日)
825
826| 文件 | 改动 |
827| --- | --- |
828| `lib/git/src/share/access.rs::verify_access_key` | 改为 `AccessKeyService.VerifyAccessKey` |
829| `lib/git/src/ssh/handler/auth.rs` | 改为 `SshKeyService.LookupSshKey` |
830| `lib/git/src/share/access.rs::verify_space_bot_token` | `bot_uid: i64` |
831| `lib/models/src/users/{user_access_keys,user_ssh_keys}.rs` | 删除 |
832| `app/im` | 已由 B2.6 覆盖 |
833
834scope 映射:KenOAuth `repo_read/repo_write` → 本地 `allows(write)`。
835
836### B4 Space 投影与写入穿透(15 人日)
837
838#### B4.1 同步层
839
840新增 `lib/space/src/sync.rs`:
841
842- `upsert_space` / `remove_space`
843- `upsert_member` / `remove_member`
844- `bump_members`
845
846映射:
847
848| OrganizationInfo | spaces |
849| --- | --- |
850| `id` | `id` |
851| `slug` | `lower_name` |
852| `name` | `name` |
853| `description` | `description` |
854| `avatar_url` | `avatar_url` |
855| `owner_user_id` | `owner_id` |
856| `member_count` | `nums_members` |
857| `version` | `version` |
858
859| OrganizationMemberInfo | space_members |
860| --- | --- |
861| `user_id` | `user_id` |
862| `role` / `left_at` / `banned_at` | `status` |
863| `alias_*` | 同名 |
864| `ban_*` | 同名 |
865| 通知偏好字段 | 同名 |
866| `last_active_at` | 同名 |
867
868#### B4.2 服务改造
869
870| 文件 | 改动 |
871| --- | --- |
872| `lib/space/src/services/spaces.rs` | 创建/更新/归档/删除 → OrganizationService |
873| `lib/space/src/services/members.rs` | → 成员 RPC + 投影 |
874| `lib/space/src/services/bans.rs` | → BanMember/UnbanMember/UpdateBan |
875| `lib/space/src/services/groups.rs` | → Role RPC |
876| `lib/space/src/services/invites.rs` | → Invite RPC |
877| `lib/space/src/services/join_rules.rs` | → K4 RPC |
878| `lib/space/src/services/join_requests.rs` | → K4 RPC |
879| `lib/space/src/share/authz.rs` | owner/system 短路 + `CheckPermission`(300s)+ 投影 |
880| `lib/space/src/services/rooms.rs`、`dir.rs`、`images.rs` | 保留本地 |
881
882#### B4.3 删除表
883
884`space_bans`、`space_groups`、`space_group_permissions`、`space_member_groups`、`space_invite`、`space_invite_groups`、`space_join_rule`、`space_join_request`。
885
886#### B4.4 权限映射
887
888| 角色 | key | 权限 |
889| --- | --- | --- |
890| OWNER | `owner` | 全部(短路) |
891| ADMIN | `admin` | `view_space, read, message, manage_members, manage_invites, manage_bans, manage_channels, manage_overwrites` |
892| MEMBER | `member` | `view_space, read, message` |
893
894自定义角色 = 原 `space_groups`;`self_assignable` = 原 `join_group_self`。
895
896验收:建 space → 成员/角色/封禁/邀请/加入申请全链路;24 个跨域 `space_members` 查询正常。
897
898### B5 收尾(4 人日)
899
9001. `lib/webapi/src/lib.rs` 路由收敛;`/internal` 服务密钥守卫。
9012. `lib/export/src/authz.rs` 清理。
9023. `lib/user`、`lib/auth` 死代码清理。
9034. `openapi.json` + `web/src/shared/api` 最终生成;`openapi-check` 通过。
9045. Helm/compose/justfile 最终校验。
9056. 全链路冒烟:注册 → 登录 → 建组织 → 邀请 → 建仓库 → push → issue → chat。
906
907---
908
909## 9. 端点处置表
910
911| 原端点 | 处置 |
912| --- | --- |
913| `GET /auth/pow/challenge` | 删除 |
914| `POST /auth/register` | 删除 |
915| `POST /auth/login` | 改为 `GET` 302 |
916| `POST /auth/login/2fa` | 删除 |
917| `POST /auth/logout` | 改为本地登出 + 302 |
918| `POST /auth/email/verify`、`/resend`、`/reclaim` | 删除 |
919| `POST /auth/password/forgot`、`/reset`、`/change` | 删除 |
920| `GET /auth/agreements` | 保留,代理 `AgreementService.ListAgreements` |
921| `PUT /me/agreements/{key}` | 改为代理 `AcceptAgreement` |
922| `POST /auth/email/change*` | 改为代理 KenOAuth |
923| `GET /auth/2fa/status` | 代理 `MfaService.GetMfaStatus` |
924| `POST /auth/2fa/setup|enable|disable` | 代理 K3 RPC |
925| `GET/PUT /auth/2fa/recovery-codes` | 代理 K3 RPC |
926| `GET/POST/DELETE/PATCH /auth/access-keys*` | 代理 KenOAuth HTTP |
927| `GET/POST/DELETE /auth/ssh-keys*` | 代理 KenOAuth HTTP |
928| `GET /auth/jwt-request` | 改为 `TokenService.IssueServiceToken` |
929| `GET/PATCH /me` | 保留;账号字段写 `AuthorityService.UpdateUser` |
930| `PUT /me/username` | 代理 `AuthorityService.UpdateUser` |
931| `GET/PATCH /me/preferences` | 保留本地 |
932| `GET/DELETE /me/sessions*` | 代理 `SessionService` |
933| `GET/POST/DELETE /me/emails*` | 删除(单邮箱) |
934| `GET/PATCH/DELETE /me/devices*` | 删除 |
935| `GET/POST/DELETE /me/deletion` | 改为代理 K5 |
936| `GET /users*` | 保留,读投影 |
937
938---
939
940## 10. 数据模型变更(GitData)
941
942| 表 | 操作 |
943| --- | --- |
944| `users` | `uid BIGINT`;删凭据列;保留 `bot/system` |
945| `user_sessions` | 删除 |
946| `user_access_key` | 删除 |
947| `user_ssh_key` | 删除 |
948| `user_email*`、`user_email_change_ticket`、`user_email_verification_codes` | 删除 |
949| `user_recovery_codes` | 删除 |
950| `user_devices` | 删除 |
951| `user_agreement_acceptances` | 删除 |
952| `space_bans` | 删除 |
953| `space_groups` | 删除 |
954| `space_group_permissions` | 删除 |
955| `space_member_groups` | 删除 |
956| `space_invite`、`space_invite_groups` | 删除 |
957| `space_join_rule`、`space_join_request` | 删除 |
958| `spaces` | 保留;补 `join_policy`、`require_email_verified`、`require_mfa`、`version` |
959| `space_members` | 保留;补 `banned_at`、`ban_reason`、`ban_expires_at`、`ban_allow_*`、`last_active_at` |
960| 其余 model | 用户外键 `UUID → BIGINT` |
961
962建表仍由 `models::migrate::sync_schema` 完成;空库无需手写迁移 SQL。
963
964---
965
966## 11. 前端改造
967
968| 区域 | 改动 |
969| --- | --- |
970| 认证页面 | 删除 6 个页面与对应路由 |
971| `features/auth` | 保留会话读取与错误翻译;删除登录/注册/POW |
972| `features/user-settings` | 密码/2FA/邮箱/会话/Access Token/SSH Key 改调代理端点 |
973| `features/space-management` | `group_id → role_id`;封禁字段;加入规则/申请字段 |
974| `entities/*` | 用户 id 由 string 改 number |
975| `shared/api` | `globals.d.ts`、`apiDefinitions.ts` 重新生成 |
976| `createApis.ts` | 401 拦截 + 单飞 refresh |
977| `shared/storage` | `setCacheOwner` 参数改 number |
978| i18n | 删除废弃文案,补新错误码 |
979
980---
981
982## 12. 配置与基础设施
983
984### 12.1 GitData 配置
985
986```toml
987[oauth]
988enabled = true
989issuer = "http://127.0.0.1:18081"
990client_id = "gitdata-web"
991client_secret = ""
992redirect_uri = "http://localhost:5173/api/v1/auth/callback"
993token_endpoint = "http://127.0.0.1:18081/api/v1/oauth/token"
994jwks_url = "http://127.0.0.1:18081/.well-known/jwks.json"
995audience = "gitdata.ai"
996grpc_endpoint = "http://127.0.0.1:18082"
997rpc_key = ""
998session_cookie = "gitdata_session"
999refresh_cookie = "gitdata_refresh"
1000cookie_domain = ""
1001cookie_path = "/"
1002cookie_secure = false
1003cookie_same_site = "lax"
1004access_ttl_secs = 600
1005refresh_cookie_max_age_secs = 2592000
1006recheck_secs = 60
1007leeway_secs = 30
1008jwks_cache_secs = 3600
1009jwks_min_refresh_secs = 30
1010http_timeout_ms = 2000
1011
1012[internal]
1013service_key = ""
1014```
1015
1016删除:`[session]`、`[admin]`、`apihost.admin_uids`、`auth.chat_jwt_sec`。
1017
1018### 12.2 本地开发
1019
1020```yaml
1021# docker-compose.dev.yml
1022nats:
1023 image: nats:2-alpine
1024 ports: ["127.0.0.1:4222:4222", "127.0.0.1:8222:8222"]
1025 command: ["-js", "-m", "8222"]
1026
1027oauth:
1028 image: harbor.gitdata.me/app/kenoauth:latest
1029 network_mode: host
1030 volumes:
1031 - ./config/oauth.yaml:/app/config/config.yaml:ro
1032 - ./config/api/keys:/app/config/api/keys:ro
1033 depends_on: [postgres, redis, nats]
1034```
1035
1036`config/local/oauth.json`、`config/local/internal.json` 单独维护(gitignore)。
1037
1038### 12.3 Helm
1039
1040- 新增 `charts/core/templates/oauth.yaml`。
1041- 删除 `adminhost.yaml` 与 `adminhost` values。
1042- 删除 `APP_SESSION__*`、`APP_SESSION__KEY`、`APP_ADMIN__*`。
1043- 新增 `APP_OAUTH__ISSUER`、`APP_OAUTH__AUDIENCE`、`APP_OAUTH__CLIENT_ID`、`APP_OAUTH__GRPC_ENDPOINT`、`APP_OAUTH__RPC_KEY`、`APP_INTERNAL__SERVICE_KEY`。
1044- `RUST_LOG` 去掉 `session::session_store`。
1045
1046### 12.4 监控
1047
1048- `lib/kenauth` 客户端指标:请求量、错误率、P99。
1049- JWKS 拉取失败告警。
1050- `oauth unavailable` 持续 30s 告警。
1051- 健康检查失败不清空本地 cookie。
1052
1053---
1054
1055## 13. 批次、依赖、工期
1056
1057### 13.1 KenOAuth
1058
1059| 批次 | 内容 | 人日 |
1060| --- | --- | --- |
1061| K0 | 配置 + `gitdata-web` 客户端 | 0.5 |
1062| K1 | bot 字段 + `EnsureBotUser` | 2 |
1063| K2 | 协议接受 | 1 |
1064| K3 | MFA 注册 | 3 |
1065| K4 | 组织扩展 | 6 |
1066| K5 | 账号自助 | 4 |
1067| 小计 | | **16.5** |
1068
1069### 13.2 GitData
1070
1071| 批次 | 内容 | 依赖 | 人日 |
1072| --- | --- | --- | --- |
1073| B0 | `lib/kenauth` + 基础设施 | K0 | 3 |
1074| B0.5 | 删除管理后台 + `/internal` 服务密钥 | — | 2 |
1075| B1 | 用户 ID 全面 i64 | — | 8 |
1076| B2.0 | `lib/jwt` | B0 | 4 |
1077| B2.1 | `webapi` 接入 | B2.0 | 1.5 |
1078| B2.2 | OIDC / refresh / logout | K5/B2.1 | 4 |
1079| B2.3 | 全局替换 + 删 `SessionGuard` | B2.1/B1 | 1.5 |
1080| B2.4 | 删除旧登录链路 | K1/K2/K3 | 4 |
1081| B2.5 | 删除两个 crate | B2.3/B2.4 | 1 |
1082| B2.6 | `app/im` 服务令牌 | B2.0 | 2 |
1083| B2.7 | 前端 | B2.2 | 2 |
1084| B2.8 | 配置 / Helm | B2.5 | 1.5 |
1085| B3 | Git 凭据 | B2 | 5 |
1086| B4 | Space 投影 | K4/B1/B2 | 15 |
1087| B5 | 收尾 | 全部 | 4 |
1088| 小计 | | | **58.5** |
1089
1090### 13.3 总计
1091
1092**约 75 人日**。
1093
1094关键路径:
1095
1096```
1097B0.5 ──► B0 ──► B1 ──► B2.0 ──► B2.1 ──► B2.2 ──► B2.3 ──► B2.4 ──► B2.5 ──► B3/B4 ──► B5
1098 ▲
1099K0 ──► K5 ───────────────────────────┘
1100K4 ─────────────────────────────────────────► B4
1101```
1102
1103可并行:
1104
1105- B0.5 与 K 系列完全独立,可最先做。
1106- B1 与 K 系列并行。
1107- B3 与 B4 并行。
1108
1109---
1110
1111## 14. 验收标准
1112
1113### 14.1 功能
1114
1115- [ ] 注册(KenOAuth 页面)→ 回跳 GitData → `/me` 正确
1116- [ ] 登录 / 登出 / 找回口令 / 改口令 / 改邮箱
1117- [ ] TOTP 注册、登录验证、恢复码、关闭
1118- [ ] 协议列表、接受、未接受时拦截
1119- [ ] Access Token 创建/列出/吊销/轮换;SSH Key 添加/删除
1120- [ ] `git clone` HTTPS + SSH、`git push`
1121- [ ] 建组织、改资料、归档/恢复/删除、转移所有权
1122- [ ] 成员增删、角色增删改、权限授予、自助角色加入/退出
1123- [ ] 邀请链接、邮件邀请、定向邀请、撤销、兑换
1124- [ ] 加入规则、加入申请(提交/撤回/批准/拒绝)
1125- [ ] 封禁 / 解封 / 更新封禁
1126- [ ] MCP internal key 与 space token
1127
1128### 14.2 JWT / 会话
1129
1130- [ ] cookie 中是 EdDSA access token,验签通过
1131- [ ] `client_id != gitdata-web` 的令牌被拒绝
1132- [ ] access token 过期后 refresh 成功,原请求重放成功
1133- [ ] refresh 轮换后旧 token 重放被拒绝
1134- [ ] 并发 10 个刷新只触发一次 `/api/v1/oauth/token`
1135- [ ] 吊销会话后 60s 内 401
1136- [ ] `im` 用 service token 握手成功,`jti` 重放被拒
1137
1138### 14.3 管理后台删除
1139
1140- [ ] `adminhost` 进程、端口、Helm 资源、脚本条目全部消失
1141- [ ] `/api/v1/internal/*` 无服务密钥 401,有密钥放行
1142- [ ] 全仓无 `session_layer` / `lib/session` / `admin_rpc`
1143
1144### 14.4 非功能
1145
1146- [ ] `cargo check --workspace` 全绿
1147- [ ] `cargo clippy --workspace --all-targets` 无新增告警
1148- [ ] `just openapi-check` 通过
1149- [ ] `just web-lint` 通过
1150- [ ] 认证 P99 < 50ms(不含 OIDC 外部调用)
1151- [ ] KenOAuth 不可用时已登录用户可读,写操作 503
1152
1153---
1154
1155## 15. 风险与回滚
1156
1157| 风险 | 等级 | 缓解 |
1158| --- | --- | --- |
1159| i64 改造遗漏 | 高 | 编译器驱动 + 独立批次 |
1160| refresh 并发触发轮换撤销 | 高 | Redis 锁 + 前端单飞 + 失败重登 |
1161| JWKS 拉取失败 | 中 | 缓存 + 限速重试 + 启动告警 |
1162| 删除 admin 后管理能力缺口 | 中 | 列为后续独立项目 |
1163| 删除 `lib/session` 后遗漏 | 中 | 编译器 + 全仓 grep |
1164| 投影漂移 | 中 | 同请求双写 + 每小时对账 + 每日全量 |
1165| NATS 新依赖 | 中 | 部署前置检查 + 健康探针 |
1166| service key 泄露 | 中 | ≥32 字符、仅内网、可轮换 |
1167| 令牌在 cookie 中被 XSS 读取 | 中 | HttpOnly + SameSite + CSP |
1168
1169回滚:
1170
1171- 空库期:回滚部署 + 恢复数据库快照。
1172- 灰度期:`[oauth] enabled` 临时开关,关闭后回到旧会话校验;B5 删除。
1173
1174---
1175
1176## 16. 已知退化与能力缺口
1177
1178| 项 | 说明 |
1179| --- | --- |
1180| 多邮箱 | 改为单邮箱 |
1181| 信任设备 | `user_devices` 删除 |
1182| 手机验证 | `allow_join_level` 的 phone 档位降级为邮箱验证 |
1183| GitData 资源管理 | 删除 adminhost 后,用户/仓库/chat 审核/系统状态均无管理入口;KenAdmin 目前只管自身用户与 KenOAuth |
1184| 聊天 JWT | 改为 KenOAuth service token,TTL 900s |
1185| 邀请统计 | `invite_sort_url_hash` 随表删除 |
1186
1187---
1188
1189## 17. 附录
1190
1191### 17.1 错误码映射(KenOAuth Code → GitData HTTP)
1192
1193| Code | HTTP | 前端处理 |
1194| --- | --- | --- |
1195| `INVALID_ARGUMENT` / `MALFORMED_REQUEST` | 400 | 展示 message |
1196| `INVALID_CREDENTIALS` / `UNAUTHENTICATED` / `TOKEN_*` | 401 | 跳登录 |
1197| `SECURITY_VERSION_STALE` | 401 | 清会话 + 跳登录 |
1198| `FORBIDDEN` / `LOGIN_FORBIDDEN` / `USER_DISABLED` | 403 | 展示 message |
1199| `MFA_REQUIRED` | 403 | 跳二次验证 |
1200| `AGREEMENT_NOT_ACCEPTED` | 403 | 跳协议页 |
1201| `NOT_FOUND` / `USER_NOT_FOUND` / `ORGANIZATION_*_NOT_FOUND` | 404 | 展示 message |
1202| `CONFLICT` / `*_TAKEN` / `*_EXISTS` | 409 | 展示 message |
1203| `RATE_LIMITED` / `EMAIL_CODE_ATTEMPTS_EXCEEDED` | 429 | 展示 retry_after |
1204| `UNAVAILABLE` | 503 | 提示稍后重试 |
1205| `INTERNAL` / `DATABASE_ERROR` / `CACHE_ERROR` | 500 | 通用错误 |
1206
1207### 17.2 权限键
1208
1209```
1210view_space
1211read
1212message
1213manage_members
1214manage_roles
1215manage_invites
1216manage_bans
1217manage_channels
1218manage_overwrites
1219```
1220
1221### 17.3 GitData 配置模板
1222
1223见 §12.1。
1224
1225### 17.4 KenOAuth 关键文件索引
1226
1227| 功能 | 文件 |
1228| --- | --- |
1229| HTTP 路由 | `src/auth/router.rs` |
1230| gRPC 注册 | `src/auth/grpc/mod.rs` |
1231| 组织 | `src/auth/grpc/organization.rs`、`src/auth/organization/**` |
1232| 身份 | `src/auth/grpc/identity.rs` |
1233| 会话 | `src/auth/grpc/session.rs`、`src/session/**` |
1234| 令牌 | `src/auth/grpc/token.rs`、`src/jwt/**` |
1235| 协议 | `src/auth/grpc/agreement.rs` |
1236| MFA | `src/auth/grpc/mfa.rs`、`src/auth/share/mfa.rs` |
1237| 邮件 | `src/email/**` |
1238| 配置 | `src/config/**` |
1239| 建表 | `src/auth/migrate.rs`、`src/db/migrate.rs` |
1240| 前端 | `view/src/**` |
1241
1242### 17.5 GitData 关键文件索引
1243
1244| 功能 | 文件 |
1245| --- | --- |
1246| 路由聚合 | `lib/webapi/src/lib.rs` |
1247| JWT 验签 | `lib/jwt/**` |
1248| 认证 | `lib/auth/src/**` |
1249| 用户 | `lib/user/src/**` |
1250| 空间 | `lib/space/src/**` |
1251| 鉴权工具 | `lib/export/src/authz.rs`、`lib/export/src/space_token.rs` |
1252| Git 凭据 | `lib/git/src/share/access.rs`、`lib/git/src/ssh/handler/auth.rs` |
1253| 模型 | `lib/models/src/**` |
1254| 配置 | `lib/config/src/**` |
1255| 前端 | `web/src/**` |
1256| 已删除 | `lib/session/**`、`lib/session-layer/**`、`lib/admin/**`、`lib/admin-rpc/**`、`app/adminhost/**` |
No newline at end of file
Modified lib/auth/src/runtime/mod.rs View lib/auth/src/runtime/mod.rs
@@ -21,7 +21,7 @@
2121use anyhow::{Context, Result};
2222use std::net::{IpAddr, SocketAddr};
2323
24const APP: &str = "oauth";
24const APP: &str = "identity";
2525/// 邮件订阅者排空时的宽限时间。
2626const EMAIL_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
2727const GRPC_SHUTDOWN_GRACE_SECS: u64 = 10;
@@ -155,7 +155,7 @@
155155 state.session.clone(),
156156 state.store.clone(),
157157 ))
158 .wrap(crate::oltp::HttpTelemetry::new("oauth"))
158 .wrap(crate::oltp::HttpTelemetry::new("identity"))
159159 .wrap(crate::envelope::EnvelopeContext)
160160 .wrap(cors.clone())
161161 .app_data(web::Data::new(state.service.clone()))
@@ -178,7 +178,7 @@
178178 email = email_enabled,
179179 email_queue_depth,
180180 event_forwarding,
181 "oauth serving"
181 "identity serving"
182182 );
183183
184184 Ok(Runtime {
Modified lib/auth/src/server/grpc/access_key.rs View lib/auth/src/server/grpc/access_key.rs
@@ -31,7 +31,7 @@
3131 AccessKeyServiceServer<Self>,
3232 context::Interceptor,
3333 > {
34 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
34 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3535 AccessKeyServiceServer::with_interceptor(self, gate)
3636 }
3737}
Modified lib/auth/src/server/grpc/agreement.rs View lib/auth/src/server/grpc/agreement.rs
@@ -82,7 +82,7 @@
8282 AgreementServiceServer<Self>,
8383 context::Interceptor,
8484 > {
85 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
85 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
8686 AgreementServiceServer::with_interceptor(self, gate)
8787 }
8888}
Modified lib/auth/src/server/grpc/audit.rs View lib/auth/src/server/grpc/audit.rs
@@ -176,7 +176,7 @@
176176 AuditServiceServer<Self>,
177177 context::Interceptor,
178178 > {
179 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
179 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
180180 AuditServiceServer::with_interceptor(self, gate)
181181 }
182182}
Modified lib/auth/src/server/grpc/authority.rs View lib/auth/src/server/grpc/authority.rs
@@ -33,7 +33,7 @@
3333 AuthorityServiceServer<Self>,
3434 context::Interceptor,
3535 > {
36 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
36 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3737 AuthorityServiceServer::with_interceptor(self, gate)
3838 }
3939}
Modified lib/auth/src/server/grpc/authorization.rs View lib/auth/src/server/grpc/authorization.rs
@@ -43,7 +43,7 @@
4343 AuthorizationServiceServer<Self>,
4444 context::Interceptor,
4545 > {
46 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
46 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
4747 AuthorizationServiceServer::with_interceptor(self, gate)
4848 }
4949
Modified lib/auth/src/server/grpc/cache_maintenance.rs View lib/auth/src/server/grpc/cache_maintenance.rs
@@ -66,7 +66,7 @@
6666 CacheMaintenanceServiceServer<Self>,
6767 context::Interceptor,
6868 > {
69 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
69 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
7070 CacheMaintenanceServiceServer::with_interceptor(self, gate)
7171 }
7272}
Deleted lib/auth/src/server/grpc/client.rs
@@ -1,256 +0,0 @@
1//! OAuth 客户端注册表管理面(`auth.internal.v1.ClientService`)。
2//!
3//! 共享密钥门禁(`x-rpc-key`)放行后,所有写操作立即刷新进程内快照。
4
5use sea_orm::EntityTrait;
6use tonic::{Request, Response, Status};
7
8use crate::models::users::oauth_clients::{
9 ClientKind as ModelKind, ClientSource as ModelSource, Entity as ClientEntity,
10};
11use crate::protos::auth::internal::v1::client_service_server::{
12 ClientService, ClientServiceServer,
13};
14use crate::protos::auth::internal::v1::*;
15use crate::server::AuthSvc;
16use crate::server::errors::AuthError;
17use crate::server::grpc::{context, convert, error};
18use crate::server::oauth::client::{ClientRecord, ClientSpec};
19
20#[derive(Clone)]
21pub struct ClientApi {
22 svc: AuthSvc,
23}
24
25impl ClientApi {
26 pub fn new(svc: AuthSvc) -> Self {
27 Self { svc }
28 }
29
30 pub fn server(
31 self,
32 ) -> tonic::service::interceptor::InterceptedService<
33 ClientServiceServer<Self>,
34 context::Interceptor,
35 > {
36 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
37 ClientServiceServer::with_interceptor(self, gate)
38 }
39}
40
41fn kind_of(value: i32) -> Result<ModelKind, Status> {
42 match ClientKind::try_from(value) {
43 Ok(ClientKind::Public) => Ok(ModelKind::Public),
44 Ok(ClientKind::Confidential) => Ok(ModelKind::Confidential),
45 _ => Err(Status::invalid_argument("client kind is required")),
46 }
47}
48
49fn record_of(row: &crate::models::users::oauth_clients::Model) -> OAuthClientRecord {
50 OAuthClientRecord {
51 client_id: row.client_id.clone(),
52 name: row.name.clone(),
53 kind: match row.kind {
54 ModelKind::Public => ClientKind::Public as i32,
55 ModelKind::Confidential => ClientKind::Confidential as i32,
56 },
57 audience: row.audience.clone().unwrap_or_default(),
58 enabled: row.enabled,
59 redirect_uris: serde_json::from_value(row.redirect_uris.clone()).unwrap_or_default(),
60 post_logout_redirect_uris: serde_json::from_value(row.post_logout_redirect_uris.clone())
61 .unwrap_or_default(),
62 backchannel_logout_uris: serde_json::from_value(row.backchannel_logout_uris.clone())
63 .unwrap_or_default(),
64 scopes: serde_json::from_value(row.scopes.clone()).unwrap_or_default(),
65 source: match row.source {
66 ModelSource::Config => ClientSource::Config as i32,
67 ModelSource::Database => ClientSource::Database as i32,
68 },
69 has_secret: row.secret_hash.is_some(),
70 created_at: Some(convert::timestamp_from_datetime(row.created_at)),
71 updated_at: Some(convert::timestamp_from_datetime(row.updated_at)),
72 }
73}
74
75#[tonic::async_trait]
76impl ClientService for ClientApi {
77 async fn list_clients(
78 &self,
79 request: Request<ListClientsRequest>,
80 ) -> Result<Response<ListClientsResponse>, Status> {
81 let meta = context::meta(&request).await?;
82 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.ClientService");
83 let result = self
84 .list_clients_inner(meta.request_id(), request.into_inner())
85 .await;
86 timer.finish("ListClients", &result);
87 result
88 }
89
90 async fn get_client(
91 &self,
92 request: Request<GetClientRequest>,
93 ) -> Result<Response<GetClientResponse>, Status> {
94 let meta = context::meta(&request).await?;
95 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.ClientService");
96 let result = self
97 .get_client_inner(meta.request_id(), request.into_inner())
98 .await;
99 timer.finish("GetClient", &result);
100 result
101 }
102
103 async fn upsert_client(
104 &self,
105 request: Request<UpsertClientRequest>,
106 ) -> Result<Response<UpsertClientResponse>, Status> {
107 let meta = context::meta(&request).await?;
108 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.ClientService");
109 let result = self
110 .upsert_client_inner(meta.request_id(), request.into_inner())
111 .await;
112 timer.finish("UpsertClient", &result);
113 result
114 }
115
116 async fn delete_client(
117 &self,
118 request: Request<DeleteClientRequest>,
119 ) -> Result<Response<DeleteClientResponse>, Status> {
120 let meta = context::meta(&request).await?;
121 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.ClientService");
122 let result = self
123 .delete_client_inner(meta.request_id(), request.into_inner())
124 .await;
125 timer.finish("DeleteClient", &result);
126 result
127 }
128}
129impl ClientApi {
130 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
131 async fn list_clients_inner(
132 &self,
133 request_id: Option<&str>,
134 request: ListClientsRequest,
135 ) -> Result<Response<ListClientsResponse>, Status> {
136 let enabled_only = request.enabled_only;
137 let rows = ClientEntity::find()
138 .all(self.svc.db().read())
139 .await
140 .map_err(|error| error::status(&AuthError::from(error), request_id))?;
141 let clients: Vec<OAuthClientRecord> = rows
142 .iter()
143 .filter(|row| !enabled_only || row.enabled)
144 .map(record_of)
145 .collect();
146 Ok(Response::new(ListClientsResponse { clients }))
147 }
148 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
149 async fn get_client_inner(
150 &self,
151 request_id: Option<&str>,
152 request: GetClientRequest,
153 ) -> Result<Response<GetClientResponse>, Status> {
154 let client_id = request.client_id;
155 let rows = ClientEntity::find()
156 .all(self.svc.db().read())
157 .await
158 .map_err(|error| error::status(&AuthError::from(error), request_id))?;
159 let Some(row) = rows.iter().find(|row| row.client_id == client_id) else {
160 return Err(error::status(&AuthError::AppNotFound, request_id));
161 };
162 Ok(Response::new(GetClientResponse {
163 client: Some(record_of(row)),
164 }))
165 }
166 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
167 async fn upsert_client_inner(
168 &self,
169 request_id: Option<&str>,
170 request: UpsertClientRequest,
171 ) -> Result<Response<UpsertClientResponse>, Status> {
172 let body = request;
173 let kind = kind_of(body.kind)?;
174 let secret = (!body.secret.is_empty()).then_some(body.secret.as_str());
175 let current = self.svc.clients().get(&body.client_id);
176 let secret_hash = current
177 .as_ref()
178 .and_then(|record| record.secret_hash.clone());
179 // enabled 未显式给出:新建按启用、覆盖时保持原状态。
180 let enabled = body.enabled.unwrap_or_else(|| {
181 current
182 .as_ref()
183 .map(|record| record.enabled)
184 .unwrap_or(true)
185 });
186
187 let record = ClientRecord::from_spec(
188 ClientSpec {
189 client_id: &body.client_id,
190 name: &body.name,
191 kind,
192 audience: (!body.audience.is_empty()).then_some(body.audience.as_str()),
193 redirect_uris: body.redirect_uris,
194 post_logout_redirect_uris: body.post_logout_redirect_uris,
195 backchannel_logout_uris: body.backchannel_logout_uris,
196 scopes: body.scopes,
197 // 服务令牌 scope 是配置面概念:管理面写入保留原值,新建置空。
198 service_scopes: current
199 .as_ref()
200 .map(|record| record.service_scopes.clone())
201 .unwrap_or_default(),
202 source: ModelSource::Database,
203 enabled,
204 secret,
205 secret_hash,
206 },
207 self.svc.auth().oauth.allow_wildcard_redirect,
208 )
209 .map_err(|error| error::status(&error, request_id))?;
210
211 self.svc
212 .clients()
213 .upsert(self.svc.db(), &record)
214 .await
215 .map_err(|error| error::status(&error, request_id))?;
216
217 let rows = ClientEntity::find()
218 .all(self.svc.db().read())
219 .await
220 .map_err(|error| error::status(&AuthError::from(error), request_id))?;
221 let row = rows
222 .iter()
223 .find(|row| row.client_id == record.client_id)
224 .map(record_of);
225 Ok(Response::new(UpsertClientResponse {
226 client: row,
227 secret_rotated: secret.is_some(),
228 }))
229 }
230 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
231 async fn delete_client_inner(
232 &self,
233 request_id: Option<&str>,
234 request: DeleteClientRequest,
235 ) -> Result<Response<DeleteClientResponse>, Status> {
236 let client_id = request.client_id;
237 // 停用的客户端不在内存注册表里,删除前先按库里的行判来源。
238 let rows = ClientEntity::find()
239 .all(self.svc.db().read())
240 .await
241 .map_err(|error| error::status(&AuthError::from(error), request_id))?;
242 let Some(row) = rows.iter().find(|row| row.client_id == client_id) else {
243 return Err(error::status(&AuthError::AppNotFound, request_id));
244 };
245 let config_sourced = row.source == ModelSource::Config;
246 self.svc
247 .clients()
248 .delete(self.svc.db(), &client_id)
249 .await
250 .map_err(|error| error::status(&error, request_id))?;
251 Ok(Response::new(DeleteClientResponse {
252 removed: !config_sourced,
253 disabled: config_sourced,
254 }))
255 }
256}
Modified lib/auth/src/server/grpc/context.rs View lib/auth/src/server/grpc/context.rs
@@ -1,7 +1,7 @@
11//! RPC 入口门禁。
22//!
33//! 这些 RPC 只在受信网络里被其它服务调用,不会暴露到公网,所以入口只核对一个
4//! 静态共享密钥:调用方把 `auth.oauth.rpc_key` 的值放进 `x-rpc-key` 头即可。
4//! 静态共享密钥:调用方把 `auth.rpc_key` 的值放进 `x-rpc-key` 头即可。
55//! 不再有调用方服务身份、用户主体(principal)或 scope 判定,具体操作谁由请求
66//! 体里的 `user_id` 决定。
77
Modified lib/auth/src/server/grpc/email.rs View lib/auth/src/server/grpc/email.rs
@@ -35,7 +35,7 @@
3535 EmailServiceServer<Self>,
3636 context::Interceptor,
3737 > {
38 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
38 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3939 EmailServiceServer::with_interceptor(self, gate)
4040 }
4141
Modified lib/auth/src/server/grpc/identity.rs View lib/auth/src/server/grpc/identity.rs
@@ -77,7 +77,7 @@
7777 IdentityServiceServer<Self>,
7878 context::Interceptor,
7979 > {
80 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
80 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
8181 IdentityServiceServer::with_interceptor(self, gate)
8282 }
8383
Modified lib/auth/src/server/grpc/key_rotation.rs View lib/auth/src/server/grpc/key_rotation.rs
@@ -31,7 +31,7 @@
3131 KeyRotationServiceServer<Self>,
3232 context::Interceptor,
3333 > {
34 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
34 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3535 KeyRotationServiceServer::with_interceptor(self, gate)
3636 }
3737
Modified lib/auth/src/server/grpc/mfa.rs View lib/auth/src/server/grpc/mfa.rs
@@ -26,7 +26,7 @@
2626 self,
2727 ) -> tonic::service::interceptor::InterceptedService<MfaServiceServer<Self>, context::Interceptor>
2828 {
29 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
29 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3030 MfaServiceServer::with_interceptor(self, gate)
3131 }
3232
Modified lib/auth/src/server/grpc/mod.rs View lib/auth/src/server/grpc/mod.rs
@@ -4,7 +4,6 @@
44pub mod authority;
55pub mod authorization;
66pub mod cache_maintenance;
7pub mod client;
87pub mod context;
98pub mod convert;
109pub mod email;
@@ -35,7 +34,6 @@
3534use crate::server::grpc::authority::AuthorityApi;
3635use crate::server::grpc::authorization::AuthorizationApi;
3736use crate::server::grpc::cache_maintenance::CacheMaintenanceApi;
38use crate::server::grpc::client::ClientApi;
3937use crate::server::grpc::email::EmailApi;
4038use crate::server::grpc::identity::IdentityApi;
4139use crate::server::grpc::key_rotation::KeyRotationApi;
@@ -61,7 +59,6 @@
6159 "auth.internal.v1.AuditService",
6260 "auth.internal.v1.AuthorityService",
6361 "auth.internal.v1.CacheMaintenanceService",
64 "auth.internal.v1.ClientService",
6562 "auth.internal.v1.EmailService",
6663 "auth.internal.v1.KeyRotationService",
6764 "auth.internal.v1.OrganizationAdminService",
@@ -112,7 +109,6 @@
112109 .add_service(KeyRotationApi::new(svc.clone()).server())
113110 .add_service(AuthorityApi::new(svc.clone()).server())
114111 .add_service(CacheMaintenanceApi::new(svc.clone()).server())
115 .add_service(ClientApi::new(svc.clone()).server())
116112 .add_service(EmailApi::new(svc.clone()).server())
117113 .add_service(AuditApi::new(svc).server()))
118114}
Modified lib/auth/src/server/grpc/organization.rs View lib/auth/src/server/grpc/organization.rs
@@ -255,7 +255,7 @@
255255 OrganizationServiceServer<Self>,
256256 context::Interceptor,
257257 > {
258 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
258 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
259259 OrganizationServiceServer::with_interceptor(self, gate)
260260 }
261261
@@ -2326,7 +2326,7 @@
23262326 context::Interceptor,
23272327 >{
23282328 use crate::protos::auth::internal::v1::organization_admin_service_server::OrganizationAdminServiceServer;
2329 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
2329 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
23302330 OrganizationAdminServiceServer::with_interceptor(self, gate)
23312331 }
23322332
Modified lib/auth/src/server/grpc/session.rs View lib/auth/src/server/grpc/session.rs
@@ -30,7 +30,7 @@
3030 SessionServiceServer<Self>,
3131 context::Interceptor,
3232 > {
33 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
33 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3434 SessionServiceServer::with_interceptor(self, gate)
3535 }
3636}
Modified lib/auth/src/server/grpc/ssh_key.rs View lib/auth/src/server/grpc/ssh_key.rs
@@ -28,7 +28,7 @@
2828 SshKeyServiceServer<Self>,
2929 context::Interceptor,
3030 > {
31 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
31 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
3232 SshKeyServiceServer::with_interceptor(self, gate)
3333 }
3434}
Modified lib/auth/src/server/grpc/token.rs View lib/auth/src/server/grpc/token.rs
@@ -130,7 +130,7 @@
130130 TokenServiceServer<Self>,
131131 context::Interceptor,
132132 > {
133 let gate = context::Interceptor::new(self.svc.auth().oauth.rpc_key.clone());
133 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
134134 TokenServiceServer::with_interceptor(self, gate)
135135 }
136136}
Modified lib/auth/src/server/http/access_token/service.rs View lib/auth/src/server/http/access_token/service.rs
@@ -39,9 +39,9 @@
3939}
4040
4141impl AuthSvc {
42 /// 校验 OAuth authorization_code 或 app-token 签发的用户级 JWT。
42 /// 校验会话或 app-token 签发的用户级 JWT。
4343 ///
44 /// 未验签的 claims 只用于定位已注册的客户端/应用及其 audience;随后仍会校验
44 /// 未验签的 claims 只用于定位已注册的应用及其 audience;随后仍会校验
4545 /// 签名、issuer、audience、有效期、会话状态和 security_version。
4646 pub async fn authenticate_jwt_access_token(
4747 &self,
@@ -60,10 +60,8 @@
6060 {
6161 return Err(AuthError::InvalidAccessToken);
6262 }
63 let registered = self
64 .clients()
65 .get(&unverified.app_id)
66 .is_some_and(|client| client.audience() == unverified.audience)
63 let registered = (unverified.app_id.is_empty()
64 && unverified.audience == crate::jwt::PLATFORM_AUDIENCE)
6765 || self
6866 .apps()
6967 .find(&unverified.app_id)
Modified lib/auth/src/server/http/app_token/handler.rs View lib/auth/src/server/http/app_token/handler.rs
@@ -12,7 +12,7 @@
1212 tag = "auth",
1313 operation_id = "auth_app_token_issue",
1414 summary = "签发内部应用令牌",
15 description = "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。",
15 description = "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。",
1616 request_body = AppTokenRequest,
1717 responses(
1818 (
Modified lib/auth/src/server/http/token/handler.rs View lib/auth/src/server/http/token/handler.rs
@@ -12,7 +12,7 @@
1212 tag = "auth",
1313 operation_id = "auth_token_issue",
1414 summary = "用会话换取平台访问令牌",
15 description = "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。",
15 description = "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。",
1616 request_body = TokenRequest,
1717 responses(
1818 (status = 200, description = "令牌已签发", body = Envelope<TokenResponse>),
Added lib/auth/src/server/http/cors.rs View lib/auth/src/server/http/cors.rs
@@ -0,0 +1,86 @@
1use actix_web::http::header::{self, HeaderValue};
2use actix_web::{HttpRequest, HttpResponse, web};
3
4use crate::config::web::WebConfig;
5use crate::server::AuthSvc;
6
7pub fn cors(
8 svc: &AuthSvc,
9 web_cfg: &WebConfig,
10 req: &HttpRequest,
11 mut response: HttpResponse,
12) -> HttpResponse {
13 let Some(origin) = request_origin(req) else {
14 return response;
15 };
16 let wildcard = web_cfg.allow_any_origin();
17 if !wildcard
18 && !crate::envelope::cors::web_allows(web_cfg, &origin)
19 && !socket_origin(svc, req, &origin)
20 {
21 return response;
22 }
23 let value = if wildcard { String::from("*") } else { origin };
24 let Ok(value) = HeaderValue::from_str(&value) else {
25 return response;
26 };
27 let headers = response.headers_mut();
28 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
29 if !wildcard {
30 headers.append(header::VARY, HeaderValue::from_static("origin"));
31 }
32 if web_cfg.cors_allow_credentials && !wildcard {
33 headers.insert(
34 header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
35 HeaderValue::from_static("true"),
36 );
37 }
38 let expose = web_cfg.expose_headers();
39 if !expose.is_empty()
40 && let Ok(value) = HeaderValue::from_str(&expose.join(", "))
41 {
42 headers.insert(header::ACCESS_CONTROL_EXPOSE_HEADERS, value);
43 }
44 response
45}
46
47fn request_origin(req: &HttpRequest) -> Option<String> {
48 req.headers()
49 .get(header::ORIGIN)
50 .and_then(|value| value.to_str().ok())
51 .map(str::trim)
52 .filter(|origin| !origin.is_empty())
53 .map(str::to_owned)
54}
55
56fn socket_origin(svc: &AuthSvc, req: &HttpRequest, origin: &str) -> bool {
57 req.path() == "/api/v1/oauth/socket-tickets"
58 && svc
59 .auth()
60 .socket_origins
61 .iter()
62 .any(|value| value == origin)
63}
64
65pub async fn preflight(
66 svc: web::Data<AuthSvc>,
67 web_cfg: web::Data<WebConfig>,
68 req: HttpRequest,
69) -> HttpResponse {
70 let web_cfg = web_cfg.get_ref().clone();
71 let response = HttpResponse::NoContent()
72 .insert_header((
73 header::ACCESS_CONTROL_ALLOW_METHODS.as_str(),
74 web_cfg.allow_methods().join(", "),
75 ))
76 .insert_header((
77 header::ACCESS_CONTROL_ALLOW_HEADERS.as_str(),
78 web_cfg.allow_headers().join(", "),
79 ))
80 .insert_header((
81 header::ACCESS_CONTROL_MAX_AGE.as_str(),
82 web_cfg.cors_max_age_secs.to_string(),
83 ))
84 .finish();
85 cors(&svc, &web_cfg, &req, response)
86}
Modified lib/auth/src/server/http/mod.rs View lib/auth/src/server/http/mod.rs
@@ -1,6 +1,7 @@
11pub mod access_token;
22pub mod app_token;
33pub mod challenge;
4pub mod cors;
45pub mod health;
56pub mod login;
67pub mod logout;
Modified lib/auth/src/server/http/openapi.rs View lib/auth/src/server/http/openapi.rs
@@ -27,17 +27,8 @@
2727 crate::server::http::ssh_key::handler::ssh_key_list,
2828 crate::server::http::ssh_key::handler::ssh_key_add,
2929 crate::server::http::ssh_key::handler::ssh_key_delete,
30 crate::server::oauth::protocol::authorize,
31 crate::server::oauth::protocol::token,
32 crate::server::oauth::protocol::revoke,
33 crate::server::oauth::protocol::metadata,
34 crate::server::oauth::protocol::openid_configuration,
3530 crate::server::http::health::health,
3631 crate::server::http::health::ready,
37 crate::server::oauth::userinfo::userinfo,
38 crate::server::oauth::logout::logout,
39 crate::server::oauth::consent::preview,
40 crate::server::oauth::consent::decide,
4132 crate::server::http::organization::handler::create_organization,
4233 crate::server::http::organization::handler::list_organizations,
4334 crate::server::http::organization::handler::get_organization,
@@ -78,14 +69,9 @@
7869 crate::server::http::organization::handler::redeem_invite,
7970 ),
8071 components(schemas(
81 crate::server::oauth::protocol::TokenRequest,
82 crate::server::oauth::protocol::RevokeRequest,
8372 crate::server::http::health::HealthResponse,
8473 crate::server::http::health::ReadinessResponse,
8574 crate::server::http::health::ComponentCheck,
86 crate::server::oauth::consent::ConsentPreview,
87 crate::server::oauth::consent::ConsentDecision,
88 crate::server::oauth::consent::ConsentForm,
8975 crate::server::http::organization::types::OrganizationSummary,
9076 crate::server::http::organization::types::MemberSummary,
9177 crate::server::http::organization::types::RoleSummary,
Modified lib/auth/src/server/http/router.rs View lib/auth/src/server/http/router.rs
@@ -2,18 +2,6 @@
22
33pub fn configure(cfg: &mut web::ServiceConfig) {
44 cfg.route(
5 "/.well-known/openid-configuration",
6 web::get().to(crate::server::oauth::protocol::openid_configuration),
7 );
8 cfg.route(
9 "/.well-known/oauth-authorization-server",
10 web::get().to(crate::server::oauth::protocol::metadata),
11 );
12 cfg.route(
13 "/.well-known/jwks.json",
14 web::get().to(crate::server::keys::jwks),
15 );
16 cfg.route(
175 "/health",
186 web::get().to(crate::server::http::health::health),
197 );
@@ -23,58 +11,14 @@
2311
2412fn api(cfg: &mut web::ServiceConfig) {
2513 cfg.route(
26 "/oauth/authorize",
27 web::get().to(crate::server::oauth::protocol::authorize),
28 );
29 cfg.route(
30 "/oauth/token",
31 web::post().to(crate::server::oauth::protocol::token),
32 );
33 cfg.route(
34 "/oauth/revoke",
35 web::post().to(crate::server::oauth::protocol::revoke),
36 );
37 cfg.route(
38 "/oauth/userinfo",
39 web::get().to(crate::server::oauth::userinfo::userinfo),
40 );
41 cfg.route(
42 "/oauth/userinfo",
43 web::post().to(crate::server::oauth::userinfo::userinfo),
44 );
45 cfg.route(
46 "/oauth/logout",
47 web::get().to(crate::server::oauth::logout::logout),
48 );
49 cfg.route(
50 "/oauth/logout",
51 web::post().to(crate::server::oauth::logout::logout),
52 );
53 cfg.route(
54 "/oauth/consent",
55 web::get().to(crate::server::oauth::consent::preview),
56 );
57 cfg.route(
58 "/oauth/consent",
59 web::post().to(crate::server::oauth::consent::decide),
60 );
61 cfg.route(
6214 "/oauth/socket-tickets",
6315 web::post().to(crate::server::http::socket_ticket::issue),
6416 );
6517 cfg.route(
6618 "/oauth/socket-tickets",
67 web::method(actix_web::http::Method::OPTIONS).to(crate::server::oauth::protocol::preflight),
68 );
69 cfg.route(
70 "/oauth/token",
71 web::method(actix_web::http::Method::OPTIONS).to(crate::server::oauth::protocol::preflight),
19 web::method(actix_web::http::Method::OPTIONS).to(crate::server::http::cors::preflight),
7220 );
7321 cfg.route(
74 "/oauth/revoke",
75 web::method(actix_web::http::Method::OPTIONS).to(crate::server::oauth::protocol::preflight),
76 );
77 cfg.route(
7822 "/oauth/challenge",
7923 web::post().to(crate::server::http::challenge::handler::challenge),
8024 );
@@ -142,7 +86,6 @@
14286 "/oauth/session/token",
14387 web::post().to(crate::server::http::token::handler::token_issue),
14488 );
145 cfg.route("/oauth/jwks.json", web::get().to(crate::server::keys::jwks));
14689 cfg.route(
14790 "/oauth/ssh-keys",
14891 web::get().to(crate::server::http::ssh_key::handler::ssh_key_list),
Modified lib/auth/src/server/http/socket_ticket.rs View lib/auth/src/server/http/socket_ticket.rs
@@ -36,7 +36,7 @@
3636 let response = issue_inner(&svc, session, &req, &body, Some(request_id.as_str()))
3737 .await
3838 .unwrap_or_else(|error| error.error_response());
39 crate::server::oauth::protocol::cors(&svc, &web_cfg, &req, response)
39 crate::server::http::cors::cors(&svc, &web_cfg, &req, response)
4040}
4141
4242#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
@@ -47,7 +47,7 @@
4747 body: &TicketRequest,
4848 request_id: Option<&str>,
4949) -> Result<HttpResponse, AuthError> {
50 if !svc.auth().oauth.socket_origins.contains(&body.origin) {
50 if !svc.auth().socket_origins.contains(&body.origin) {
5151 return Err(AuthError::Forbidden);
5252 }
5353 let origin = req
@@ -141,7 +141,6 @@
141141 if ticket.len() != 43
142142 || !self
143143 .auth()
144 .oauth
145144 .socket_origins
146145 .iter()
147146 .any(|allowed| allowed == origin)
Deleted lib/auth/src/server/oauth/client/mod.rs
@@ -1,8 +0,0 @@
1//! OAuth 客户端注册表。
2//!
3//! 真相源是 `oauth_clients` 表:`auth.oauth.clients` 里的配置在启动时作为种子 upsert 进去
4//! (`source = config`),管理接口写入的行是 `source = database`,两者走同一套校验与查询。
5
6pub mod registry;
7
8pub use registry::{ClientRecord, ClientRegistry, ClientSpec, MIN_SECRET_LEN};
Deleted lib/auth/src/server/oauth/client/registry.rs
@@ -1,529 +0,0 @@
1//! 客户端记录的校验、落库与查询。所有 redirect_uri / scope 判定都集中在这里。
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use reqwest::Url;
7use sea_orm::sea_query::OnConflict;
8use sea_orm::{ActiveModelTrait, EntityTrait, Set};
9use serde_json::{Value, json};
10
11use crate::config::auth::{OAuthClient, OAuthConfig};
12use crate::db::DbConn;
13use crate::models::users::oauth_clients::{
14 ActiveModel as ClientActiveModel, ClientKind, ClientSource, Column, Entity as ClientEntity,
15 Model as ClientModel,
16};
17use crate::server::errors::{AuthError, AuthResult};
18use crate::server::share::crypto;
19
20/// 机密客户端 secret 的最小长度(high-entropy 才能用 sha256 存储)。
21pub const MIN_SECRET_LEN: usize = 32;
22
23/// 运行时可用的客户端快照。
24#[derive(Clone, Debug)]
25pub struct ClientRecord {
26 pub client_id: String,
27 pub name: String,
28 pub kind: ClientKind,
29 pub secret_hash: Option<String>,
30 pub audience: Option<String>,
31 pub redirect_uris: Vec<String>,
32 pub post_logout_redirect_uris: Vec<String>,
33 pub backchannel_logout_uris: Vec<String>,
34 pub scopes: Vec<String>,
35 /// client_credentials 服务令牌的 scope 天花板与缺省集(`scopes` 的子集)。
36 pub service_scopes: Vec<String>,
37 pub source: ClientSource,
38 /// 停用的客户端保留在表里,但不参与授权与令牌交换。
39 pub enabled: bool,
40}
41
42impl ClientRecord {
43 pub fn is_confidential(&self) -> bool {
44 self.kind == ClientKind::Confidential
45 }
46
47 /// 令牌受众:客户端显式配置优先,否则回落平台受众。
48 pub fn audience(&self) -> &str {
49 self.audience
50 .as_deref()
51 .unwrap_or(crate::jwt::PLATFORM_AUDIENCE)
52 }
53
54 pub fn redirect_allowed(&self, requested: &str, wildcard: bool) -> bool {
55 self.redirect_uris
56 .iter()
57 .any(|pattern| matches_uri(pattern, requested, wildcard))
58 }
59
60 pub fn post_logout_allowed(&self, requested: &str, wildcard: bool) -> bool {
61 self.post_logout_redirect_uris
62 .iter()
63 .any(|pattern| matches_uri(pattern, requested, wildcard))
64 }
65
66 /// 客户端是否被允许申请该 scope。
67 pub fn scope_allowed(&self, scope: &str) -> bool {
68 self.scopes.iter().any(|allowed| allowed == scope)
69 }
70
71 /// 请求的 scope 集合必须全部在客户端允许集合里。
72 pub fn scopes_allowed<'a>(&self, requested: impl IntoIterator<Item = &'a str>) -> bool {
73 requested.into_iter().all(|scope| self.scope_allowed(scope))
74 }
75
76 /// 机密客户端 secret 校验:`sha256(secret)` 定长比对。
77 pub fn verify_secret(&self, provided: &str) -> bool {
78 let Some(expected) = self.secret_hash.as_deref() else {
79 return false;
80 };
81 crypto::constant_time_eq(
82 crypto::sha256_hex(provided.as_bytes()).as_bytes(),
83 expected.as_bytes(),
84 )
85 }
86
87 fn from_config(seed: &OAuthClient, allow_wildcard: bool) -> AuthResult<Self> {
88 let record = Self::from_spec(
89 ClientSpec {
90 client_id: &seed.client_id,
91 name: &seed.name,
92 kind: seed.kind,
93 audience: seed.audience.as_deref(),
94 redirect_uris: seed.redirect_uris.clone(),
95 post_logout_redirect_uris: seed.post_logout_redirect_uris.clone(),
96 backchannel_logout_uris: seed.backchannel_logout_uris.clone(),
97 scopes: seed.scopes.clone(),
98 service_scopes: seed.service_scopes.clone(),
99 source: ClientSource::Config,
100 enabled: seed.enabled,
101 secret: seed.secret.as_deref(),
102 secret_hash: None,
103 },
104 allow_wildcard,
105 )?;
106 Ok(record)
107 }
108
109 /// 统一入口:配置种子和管理面写入都走这里,secret 在这里就变成哈希。
110 pub fn from_spec(spec: ClientSpec<'_>, allow_wildcard: bool) -> AuthResult<Self> {
111 let secret_hash = match (spec.kind, spec.secret) {
112 (ClientKind::Confidential, Some(secret)) => {
113 if secret.len() < MIN_SECRET_LEN {
114 return Err(AuthError::MalformedRequest);
115 }
116 Some(crypto::sha256_hex(secret.as_bytes()))
117 }
118 (ClientKind::Confidential, None) => spec.secret_hash.clone(),
119 (ClientKind::Public, _) => None,
120 };
121 let record = Self {
122 client_id: spec.client_id.to_owned(),
123 name: spec.name.to_owned(),
124 kind: spec.kind,
125 secret_hash,
126 audience: spec.audience.map(str::to_owned),
127 redirect_uris: spec.redirect_uris,
128 post_logout_redirect_uris: spec.post_logout_redirect_uris,
129 backchannel_logout_uris: spec.backchannel_logout_uris,
130 scopes: spec.scopes,
131 service_scopes: spec.service_scopes,
132 source: spec.source,
133 enabled: spec.enabled,
134 };
135 record.validate(allow_wildcard)?;
136 Ok(record)
137 }
138
139 /// 统一校验:id 字符集、redirect 形态、logout 端点、scope 白名单。
140 pub fn validate(&self, allow_wildcard: bool) -> AuthResult<()> {
141 validate_client_id(&self.client_id)?;
142 if self.name.trim().is_empty() {
143 return Err(AuthError::MalformedRequest);
144 }
145 if self.is_confidential() && self.secret_hash.is_none() {
146 return Err(AuthError::MalformedRequest);
147 }
148 if self.audience.as_deref().is_some_and(str::is_empty) {
149 return Err(AuthError::MalformedRequest);
150 }
151 for uri in &self.redirect_uris {
152 validate_uri(uri, allow_wildcard)?;
153 }
154 for uri in &self.post_logout_redirect_uris {
155 validate_uri(uri, allow_wildcard)?;
156 }
157 for uri in &self.backchannel_logout_uris {
158 validate_strict_uri(uri)?;
159 }
160 for scope in &self.scopes {
161 if !crate::jwt::scope::is_known(scope) {
162 return Err(AuthError::InvalidScope);
163 }
164 }
165 for scope in &self.service_scopes {
166 if !self.scopes.iter().any(|allowed| allowed == scope) {
167 return Err(AuthError::InvalidScope);
168 }
169 }
170 Ok(())
171 }
172
173 fn values(&self) -> ClientValues {
174 ClientValues {
175 client_id: self.client_id.clone(),
176 name: self.name.clone(),
177 kind: self.kind,
178 source: self.source,
179 secret_hash: self.secret_hash.clone(),
180 audience: self.audience.clone(),
181 redirect_uris: self.redirect_uris.clone(),
182 post_logout_redirect_uris: self.post_logout_redirect_uris.clone(),
183 backchannel_logout_uris: self.backchannel_logout_uris.clone(),
184 scopes: self.scopes.clone(),
185 service_scopes: self.service_scopes.clone(),
186 enabled: self.enabled,
187 }
188 }
189
190 /// 从记录构建 ActiveModel;`existing_id` 存在时作为更新目标的主键,
191 /// 否则留空由 before_save 生成 UUIDv7。upsert 与 sync_seeds 共用,
192 /// 消除 14 字段的重复拷贝。
193 fn active_model(&self, existing_id: Option<uuid::Uuid>) -> ClientActiveModel {
194 let values = self.values();
195 let mut model = ClientActiveModel {
196 client_id: Set(values.client_id),
197 name: Set(values.name),
198 kind: Set(values.kind),
199 source: Set(values.source),
200 enabled: Set(values.enabled),
201 secret_hash: Set(values.secret_hash),
202 audience: Set(values.audience),
203 redirect_uris: Set(json!(values.redirect_uris)),
204 post_logout_redirect_uris: Set(json!(values.post_logout_redirect_uris)),
205 backchannel_logout_uris: Set(json!(values.backchannel_logout_uris)),
206 scopes: Set(json!(values.scopes)),
207 service_scopes: Set(Some(json!(values.service_scopes))),
208 ..Default::default()
209 };
210 if let Some(id) = existing_id {
211 model.id = Set(id);
212 }
213 model
214 }
215
216 fn from_model(row: &ClientModel) -> Self {
217 Self {
218 client_id: row.client_id.clone(),
219 name: row.name.clone(),
220 kind: row.kind,
221 secret_hash: row.secret_hash.clone(),
222 audience: row.audience.clone(),
223 redirect_uris: strings(&row.redirect_uris),
224 post_logout_redirect_uris: strings(&row.post_logout_redirect_uris),
225 backchannel_logout_uris: strings(&row.backchannel_logout_uris),
226 scopes: strings(&row.scopes),
227 service_scopes: row.service_scopes.as_ref().map(strings).unwrap_or_default(),
228 source: row.source,
229 enabled: row.enabled,
230 }
231 }
232}
233
234struct ClientValues {
235 client_id: String,
236 name: String,
237 kind: ClientKind,
238 source: ClientSource,
239 secret_hash: Option<String>,
240 audience: Option<String>,
241 redirect_uris: Vec<String>,
242 post_logout_redirect_uris: Vec<String>,
243 backchannel_logout_uris: Vec<String>,
244 scopes: Vec<String>,
245 service_scopes: Vec<String>,
246 enabled: bool,
247}
248
249/// 构建客户端记录的入参:配置种子与管理面写入共用。
250pub struct ClientSpec<'a> {
251 pub client_id: &'a str,
252 pub name: &'a str,
253 pub kind: ClientKind,
254 pub audience: Option<&'a str>,
255 pub redirect_uris: Vec<String>,
256 pub post_logout_redirect_uris: Vec<String>,
257 pub backchannel_logout_uris: Vec<String>,
258 pub scopes: Vec<String>,
259 pub service_scopes: Vec<String>,
260 pub source: ClientSource,
261 pub enabled: bool,
262 /// 明文 secret:只在新建或轮换时提供。
263 pub secret: Option<&'a str>,
264 /// 已有哈希:保留旧 secret 时使用。
265 pub secret_hash: Option<String>,
266}
267
268/// 进程内客户端快照:启动时加载,管理面写入后 `reload`。
269#[derive(Debug, Default)]
270pub struct ClientRegistry {
271 clients: RwLock<HashMap<String, Arc<ClientRecord>>>,
272}
273
274impl ClientRegistry {
275 /// 落库配置种子后再加载全部启用客户端。
276 pub async fn load(db: &DbConn, config: &OAuthConfig) -> AuthResult<Self> {
277 let mut seeds = Vec::with_capacity(config.clients.len());
278 for seed in &config.clients {
279 seeds.push(ClientRecord::from_config(
280 seed,
281 config.allow_wildcard_redirect,
282 )?);
283 }
284 sync_seeds(db, &seeds).await?;
285
286 let registry = Self::default();
287 registry.reload(db).await?;
288 Ok(registry)
289 }
290
291 pub async fn reload(&self, db: &DbConn) -> AuthResult<()> {
292 let rows = ClientEntity::find().all(db.write()).await?;
293 let mut next = HashMap::with_capacity(rows.len());
294 for row in rows.iter().filter(|row| row.enabled) {
295 let record = ClientRecord::from_model(row);
296 next.insert(record.client_id.clone(), Arc::new(record));
297 }
298 let mut guard = self
299 .clients
300 .write()
301 .map_err(|_| AuthError::Unavailable("client registry poisoned".into()))?;
302 *guard = next;
303 Ok(())
304 }
305
306 pub fn get(&self, client_id: &str) -> Option<Arc<ClientRecord>> {
307 self.clients.read().ok()?.get(client_id).cloned()
308 }
309
310 pub fn list(&self) -> Vec<Arc<ClientRecord>> {
311 let Ok(guard) = self.clients.read() else {
312 return Vec::new();
313 };
314 let mut all: Vec<_> = guard.values().cloned().collect();
315 all.sort_by(|a, b| a.client_id.cmp(&b.client_id));
316 all
317 }
318
319 pub async fn upsert(&self, db: &DbConn, record: &ClientRecord) -> AuthResult<()> {
320 record.validate(true)?;
321 let conn = db.write();
322 // 单次 upsert 完成插入或覆盖:先查后写的写法在并发时会撞 client_id 唯一约束。
323 ClientEntity::insert(record.active_model(None))
324 .on_conflict(
325 OnConflict::column(Column::ClientId)
326 .update_columns([
327 Column::Name,
328 Column::Kind,
329 Column::Source,
330 Column::Enabled,
331 Column::SecretHash,
332 Column::Audience,
333 Column::RedirectUris,
334 Column::PostLogoutRedirectUris,
335 Column::BackchannelLogoutUris,
336 Column::Scopes,
337 Column::ServiceScopes,
338 ])
339 .to_owned(),
340 )
341 .exec(conn)
342 .await?;
343 self.reload(db).await
344 }
345
346 pub async fn delete(&self, db: &DbConn, client_id: &str) -> AuthResult<bool> {
347 let conn = db.write();
348 let existing = ClientEntity::find().all(conn).await?;
349 let Some(row) = existing.iter().find(|row| row.client_id == client_id) else {
350 return Ok(false);
351 };
352 if row.source == ClientSource::Config {
353 // 配置种子由 config 管理,删除会在下次启动时回来:只能停用。
354 ClientActiveModel {
355 id: Set(row.id),
356 enabled: Set(false),
357 ..Default::default()
358 }
359 .update(conn)
360 .await?;
361 } else {
362 ClientActiveModel {
363 id: Set(row.id),
364 ..Default::default()
365 }
366 .delete(conn)
367 .await?;
368 }
369 self.reload(db).await?;
370 Ok(true)
371 }
372}
373
374/// 把配置种子写进表,并停用已经消失的 config 来源客户端。
375async fn sync_seeds(db: &DbConn, seeds: &[ClientRecord]) -> AuthResult<()> {
376 let conn = db.write();
377 let existing = ClientEntity::find().all(conn).await?;
378
379 for seed in seeds {
380 match existing.iter().find(|row| row.client_id == seed.client_id) {
381 Some(row) => {
382 seed.active_model(Some(row.id)).update(conn).await?;
383 }
384 None => {
385 seed.active_model(None).insert(conn).await?;
386 }
387 }
388 }
389
390 for row in &existing {
391 let still_configured = seeds.iter().any(|seed| seed.client_id == row.client_id);
392 if row.source == ClientSource::Config && !still_configured && row.enabled {
393 ClientActiveModel {
394 id: Set(row.id),
395 enabled: Set(false),
396 ..Default::default()
397 }
398 .update(conn)
399 .await?;
400 }
401 }
402 Ok(())
403}
404
405fn strings(value: &Value) -> Vec<String> {
406 serde_json::from_value(value.clone()).unwrap_or_default()
407}
408
409/// client_id 字符集:`[A-Za-z0-9-_.]`,长度 1..=128。
410pub fn validate_client_id(client_id: &str) -> AuthResult<()> {
411 let valid = !client_id.is_empty()
412 && client_id.len() <= 128
413 && client_id
414 .chars()
415 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
416 if valid {
417 Ok(())
418 } else {
419 Err(AuthError::MalformedRequest)
420 }
421}
422
423/// redirect_uri 可以是 https、loopback http,或(开启通配时)`https://*.host/path`。
424pub fn validate_uri(raw: &str, allow_wildcard: bool) -> AuthResult<()> {
425 if let Some((scheme, rest)) = raw.split_once("://")
426 && scheme == "https"
427 && rest.starts_with("*.")
428 {
429 if !allow_wildcard {
430 return Err(AuthError::MalformedRequest);
431 }
432 let host = rest.split('/').next().unwrap_or_default();
433 let tail = &rest[host.len()..];
434 if host.len() <= 2 || host.contains(':') || tail.is_empty() || tail.contains('#') {
435 return Err(AuthError::MalformedRequest);
436 }
437 // 公共后缀域名(*.github.io 等)下任意子域持有者都能收到他人授权码,
438 // 拒绝注册;私有域请逐个登记子域。
439 const PUBLIC_SUFFIXES: [&str; 8] = [
440 ".github.io",
441 ".gitlab.io",
442 ".pages.dev",
443 ".netlify.app",
444 ".vercel.app",
445 ".web.app",
446 ".firebaseapp.com",
447 ".herokuapp.com",
448 ];
449 if PUBLIC_SUFFIXES.iter().any(|suffix| host.ends_with(suffix)) {
450 return Err(AuthError::MalformedRequest);
451 }
452 return validate_query(tail);
453 }
454 let url = endpoint(raw).ok_or(AuthError::MalformedRequest)?;
455 // RFC 6749 §3.1.2:redirect_uri 不得携带 fragment。
456 if url.fragment().is_some() {
457 return Err(AuthError::MalformedRequest);
458 }
459 validate_query(url.query().unwrap_or_default())
460}
461
462/// back-channel logout 端点必须是绝对 https(或 loopback),且不接受通配。
463fn validate_strict_uri(raw: &str) -> AuthResult<()> {
464 let url = endpoint(raw).ok_or(AuthError::MalformedRequest)?;
465 if url.host_str().is_none() {
466 return Err(AuthError::MalformedRequest);
467 }
468 Ok(())
469}
470
471fn validate_query(query: &str) -> AuthResult<()> {
472 for pair in query.split('&').filter(|pair| !pair.is_empty()) {
473 let key = pair.split('=').next().unwrap_or_default();
474 if matches!(key, "code" | "state" | "iss" | "error") {
475 return Err(AuthError::MalformedRequest);
476 }
477 }
478 Ok(())
479}
480
481/// 单个 redirect_uri 的合法性:https,或 loopback 上的 http。
482pub fn endpoint(raw: &str) -> Option<Url> {
483 let url = Url::parse(raw).ok()?;
484 match url.scheme() {
485 "https" => Some(url),
486 "http" if url.host_str().is_some_and(is_loopback) => Some(url),
487 _ => None,
488 }
489}
490
491pub fn is_loopback(host: &str) -> bool {
492 crate::server::share::net::is_loopback_host(host)
493}
494
495/// 请求的 URI 是否匹配注册的模式;`wildcard=false` 时退化为精确串比较。
496fn matches_uri(pattern: &str, requested: &str, wildcard: bool) -> bool {
497 if pattern == requested {
498 return true;
499 }
500 if !wildcard {
501 return false;
502 }
503 let Some((scheme, rest)) = pattern.split_once("://") else {
504 return false;
505 };
506 if scheme != "https" || !rest.starts_with("*.") {
507 return false;
508 }
509 let Some((requested_scheme, requested_rest)) = requested.split_once("://") else {
510 return false;
511 };
512 if requested_scheme != "https" {
513 return false;
514 }
515
516 let pattern_host = rest.split('/').next().unwrap_or_default();
517 let suffix = &pattern_host[1..];
518 let pattern_tail = &rest[pattern_host.len()..];
519
520 let requested_host = requested_rest.split('/').next().unwrap_or_default();
521 if requested_host.contains(':') {
522 return false;
523 }
524 let requested_tail = &requested_rest[requested_host.len()..];
525
526 requested_host.len() > suffix.len()
527 && requested_host.ends_with(suffix)
528 && requested_tail == pattern_tail
529}
Deleted lib/auth/src/server/oauth/consent.rs
@@ -1,349 +0,0 @@
1//! 授权同意:按 `(user_id, client_id)` 记一行,scopes 是已授权的并集。
2
3use actix_web::{HttpResponse, web};
4use chrono::{DateTime, Utc};
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, Set,
7 Statement, TransactionTrait,
8};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12use crate::models::users::oauth_consents as consents;
13use crate::server::AuthSvc;
14use crate::server::errors::AuthResult;
15
16async fn load(svc: &AuthSvc, user_id: i64, client_id: &str) -> AuthResult<Option<consents::Model>> {
17 Ok(consents::Entity::find()
18 .filter(consents::Column::UserId.eq(user_id))
19 .filter(consents::Column::ClientId.eq(client_id))
20 .one(svc.db().read())
21 .await?)
22}
23
24/// 请求的 scope 是否都已被同意(且没有被撤销)。
25#[tracing::instrument(skip_all, fields(user_id = user_id, client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
26pub async fn granted(
27 svc: &AuthSvc,
28 user_id: i64,
29 client_id: &str,
30 scopes: &[String],
31 request_id: Option<&str>,
32) -> AuthResult<bool> {
33 let Some(row) = load(svc, user_id, client_id).await? else {
34 return Ok(false);
35 };
36 if row.revoked_at.is_some() {
37 return Ok(false);
38 }
39 let granted: Vec<String> = serde_json::from_value(row.scopes).unwrap_or_default();
40 Ok(scopes.iter().all(|scope| granted.contains(scope)))
41}
42
43/// 同一 `(user_id, client_id)` 的同意写入靠事务级咨询锁串行化:
44/// 表上只有普通索引,先查后写并发时会写出重复行,而读取侧只取其中一行。
45async fn lock_pair(
46 txn: &sea_orm::DatabaseTransaction,
47 user_id: i64,
48 client_id: &str,
49) -> AuthResult<()> {
50 let key = format!("oauth_consent:{user_id}:{client_id}");
51 txn.query_one_raw(Statement::from_sql_and_values(
52 DbBackend::Postgres,
53 "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
54 [key.into()],
55 ))
56 .await?;
57 Ok(())
58}
59
60/// 记录同意:已有行就把新增 scope 并进去,并清掉撤销标记。
61/// 撤销后重新同意只代表本次请求的 scope 重新生效,历史授权不能随撤销复活。
62#[tracing::instrument(skip_all, fields(user_id = user_id, client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
63pub async fn grant(
64 svc: &AuthSvc,
65 user_id: i64,
66 client_id: &str,
67 scopes: &[String],
68 request_id: Option<&str>,
69) -> AuthResult<()> {
70 let txn = svc.db().write().begin().await?;
71 lock_pair(&txn, user_id, client_id).await?;
72 let existing = consents::Entity::find()
73 .filter(consents::Column::UserId.eq(user_id))
74 .filter(consents::Column::ClientId.eq(client_id))
75 .one(&txn)
76 .await?;
77 match existing {
78 Some(row) => {
79 let revoked = row.revoked_at.is_some();
80 let mut merged: Vec<String> = if revoked {
81 Vec::new()
82 } else {
83 serde_json::from_value(row.scopes.clone()).unwrap_or_default()
84 };
85 for scope in scopes {
86 if !merged.contains(scope) {
87 merged.push(scope.clone());
88 }
89 }
90 consents::ActiveModel {
91 id: Set(row.id),
92 scopes: Set(serde_json::json!(merged)),
93 revoked_at: Set(None),
94 ..Default::default()
95 }
96 .update(&txn)
97 .await?;
98 }
99 None => {
100 consents::ActiveModel {
101 user_id: Set(user_id),
102 client_id: Set(client_id.to_owned()),
103 scopes: Set(serde_json::json!(scopes)),
104 ..Default::default()
105 }
106 .insert(&txn)
107 .await?;
108 }
109 }
110 txn.commit().await?;
111 Ok(())
112}
113
114#[tracing::instrument(skip_all, fields(user_id = user_id, client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
115pub async fn revoke(
116 svc: &AuthSvc,
117 user_id: i64,
118 client_id: &str,
119 request_id: Option<&str>,
120) -> AuthResult<()> {
121 consents::Entity::update_many()
122 .col_expr(
123 consents::Column::RevokedAt,
124 sea_orm::sea_query::Expr::value(Utc::now()),
125 )
126 .filter(consents::Column::UserId.eq(user_id))
127 .filter(consents::Column::ClientId.eq(client_id))
128 .filter(consents::Column::RevokedAt.is_null())
129 .exec(svc.db().write())
130 .await?;
131 Ok(())
132}
133
134/// 一条已授权记录:`(client_id, scopes, granted_at)`。
135pub type GrantedConsent = (String, Vec<String>, DateTime<Utc>);
136
137/// 用户已授权的应用列表(供账号页展示)。
138#[tracing::instrument(skip_all, fields(user_id = user_id, request_id = %request_id.unwrap_or_default()))]
139pub async fn list(
140 svc: &AuthSvc,
141 user_id: i64,
142 request_id: Option<&str>,
143) -> AuthResult<Vec<GrantedConsent>> {
144 let rows = consents::Entity::find()
145 .filter(consents::Column::UserId.eq(user_id))
146 .filter(consents::Column::RevokedAt.is_null())
147 .all(svc.db().read())
148 .await?;
149 Ok(rows
150 .into_iter()
151 .map(|row| {
152 let scopes: Vec<String> = serde_json::from_value(row.scopes).unwrap_or_default();
153 (row.client_id, scopes, row.granted_at)
154 })
155 .collect())
156}
157
158// ---------------------------------------------------------------------------
159// HTTP 处理:`GET /api/v1/oauth/consent`(预览)与 `POST /api/v1/oauth/consent`(决定)
160// ---------------------------------------------------------------------------
161
162#[derive(Debug, Serialize, ToSchema)]
163pub struct ConsentPreview {
164 pub client_id: String,
165 pub client_name: String,
166 pub scopes: Vec<String>,
167 pub username: String,
168}
169
170#[derive(Debug, Serialize, ToSchema)]
171pub struct ConsentDecision {
172 /// 下一步跳转:允许则回 `/api/v1/oauth/authorize?...`,拒绝则回客户端并带 `error=access_denied`。
173 pub redirect: String,
174}
175
176/// 同意页提交体:授权参数在查询串里,请求体只带决定。
177#[derive(Debug, Deserialize, ToSchema)]
178pub struct ConsentForm {
179 pub decision: String,
180}
181
182fn bad_request(error: &'static str) -> HttpResponse {
183 HttpResponse::BadRequest()
184 .insert_header(("cache-control", "no-store"))
185 .json(serde_json::json!({ "error": error }))
186}
187
188fn unauthenticated() -> HttpResponse {
189 HttpResponse::Unauthorized()
190 .insert_header(("cache-control", "no-store"))
191 .json(serde_json::json!({ "error": "login_required" }))
192}
193
194#[utoipa::path(
195 get,
196 path = "/api/v1/oauth/consent",
197 tag = "oauth",
198 operation_id = "oauth_consent_preview",
199 summary = "同意页所需的客户端与 scope 预览",
200 responses(
201 (status = 200, description = "客户端名称与待授权 scope", body = ConsentPreview),
202 (status = 400, description = "`invalid_request`"),
203 (status = 401, description = "`login_required`")
204 )
205)]
206#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
207pub async fn preview(
208 svc: web::Data<AuthSvc>,
209 session: crate::session::Session,
210 request_id: crate::session::RequestId,
211 query: Result<web::Query<crate::server::oauth::protocol::AuthorizeRequest>, actix_web::Error>,
212) -> HttpResponse {
213 let Ok(query) = query else {
214 return bad_request("invalid_request");
215 };
216 let Some(record) = svc.clients().get(&query.client_id) else {
217 return bad_request("invalid_request");
218 };
219 if !record.redirect_allowed(
220 &query.redirect_uri,
221 svc.auth().oauth.allow_wildcard_redirect,
222 ) {
223 return bad_request("invalid_request");
224 }
225 let user_id = match svc
226 .require_session(&session, Some(request_id.as_str()))
227 .await
228 {
229 Ok(user_id) => user_id,
230 Err(_) => return unauthenticated(),
231 };
232 let user = match crate::jwt::authority::user(user_id).await {
233 Ok(user) => user,
234 Err(_) => return unauthenticated(),
235 };
236 let scopes = query
237 .scopes()
238 .into_iter()
239 .filter(|scope| record.scope_allowed(scope))
240 .collect::<Vec<_>>();
241
242 HttpResponse::Ok()
243 .insert_header(("cache-control", "no-store"))
244 .json(ConsentPreview {
245 client_id: record.client_id.clone(),
246 client_name: record.name.clone(),
247 scopes,
248 username: user.displayname.unwrap_or(user.username),
249 })
250}
251
252#[utoipa::path(
253 post,
254 path = "/api/v1/oauth/consent",
255 tag = "oauth",
256 operation_id = "oauth_consent_decide",
257 summary = "提交同意决定",
258 description = "授权参数从查询串读取;`decision=allow` 落库并回 `/api/v1/oauth/authorize?...`,`deny` 回客户端并带 `error=access_denied`。",
259 request_body = ConsentForm,
260 responses(
261 (status = 200, description = "下一步地址", body = ConsentDecision),
262 (status = 400, description = "`invalid_request`"),
263 (status = 401, description = "`login_required`")
264 )
265)]
266#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
267pub async fn decide(
268 svc: web::Data<AuthSvc>,
269 session: crate::session::Session,
270 req: actix_web::HttpRequest,
271 request_id: crate::session::RequestId,
272 query: Result<web::Query<crate::server::oauth::protocol::AuthorizeRequest>, actix_web::Error>,
273 form: Result<web::Form<ConsentForm>, actix_web::Error>,
274) -> HttpResponse {
275 let (Ok(query), Ok(form)) = (query, form) else {
276 return bad_request("invalid_request");
277 };
278 let query = &query.into_inner();
279 let Some(record) = svc.clients().get(&query.client_id) else {
280 return bad_request("invalid_request");
281 };
282 if !record.redirect_allowed(
283 &query.redirect_uri,
284 svc.auth().oauth.allow_wildcard_redirect,
285 ) {
286 return bad_request("invalid_request");
287 }
288 let user_id = match svc
289 .require_session(&session, Some(request_id.as_str()))
290 .await
291 {
292 Ok(user_id) => user_id,
293 Err(_) => return unauthenticated(),
294 };
295
296 let allowed_scopes = query
297 .scopes()
298 .into_iter()
299 .filter(|scope| record.scope_allowed(scope))
300 .collect::<Vec<_>>();
301
302 let redirect = match form.decision.as_str() {
303 "allow" => {
304 if let Err(error) = grant(
305 &svc,
306 user_id,
307 &record.client_id,
308 &allowed_scopes,
309 Some(request_id.as_str()),
310 )
311 .await
312 {
313 tracing::warn!(%error, "failed to persist oauth consent");
314 return bad_request("server_error");
315 }
316 // 回放原始请求继续授权,但必须剔除 `prompt`:否则同意页会无限重入。
317 let forwarded = req
318 .query_string()
319 .split('&')
320 .filter(|pair| !pair.is_empty() && !pair.starts_with("prompt="))
321 .collect::<Vec<_>>()
322 .join("&");
323 if forwarded.is_empty() {
324 return bad_request("invalid_request");
325 }
326 format!("/api/v1/oauth/authorize?{forwarded}")
327 }
328 "deny" => {
329 // 拒绝不推翻历史上已授权的 scope,只让本次授权带上 access_denied 回客户端。
330 match reqwest::Url::parse(&query.redirect_uri) {
331 Ok(mut url) => {
332 url.query_pairs_mut().append_pair("error", "access_denied");
333 if let Some(state) = query.state.as_deref().filter(|state| !state.is_empty()) {
334 url.query_pairs_mut().append_pair("state", state);
335 }
336 url.query_pairs_mut()
337 .append_pair("iss", &svc.auth().oauth.public_url);
338 url.to_string()
339 }
340 Err(_) => return bad_request("invalid_request"),
341 }
342 }
343 _ => return bad_request("invalid_request"),
344 };
345
346 HttpResponse::Ok()
347 .insert_header(("cache-control", "no-store"))
348 .json(ConsentDecision { redirect })
349}
Deleted lib/auth/src/server/oauth/id_token.rs
@@ -1,153 +0,0 @@
1//! id_token 签发。claims 按请求的 scope 裁剪:`profile` 带用户名与显示名,`email` 带邮箱。
2
3use base64::Engine;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use serde::Serialize;
6use sha2::{Digest, Sha512};
7
8use crate::server::{AuthResult, AuthSvc};
9
10/// EdDSA 底层是 SHA-512:`at_hash` 取哈希左半再 base64url 无填充(OIDC Core 3.1.3.6)。
11pub fn at_hash(access_token: &str) -> String {
12 let digest = Sha512::digest(access_token.as_bytes());
13 URL_SAFE_NO_PAD.encode(&digest[..digest.len() / 2])
14}
15
16#[derive(Debug, Serialize)]
17pub struct IdTokenClaims {
18 pub iss: String,
19 pub sub: String,
20 pub aud: String,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub azp: Option<String>,
23 pub iat: i64,
24 pub exp: i64,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub auth_time: Option<i64>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub nonce: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub at_hash: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub sid: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub preferred_username: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub name: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub email: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub email_verified: Option<bool>,
41}
42
43pub struct IdTokenInput<'a> {
44 pub client_id: &'a str,
45 pub user_id: i64,
46 pub username: &'a str,
47 pub displayname: Option<&'a str>,
48 pub email: &'a str,
49 pub email_verified: bool,
50 pub scopes: &'a [String],
51 pub access_token: &'a str,
52 pub session_id: Option<&'a str>,
53 /// 会话建立时间(秒),用于 `max_age` 与 RP 侧 `auth_time` 校验。
54 pub auth_time: i64,
55 pub nonce: Option<&'a str>,
56}
57
58pub fn build(svc: &AuthSvc, input: &IdTokenInput<'_>) -> AuthResult<String> {
59 let claims = claims_of(
60 input,
61 svc.signing().issuer(),
62 crate::jwt::now_secs(),
63 svc.auth().oauth.id_token_ttl_secs.max(1),
64 );
65 svc.signing().sign_id_token(&claims)
66}
67
68/// 纯 claims 组装:`auth_time` / `nonce` 原样取自输入,不得用 `now()` 顶替。
69pub(crate) fn claims_of(
70 input: &IdTokenInput<'_>,
71 issuer: &str,
72 now: i64,
73 ttl_secs: i64,
74) -> IdTokenClaims {
75 let has = |scope: &str| input.scopes.iter().any(|value| value == scope);
76
77 IdTokenClaims {
78 iss: issuer.to_owned(),
79 sub: input.user_id.to_string(),
80 aud: input.client_id.to_owned(),
81 azp: None,
82 iat: now,
83 exp: now + ttl_secs,
84 auth_time: Some(input.auth_time),
85 nonce: input.nonce.map(str::to_owned),
86 at_hash: Some(at_hash(input.access_token)),
87 sid: input.session_id.map(str::to_owned),
88 preferred_username: has("profile").then(|| input.username.to_owned()),
89 name: has("profile")
90 .then(|| input.displayname.map(str::to_owned))
91 .flatten(),
92 email: has("email").then(|| input.email.to_owned()),
93 email_verified: has("email").then_some(input.email_verified),
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn input<'a>(scopes: &'a [String], auth_time: i64, nonce: Option<&'a str>) -> IdTokenInput<'a> {
102 IdTokenInput {
103 client_id: "api",
104 user_id: 7,
105 username: "ada",
106 displayname: Some("Ada"),
107 email: "ada@example.com",
108 email_verified: true,
109 scopes,
110 access_token: "token",
111 session_id: Some("sid"),
112 auth_time,
113 nonce,
114 }
115 }
116
117 #[test]
118 fn at_hash_is_left_half_of_sha512() {
119 // 固定向量:空串的 SHA-512 左半 = ba7816bf... 的前 32 字节的 base64url
120 let digest = Sha512::digest(b"");
121 let expected = URL_SAFE_NO_PAD.encode(&digest[..digest.len() / 2]);
122 assert_eq!(at_hash(""), expected);
123 assert_eq!(at_hash("token").len(), 43);
124 assert_ne!(at_hash("a"), at_hash("b"));
125 }
126
127 #[test]
128 fn claims_keep_the_original_auth_time_and_nonce() {
129 let scopes = vec!["openid".to_owned(), "profile".to_owned()];
130 let claims = claims_of(&input(&scopes, 1_000, Some("n1")), "iss", 9_999, 300);
131 // 回归:auth_time 必须来自会话建立时间,不得被 now() 顶替。
132 assert_eq!(claims.auth_time, Some(1_000));
133 assert_eq!(claims.nonce.as_deref(), Some("n1"));
134 assert_eq!(claims.iat, 9_999);
135 assert_eq!(claims.exp, 10_299);
136 assert_eq!(claims.sub, "7");
137 assert_eq!(claims.aud, "api");
138 assert_eq!(claims.sid.as_deref(), Some("sid"));
139 }
140
141 #[test]
142 fn claims_scope_gating_and_boundaries() {
143 // 非法/缺失输入:无 nonce、无 profile/email scope → 不下发对应 claims。
144 let scopes = vec!["openid".to_owned()];
145 let claims = claims_of(&input(&scopes, 0, None), "iss", 0, 1);
146 assert_eq!(claims.nonce, None);
147 assert_eq!(claims.preferred_username, None);
148 assert_eq!(claims.email, None);
149 assert_eq!(claims.email_verified, None);
150 // auth_time = 0 也原样保留(真实语义由调用方保证)。
151 assert_eq!(claims.auth_time, Some(0));
152 }
153}
Deleted lib/auth/src/server/oauth/logout.rs
@@ -1,341 +0,0 @@
1//! 单点登出:`GET|POST /api/v1/oauth/logout`(RP-Initiated Logout)+ 反向通道通知。
2//!
3//! 一个 RP 发起点登出,认证中心会:
4//! 1. 撤销当前会话(`user_sessions.revoked_at`)
5//! 2. 撤销该会话在所有客户端下的刷新令牌
6//! 3. 向注册了 `backchannel_logout_uris` 的客户端 POST `logout_token`
7//! 4. 302 回 `post_logout_redirect_uri`(必须已注册)
8
9use std::time::Duration;
10
11use actix_web::{HttpRequest, HttpResponse, web};
12use reqwest::Url;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::server::AuthSvc;
17use crate::session::{RequestId, Session};
18
19/// 反向通道注销的 events 键(OIDC Back-Channel Logout 1.0 §2.4)。
20const BACKCHANNEL_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout";
21
22#[derive(Debug, Default, Deserialize)]
23pub struct LogoutRequest {
24 pub id_token_hint: Option<String>,
25 pub post_logout_redirect_uri: Option<String>,
26 pub state: Option<String>,
27 pub client_id: Option<String>,
28}
29
30/// id_token_hint 只取定位客户端与会话需要的字段;签名必须有效,过期仍然接受(规范允许)。
31#[derive(Deserialize)]
32struct HintClaims {
33 aud: String,
34 #[serde(default)]
35 sub: Option<String>,
36 #[serde(default)]
37 sid: Option<String>,
38}
39
40/// hint 必须绑定到当前会话(`sid` 或 `sub` 匹配)才算验证通过;
41/// 否则任何人都能拿自己的合法 hint 给受害者发强制登出链接。
42fn hint_matches_session(
43 claims: &HintClaims,
44 session_id: Option<&str>,
45 user_id: Option<i64>,
46) -> bool {
47 if let (Some(hint_sid), Some(sid)) = (claims.sid.as_deref(), session_id)
48 && hint_sid == sid
49 {
50 return true;
51 }
52 match (claims.sub.as_deref(), user_id) {
53 (Some(sub), Some(user_id)) => sub == user_id.to_string(),
54 _ => false,
55 }
56}
57
58/// GET 且没有验证通过的 hint:返回确认页,确认后 POST 回本端点完成登出。
59fn confirm_html(action_query: &str) -> String {
60 let action = format!("/api/v1/oauth/logout?{action_query}");
61 maud::html! {
62 (maud::DOCTYPE)
63 html lang="zh" {
64 head { meta charset="utf-8"; title { "确认登出" } }
65 body {
66 h1 { "确认登出" }
67 p { "确认要退出当前登录吗?" }
68 form method="post" action=(action) {
69 button type="submit" { "确认登出" }
70 }
71 a href="/" { "取消" }
72 }
73 }
74 }
75 .into_string()
76}
77
78fn confirm_page(action_query: &str) -> HttpResponse {
79 HttpResponse::Ok()
80 .content_type("text/html; charset=utf-8")
81 .insert_header(("cache-control", "no-store"))
82 .body(confirm_html(action_query))
83}
84
85#[derive(Serialize)]
86struct LogoutToken<'a> {
87 iss: &'a str,
88 aud: &'a str,
89 iat: i64,
90 exp: i64,
91 jti: String,
92 sub: String,
93 sid: &'a str,
94 events: serde_json::Value,
95}
96
97fn redirect(location: &str) -> HttpResponse {
98 HttpResponse::Found()
99 .insert_header(("location", location))
100 .insert_header(("cache-control", "no-store"))
101 .insert_header(("referrer-policy", "no-referrer"))
102 .finish()
103}
104
105fn hint_client(svc: &AuthSvc, hint: &str) -> Option<HintClaims> {
106 crate::jwt::verify_id_token(svc.signing().jwks(), hint).ok()
107}
108
109/// `GET|POST /api/v1/oauth/logout` 共用实现(规范两种方法都允许)。
110#[utoipa::path(
111 get,
112 path = "/api/v1/oauth/logout",
113 tag = "oauth",
114 operation_id = "oauth_logout",
115 summary = "单点登出(RP-Initiated Logout)",
116 description = "撤销当前会话与其全客户端刷新令牌,并向 `backchannel_logout_uris` 发 `logout_token`;最后回跳已注册的 `post_logout_redirect_uri`。GET 带验证通过的 `id_token_hint`(签名有效且绑定当前会话)时免确认直接登出,否则返回确认页,确认后 POST 完成登出。",
117 params(
118 ("id_token_hint" = Option<String>, Query, description = "之前签发的 id_token(校验签名且须绑定当前会话)"),
119 ("post_logout_redirect_uri" = Option<String>, Query, description = "必须在客户端注册过"),
120 ("state" = Option<String>, Query, description = "原样回传"),
121 ("client_id" = Option<String>, Query, description = "无 id_token_hint 时可用")
122 ),
123 responses(
124 (status = 200, description = "GET 未验证 hint 时返回确认页"),
125 (status = 302, description = "回到 `post_logout_redirect_uri`,否则回站点首页")
126 )
127)]
128#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
129pub async fn logout(
130 svc: web::Data<AuthSvc>,
131 session: Session,
132 request_id: RequestId,
133 req: HttpRequest,
134 query: Result<web::Query<LogoutRequest>, actix_web::Error>,
135) -> HttpResponse {
136 let Ok(query) = query else {
137 return redirect("/");
138 };
139
140 // 0. 会话与用户先取出来:登出会清 cookie,之后就读不到了。
141 let session_id = session
142 .get::<String>("auth_session_id")
143 .ok()
144 .flatten()
145 .and_then(|value| Uuid::parse_str(&value).ok());
146 let user_id = session.user_id().ok().flatten();
147
148 // 1. 定位客户端:id_token_hint 优先,其次 client_id。
149 let hint = query
150 .id_token_hint
151 .as_deref()
152 .and_then(|hint| hint_client(&svc, hint));
153 let session_key = session_id.map(|id| id.to_string());
154 let hint_ok = hint
155 .as_ref()
156 .is_some_and(|claims| hint_matches_session(claims, session_key.as_deref(), user_id));
157
158 // 2. GET 且 hint 未验证通过 → 确认页:防跨站链接强制登出(CSRF)。
159 // POST 是确认页表单提交(SameSite cookie 挡住跨站自动提交),或已验证的 RP 发起。
160 if req.method() == actix_web::http::Method::GET && !hint_ok {
161 return confirm_page(req.query_string());
162 }
163
164 let client = hint
165 .as_ref()
166 .map(|claims| claims.aud.clone())
167 .or_else(|| query.client_id.clone())
168 .and_then(|id| svc.clients().get(&id));
169
170 // 3. 撤销会话与刷新令牌。
171 if let Some(id) = session_id
172 && let Err(error) =
173 crate::server::oauth::refresh::revoke_session(&svc, id, Some(request_id.as_str())).await
174 {
175 tracing::warn!(%error, %id, "failed to revoke oauth session tokens during logout");
176 }
177 let response = match svc.logout(session, request_id.as_str().to_owned()).await {
178 Ok(response) => response,
179 Err(error) => {
180 tracing::warn!(%error, "logout failed while revoking the session");
181 return redirect("/");
182 }
183 };
184
185 // 4. 反向通道通知:只发给注册了端点且当前用户属于该客户端的调用方。
186 if let (Some(user_id), Some(sid)) = (response.user_id.or(user_id), session_id) {
187 notify_backchannel(
188 &svc,
189 &sid.to_string(),
190 user_id,
191 client.as_deref(),
192 Some(request_id.as_str()),
193 )
194 .await;
195 }
196
197 // 5. 回跳:只有注册过的 post_logout_redirect_uri 才允许,否则回首页。
198 let wildcard = svc.auth().oauth.allow_wildcard_redirect;
199 if let (Some(target), Some(record)) = (query.post_logout_redirect_uri.as_deref(), &client)
200 && record.post_logout_allowed(target, wildcard)
201 && let Ok(mut url) = Url::parse(target)
202 {
203 if let Some(state) = &query.state {
204 url.query_pairs_mut().append_pair("state", state);
205 }
206 return redirect(url.as_str());
207 }
208 redirect("/")
209}
210
211#[tracing::instrument(skip_all, fields(session_id = %session_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
212async fn notify_backchannel(
213 svc: &AuthSvc,
214 session_id: &str,
215 user_id: i64,
216 client: Option<&crate::server::oauth::client::ClientRecord>,
217 request_id: Option<&str>,
218) {
219 let targets: Vec<(String, Vec<String>)> = svc
220 .clients()
221 .list()
222 .iter()
223 .filter(|record| client.is_none_or(|only| only.client_id == record.client_id))
224 .filter(|record| !record.backchannel_logout_uris.is_empty())
225 .map(|record| {
226 (
227 record.client_id.clone(),
228 record.backchannel_logout_uris.clone(),
229 )
230 })
231 .collect();
232 if targets.is_empty() {
233 return;
234 }
235
236 let now = crate::jwt::now_secs();
237 let http = match reqwest::Client::builder()
238 .timeout(Duration::from_secs(5))
239 .build()
240 {
241 Ok(client) => client,
242 Err(error) => {
243 tracing::warn!(%error, "backchannel logout client could not be built");
244 return;
245 }
246 };
247
248 for (client_id, uris) in targets {
249 let claims = LogoutToken {
250 iss: svc.signing().issuer(),
251 aud: &client_id,
252 iat: now,
253 exp: now + 120,
254 jti: Uuid::now_v7().to_string(),
255 sub: user_id.to_string(),
256 sid: session_id,
257 events: serde_json::json!({ BACKCHANNEL_EVENT: {} }),
258 };
259 let token = match svc.signing().sign_id_token(&claims) {
260 Ok(token) => token,
261 Err(error) => {
262 tracing::warn!(%error, %client_id, "backchannel logout token could not be signed");
263 continue;
264 }
265 };
266 for uri in uris {
267 let result = http
268 .post(&uri)
269 .form(&[("logout_token", token.as_str())])
270 .send()
271 .await;
272 match result {
273 Ok(response) if response.status().is_success() => {
274 tracing::info!(%client_id, %uri, "backchannel logout delivered");
275 }
276 Ok(response) => {
277 tracing::warn!(%client_id, %uri, status = %response.status(), "backchannel logout rejected");
278 }
279 Err(error) => {
280 tracing::warn!(%client_id, %uri, %error, "backchannel logout failed");
281 }
282 }
283 }
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn claims(sub: Option<&str>, sid: Option<&str>) -> HintClaims {
292 HintClaims {
293 aud: "api".into(),
294 sub: sub.map(str::to_owned),
295 sid: sid.map(str::to_owned),
296 }
297 }
298
299 #[test]
300 fn hint_must_bind_to_the_current_session() {
301 assert!(hint_matches_session(
302 &claims(None, Some("s1")),
303 Some("s1"),
304 Some(7)
305 ));
306 assert!(hint_matches_session(
307 &claims(Some("7"), None),
308 Some("s1"),
309 Some(7)
310 ));
311 // 不同会话/不同用户:验证不通过,必须走确认页。
312 assert!(!hint_matches_session(
313 &claims(None, Some("s2")),
314 Some("s1"),
315 Some(7)
316 ));
317 assert!(!hint_matches_session(
318 &claims(Some("8"), None),
319 Some("s1"),
320 Some(7)
321 ));
322 // 非法输入:hint 或会话缺少绑定字段。
323 assert!(!hint_matches_session(
324 &claims(None, None),
325 Some("s1"),
326 Some(7)
327 ));
328 assert!(!hint_matches_session(
329 &claims(Some("7"), Some("s1")),
330 None,
331 None
332 ));
333 }
334
335 #[test]
336 fn confirm_page_reposts_the_original_query() {
337 let html = confirm_html("client_id=api&state=xyz");
338 assert!(html.contains("method=\"post\""));
339 assert!(html.contains("/api/v1/oauth/logout?client_id=api&amp;state=xyz"));
340 }
341}
Deleted lib/auth/src/server/oauth/mod.rs
@@ -1,11 +0,0 @@
1//! OAuth 2.0 与 OIDC:客户端注册、授权码、id_token、userinfo、consent、refresh token、单点登出。
2//!
3//! OAuth 2.0 的授权码流程在 `protocol`,客户端注册表在 `client`。
4
5pub mod client;
6pub mod consent;
7pub mod id_token;
8pub mod logout;
9pub mod protocol;
10pub mod refresh;
11pub mod userinfo;
Deleted lib/auth/src/server/oauth/protocol.rs
@@ -1,1528 +0,0 @@
1//! OAuth 2.0 授权码流程 + OIDC 发现文档。
2//!
3//! 端点(`/api/v1` 之外只保留发现文档,遵循 OIDC / RFC 8414 的固定位置):
4//! - `GET /api/v1/oauth/authorize` — 授权码 + PKCE(S256),支持 `prompt` / `max_age` / `nonce`
5//! - `POST /api/v1/oauth/token` — `authorization_code` / `refresh_token` / `client_credentials`
6//! - `POST /api/v1/oauth/revoke` — RFC 7009 令牌撤销
7//! - `GET /.well-known/oauth-authorization-server` 与 `/.well-known/openid-configuration`
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::config::auth::OAuthConfig;
13use crate::config::web::WebConfig;
14use actix_web::http::header::{self, HeaderValue};
15use actix_web::{HttpRequest, HttpResponse, web};
16use base64::Engine;
17use base64::engine::general_purpose::URL_SAFE_NO_PAD;
18use chrono::Utc;
19use reqwest::Url;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::server::oauth::client::ClientRecord;
24use crate::server::share::crypto::{constant_time_eq, random_b64url, sha256_hex};
25use crate::server::{AuthError, AuthResult, AuthSvc};
26
27pub const MIN_RPC_KEY_LEN: usize = 32;
28
29/// 配置示例中的占位符前缀:带着它启动说明密钥一直没换,直接拒绝。
30fn is_placeholder(value: &str) -> bool {
31 value.trim().starts_with("REPLACE_WITH_")
32}
33
34pub fn validate_config(config: &OAuthConfig, issuer: &str) -> AuthResult<()> {
35 // gRPC 面只有这一道门禁,所以密钥必须在启动时就位。
36 if config.rpc_key.len() < MIN_RPC_KEY_LEN {
37 return Err(AuthError::Unavailable(format!(
38 "auth.oauth.rpc_key requires at least {MIN_RPC_KEY_LEN} characters"
39 )));
40 }
41 if is_placeholder(&config.rpc_key) {
42 return Err(AuthError::Unavailable(
43 "auth.oauth.rpc_key is still set to the placeholder from config.yaml.example".into(),
44 ));
45 }
46 if config.public_url.is_empty() && config.clients.is_empty() && config.socket_origins.is_empty()
47 {
48 return Ok(());
49 }
50 let base = endpoint(&config.public_url).ok_or_else(|| {
51 AuthError::Unavailable(
52 "auth.oauth.public_url requires an https origin or loopback http origin".into(),
53 )
54 })?;
55 if base.origin().ascii_serialization() != config.public_url || issuer != config.public_url {
56 return Err(AuthError::Unavailable(
57 "JWT issuer must equal auth.oauth.public_url, with no trailing slash".into(),
58 ));
59 }
60 for (index, client) in config.clients.iter().enumerate() {
61 let duplicate = config.clients[index + 1..]
62 .iter()
63 .any(|other| other.client_id == client.client_id);
64 if duplicate
65 || crate::server::oauth::client::registry::validate_client_id(&client.client_id).is_err()
66 // 仅跑 client_credentials 的机密客户端可以没有重定向地址。
67 || (client.kind == crate::models::users::oauth_clients::ClientKind::Public
68 && client.redirect_uris.is_empty())
69 || client
70 .scopes
71 .iter()
72 .any(|scope| !crate::jwt::scope::is_known(scope))
73 || client
74 .redirect_uris
75 .iter()
76 .any(|uri| {
77 crate::server::oauth::client::registry::validate_uri(uri, config.allow_wildcard_redirect)
78 .is_err()
79 })
80 || (client.kind == crate::models::users::oauth_clients::ClientKind::Confidential
81 && client
82 .secret
83 .as_deref()
84 .is_none_or(
85 |secret| {
86 secret.len() < crate::server::oauth::client::registry::MIN_SECRET_LEN
87 || is_placeholder(secret)
88 },
89 ))
90 {
91 return Err(AuthError::Unavailable(
92 "OAuth client registration is invalid".into(),
93 ));
94 }
95 }
96 for origin in &config.socket_origins {
97 if endpoint(origin).is_none_or(|url| url.origin().ascii_serialization() != *origin) {
98 return Err(AuthError::Unavailable(
99 "Socket origins require exact http(s) origins".into(),
100 ));
101 }
102 }
103 Ok(())
104}
105
106fn endpoint(raw: &str) -> Option<Url> {
107 crate::server::oauth::client::registry::endpoint(raw)
108}
109
110/// 一次授权请求里 `prompt` 解析结果。
111#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
112pub struct Prompt {
113 pub login: bool,
114 pub consent: bool,
115 pub none: bool,
116}
117
118/// `prompt` 只支持 `none` / `login` / `consent`;`none` 必须单独出现。
119pub fn parse_prompt(raw: Option<&str>) -> Result<Prompt, AuthError> {
120 let mut prompt = Prompt::default();
121 let Some(raw) = raw else {
122 return Ok(prompt);
123 };
124 let mut count = 0;
125 for token in raw.split_ascii_whitespace() {
126 count += 1;
127 match token {
128 "none" => prompt.none = true,
129 "login" => prompt.login = true,
130 "consent" => prompt.consent = true,
131 _ => return Err(AuthError::MalformedRequest),
132 }
133 }
134 if count == 0 || (prompt.none && count > 1) {
135 return Err(AuthError::MalformedRequest);
136 }
137 Ok(prompt)
138}
139
140#[utoipa::path(
141 get,
142 path = "/.well-known/oauth-authorization-server",
143 tag = "oauth",
144 operation_id = "oauth_metadata",
145 summary = "OAuth 授权服务器元数据(含 OIDC 字段)",
146 responses((status = 200, description = "RFC 8414 元数据"))
147)]
148pub async fn metadata(svc: web::Data<AuthSvc>) -> HttpResponse {
149 let issuer = svc.auth().oauth.public_url.clone();
150 if issuer.is_empty() {
151 return failure("temporarily_unavailable", true);
152 }
153 let config = &svc.auth().oauth;
154 HttpResponse::Ok().json(serde_json::json!({
155 "issuer": issuer,
156 "authorization_endpoint": format!("{issuer}/api/v1/oauth/authorize"),
157 "token_endpoint": format!("{issuer}/api/v1/oauth/token"),
158 "userinfo_endpoint": format!("{issuer}/api/v1/oauth/userinfo"),
159 "revocation_endpoint": format!("{issuer}/api/v1/oauth/revoke"),
160 "end_session_endpoint": format!("{issuer}/api/v1/oauth/logout"),
161 "jwks_uri": format!("{issuer}/.well-known/jwks.json"),
162 "response_types_supported": ["code"],
163 "response_modes_supported": ["query"],
164 "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
165 "token_endpoint_auth_methods_supported": [
166 "none", "client_secret_basic", "client_secret_post"
167 ],
168 "code_challenge_methods_supported": ["S256"],
169 "authorization_response_iss_parameter_supported": true,
170 "subject_types_supported": ["public"],
171 "id_token_signing_alg_values_supported": [crate::jwt::ALG],
172 "prompt_values_supported": ["none", "login", "consent"],
173 "claims_supported": [
174 "iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "at_hash", "sid",
175 "preferred_username", "name", "email", "email_verified"
176 ],
177 "claims_parameter_supported": false,
178 "scopes_supported": crate::jwt::scope::supported(),
179 "backchannel_logout_supported": true,
180 "backchannel_logout_session_supported": true,
181 "request_parameter_supported": false,
182 "request_uri_parameter_supported": false,
183 "access_token_ttl": config.access_token_ttl_secs,
184 "id_token_ttl": config.id_token_ttl_secs,
185 "refresh_token_ttl": config.refresh_token_ttl_secs,
186 }))
187}
188
189/// OIDC 发现文档与 OAuth AS 元数据内容一致(OIDC 只要求其中一部分字段)。
190/// 单行转发 metadata:两个发现端点本就同内容(RFC 8414 vs OIDC Discovery),
191/// 拆开只会复制整段 JSON;保留此风格并以此注释声明。
192#[utoipa::path(
193 get,
194 path = "/.well-known/openid-configuration",
195 tag = "oauth",
196 operation_id = "oauth_openid_configuration",
197 summary = "OIDC 发现文档",
198 responses((status = 200, description = "OpenID Provider 元数据"))
199)]
200pub async fn openid_configuration(svc: web::Data<AuthSvc>) -> HttpResponse {
201 metadata(svc).await
202}
203
204#[derive(Debug, Deserialize)]
205pub struct AuthorizeRequest {
206 pub response_type: String,
207 pub client_id: String,
208 pub redirect_uri: String,
209 /// 可选(RFC 6749 §4.1.2.1);给出时原样回传,长度上限 2048 是实现约束。
210 #[serde(default)]
211 pub state: Option<String>,
212 pub code_challenge: String,
213 pub code_challenge_method: String,
214 #[serde(default)]
215 pub scope: String,
216 #[serde(default)]
217 pub nonce: Option<String>,
218 #[serde(default)]
219 pub prompt: Option<String>,
220 #[serde(default)]
221 pub max_age: Option<i64>,
222}
223
224/// `state` 的实现上限:规范不约束长度,只拦超长值撑爆重定向 URL。
225pub const MAX_STATE_LEN: usize = 2048;
226
227pub fn valid_state(state: Option<&str>) -> bool {
228 state.is_none_or(|state| state.len() <= MAX_STATE_LEN)
229}
230
231/// 解析失败时的降级入参:只要 client_id / redirect_uri 能验证,就能把错误送回客户端。
232#[derive(Debug, Deserialize)]
233pub struct RawAuthorizeParams {
234 #[serde(default)]
235 pub client_id: Option<String>,
236 #[serde(default)]
237 pub redirect_uri: Option<String>,
238 #[serde(default)]
239 pub state: Option<String>,
240}
241
242impl AuthorizeRequest {
243 pub fn scopes(&self) -> Vec<String> {
244 let mut scopes: Vec<String> = self
245 .scope
246 .split_ascii_whitespace()
247 .map(str::to_owned)
248 .collect();
249 scopes.sort();
250 scopes.dedup();
251 scopes
252 }
253}
254
255#[derive(Serialize, Deserialize)]
256struct AuthorizationCode {
257 user_id: i64,
258 version: i32,
259 session_id: String,
260 client_id: String,
261 redirect_uri: String,
262 challenge: String,
263 scopes: Vec<String>,
264 audience: String,
265 auth_time: i64,
266 #[serde(default)]
267 nonce: Option<String>,
268 expires_at: i64,
269}
270
271fn valid_challenge(challenge: &str) -> bool {
272 challenge.len() == 43
273 && URL_SAFE_NO_PAD
274 .decode(challenge)
275 .is_ok_and(|value| value.len() == 32)
276}
277
278fn valid_nonce(nonce: &str) -> bool {
279 (1..=512).contains(&nonce.len()) && nonce.chars().all(|c| ('!'..='~').contains(&c))
280}
281
282fn unauthenticated(req: &HttpRequest) -> HttpResponse {
283 let callback = format!("/api/v1/oauth/authorize?{}", req.query_string());
284 let location = format!(
285 "/login?callback_url={}",
286 percent_encoding::utf8_percent_encode(&callback, percent_encoding::NON_ALPHANUMERIC)
287 );
288 HttpResponse::Found()
289 .insert_header(("location", location))
290 .insert_header(("cache-control", "no-store"))
291 .finish()
292}
293
294/// 同意页地址:原样带上授权查询串,页面把它原样传给 `/api/v1/oauth/consent`。
295fn consent_url(req: &HttpRequest) -> String {
296 format!("/consent?{}", req.query_string())
297}
298
299#[utoipa::path(
300 get,
301 path = "/api/v1/oauth/authorize",
302 tag = "oauth",
303 operation_id = "oauth_authorize",
304 summary = "授权端点(授权码 + PKCE S256)",
305 description = "未登录跳到 `/login`;首次授权跳 `/consent`;`prompt=none` 直接返回 `login_required` / `consent_required`。",
306 params(
307 ("response_type" = String, Query, description = "固定为 `code`"),
308 ("client_id" = String, Query, description = "已注册的客户端 id"),
309 ("redirect_uri" = String, Query, description = "已注册的回调地址,支持子域通配"),
310 ("state" = String, Query, description = "16..=512 字符的不透明串"),
311 ("code_challenge" = String, Query, description = "PKCE S256 challenge(43 字符 base64url)"),
312 ("code_challenge_method" = String, Query, description = "固定为 `S256`"),
313 ("scope" = Option<String>, Query, description = "空格分隔,必须 ⊆ 客户端登记 scope"),
314 ("nonce" = Option<String>, Query, description = "OIDC nonce,原样写入 id_token"),
315 ("prompt" = Option<String>, Query, description = "`none` / `login` / `consent`"),
316 ("max_age" = Option<i64>, Query, description = "会话最大年龄(秒),超出就重新登录")
317 ),
318 responses(
319 (status = 302, description = "重定向回 `redirect_uri`,带 `code` / `error` / `state` / `iss`"),
320 (status = 400, description = "`invalid_request`(客户端或回调未注册)")
321 )
322)]
323#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
324pub async fn authorize(
325 svc: web::Data<AuthSvc>,
326 session: crate::session::Session,
327 request_id: crate::session::RequestId,
328 req: HttpRequest,
329 query: Result<web::Query<AuthorizeRequest>, actix_web::Error>,
330) -> HttpResponse {
331 let Ok(query) = query else {
332 // 反序列化失败也尽量走重定向:client_id / redirect_uri 验证通过就 302 携 error 回客户端。
333 return authorize_parse_failure(&svc, req.query_string());
334 };
335 let Some(record) = svc.clients().get(&query.client_id) else {
336 return failure("invalid_request", false);
337 };
338 let wildcard = svc.auth().oauth.allow_wildcard_redirect;
339 if !record.redirect_allowed(&query.redirect_uri, wildcard) {
340 return failure("invalid_request", false);
341 }
342 if query.response_type != "code" {
343 return authorization_error(&svc, &query, "unsupported_response_type");
344 }
345 let prompt = match parse_prompt(query.prompt.as_deref()) {
346 Ok(prompt) => prompt,
347 Err(_) => return authorization_error(&svc, &query, "invalid_request"),
348 };
349 let scopes = query.scopes();
350 let nonce_ok = query.nonce.as_deref().is_none_or(valid_nonce);
351 if query.code_challenge_method != "S256"
352 || !valid_challenge(&query.code_challenge)
353 || !valid_state(query.state.as_deref())
354 || query.scope.len() > 1024
355 || !nonce_ok
356 {
357 return authorization_error(&svc, &query, "invalid_request");
358 }
359 if !record.scopes_allowed(scopes.iter().map(String::as_str)) {
360 return authorization_error(&svc, &query, "invalid_scope");
361 }
362
363 let mut identity = if prompt.login {
364 None
365 } else {
366 match svc
367 .require_session(&session, Some(request_id.as_str()))
368 .await
369 {
370 Ok(user_id) => Some(user_id),
371 Err(AuthError::Unauthenticated) => None,
372 Err(error) => return auth_failure(error),
373 }
374 };
375 if let (Some(_), Some(max_age)) = (identity, query.max_age)
376 && session_expired(&svc, &session, max_age, Some(request_id.as_str())).await
377 {
378 identity = None;
379 }
380
381 let Some(user_id) = identity else {
382 return if prompt.none {
383 authorization_error(&svc, &query, "login_required")
384 } else {
385 unauthenticated(&req)
386 };
387 };
388
389 let needs_consent = prompt.consent
390 || (svc.auth().oauth.require_consent
391 && !crate::server::oauth::consent::granted(
392 &svc,
393 user_id,
394 &record.client_id,
395 &scopes,
396 Some(request_id.as_str()),
397 )
398 .await
399 .unwrap_or(false));
400 if needs_consent {
401 return if prompt.none {
402 authorization_error(&svc, &query, "consent_required")
403 } else {
404 HttpResponse::Found()
405 .insert_header(("location", consent_url(&req)))
406 .insert_header(("cache-control", "no-store"))
407 .finish()
408 };
409 }
410
411 let result: AuthResult<String> = async {
412 let id = session
413 .get::<String>("auth_session_id")?
414 .ok_or(AuthError::Unauthenticated)?;
415 let version = session
416 .security_version()?
417 .ok_or(AuthError::Unauthenticated)?;
418 let auth_time = svc
419 .session_created_at(&session, Some(request_id.as_str()))
420 .await?
421 .map_or_else(crate::jwt::now_secs, |at| at.timestamp());
422 let ttl = svc.auth().oauth.code_ttl_secs.clamp(10, 600);
423 let code = random_b64url(32);
424 let grant = AuthorizationCode {
425 user_id,
426 version,
427 session_id: id,
428 client_id: query.client_id.clone(),
429 redirect_uri: query.redirect_uri.clone(),
430 challenge: query.code_challenge.clone(),
431 scopes,
432 audience: record.audience().to_owned(),
433 auth_time,
434 nonce: query.nonce.clone(),
435 expires_at: crate::jwt::now_secs() + ttl,
436 };
437 svc.cache()
438 .set_ex(
439 &format!("auth:oauth:code:{}", sha256_hex(code.as_bytes())),
440 serde_json::to_string(&grant)?,
441 Duration::from_secs(ttl as u64),
442 )
443 .await?;
444 Ok(code)
445 }
446 .await;
447 match result {
448 Ok(code) => {
449 let Ok(mut redirect) = Url::parse(&query.redirect_uri) else {
450 return failure("invalid_request", false);
451 };
452 redirect.query_pairs_mut().append_pair("code", &code);
453 if let Some(state) = &query.state {
454 redirect.query_pairs_mut().append_pair("state", state);
455 }
456 redirect
457 .query_pairs_mut()
458 .append_pair("iss", &svc.auth().oauth.public_url);
459 HttpResponse::Found()
460 .insert_header(("location", redirect.as_str()))
461 .insert_header(("cache-control", "no-store"))
462 .insert_header(("referrer-policy", "no-referrer"))
463 .finish()
464 }
465 Err(error) => auth_failure(error),
466 }
467}
468
469/// `max_age` 判定:会话建立时间超出窗口就要求重新认证。
470async fn session_expired(
471 svc: &AuthSvc,
472 session: &crate::session::Session,
473 max_age: i64,
474 _request_id: Option<&str>,
475) -> bool {
476 if max_age < 0 {
477 return true;
478 }
479 match svc.session_created_at(session, _request_id).await {
480 Ok(Some(created_at)) => {
481 Utc::now().signed_duration_since(created_at).num_seconds() > max_age
482 }
483 // 拿不到会话行就当作过期,交给登录页重来。
484 Ok(None) => true,
485 Err(_) => true,
486 }
487}
488
489#[derive(Debug, Deserialize, utoipa::ToSchema)]
490pub struct TokenRequest {
491 pub grant_type: String,
492 #[serde(default)]
493 pub code: Option<String>,
494 #[serde(default)]
495 pub redirect_uri: Option<String>,
496 #[serde(default)]
497 pub code_verifier: Option<String>,
498 #[serde(default)]
499 pub refresh_token: Option<String>,
500 #[serde(default)]
501 pub client_id: Option<String>,
502 #[serde(default)]
503 pub client_secret: Option<String>,
504 #[serde(default)]
505 pub scope: Option<String>,
506}
507
508#[derive(Debug, Clone, Copy)]
509enum ClientAuthFailure {
510 /// 客户端不存在、被停用,或 secret 不对。
511 InvalidClient,
512 /// 客户端类型不允许这个 grant(例如 public 客户端走 client_credentials)。
513 UnauthorizedClient,
514}
515
516impl ClientAuthFailure {
517 fn code(self) -> &'static str {
518 match self {
519 Self::InvalidClient => "invalid_client",
520 Self::UnauthorizedClient => "unauthorized_client",
521 }
522 }
523
524 fn status(self) -> actix_web::http::StatusCode {
525 match self {
526 Self::InvalidClient => actix_web::http::StatusCode::UNAUTHORIZED,
527 Self::UnauthorizedClient => actix_web::http::StatusCode::BAD_REQUEST,
528 }
529 }
530}
531
532/// 解析客户端认证:支持 `client_secret_basic` 与 `client_secret_post`;public 客户端只认 `client_id`。
533fn authenticate_client(
534 svc: &AuthSvc,
535 req: &GrantContext,
536 form: &TokenRequest,
537) -> Result<Arc<ClientRecord>, ClientAuthFailure> {
538 let basic = req
539 .authorization
540 .as_deref()
541 .and_then(|value| {
542 let (scheme, rest) = value.split_once(' ')?;
543 scheme.eq_ignore_ascii_case("basic").then_some(rest.trim())
544 })
545 .and_then(|raw| {
546 URL_SAFE_NO_PAD
547 .decode(raw)
548 .or_else(|_| base64::engine::general_purpose::STANDARD.decode(raw))
549 .ok()
550 })
551 .and_then(|bytes| String::from_utf8(bytes).ok())
552 .and_then(|decoded| {
553 decoded
554 .split_once(':')
555 .map(|(id, secret)| (id.to_owned(), secret.to_owned()))
556 });
557
558 let (client_id, secret) = match basic {
559 Some((id, secret)) => (id, Some(secret).filter(|value| !value.is_empty())),
560 None => (
561 form.client_id
562 .clone()
563 .ok_or(ClientAuthFailure::InvalidClient)?,
564 form.client_secret.clone().filter(|value| !value.is_empty()),
565 ),
566 };
567
568 let record = svc
569 .clients()
570 .get(&client_id)
571 .ok_or(ClientAuthFailure::InvalidClient)?;
572 check_client_secret(&record, secret.as_deref())?;
573 Ok(record)
574}
575
576/// 客户端凭据判定:机密客户端必须给出正确 secret;public 客户端不得携带 secret
577/// (RFC 6749 §2.3:客户端只能用注册的认证方式,拿错凭据一律 `invalid_client`)。
578fn check_client_secret(
579 record: &ClientRecord,
580 secret: Option<&str>,
581) -> Result<(), ClientAuthFailure> {
582 let secret = secret.filter(|value| !value.is_empty());
583 match (record.is_confidential(), secret) {
584 (true, Some(secret)) if record.verify_secret(secret) => Ok(()),
585 (true, _) => Err(ClientAuthFailure::InvalidClient),
586 (false, None) => Ok(()),
587 (false, Some(_)) => Err(ClientAuthFailure::InvalidClient),
588 }
589}
590
591fn verifier_matches(verifier: &str, challenge: &str) -> bool {
592 (43..=128).contains(&verifier.len())
593 && verifier
594 .bytes()
595 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'))
596 && constant_time_eq(
597 URL_SAFE_NO_PAD
598 .encode(Sha256::digest(verifier.as_bytes()))
599 .as_bytes(),
600 challenge.as_bytes(),
601 )
602}
603
604#[utoipa::path(
605 post,
606 path = "/api/v1/oauth/token",
607 tag = "oauth",
608 operation_id = "oauth_token",
609 summary = "令牌端点",
610 description = "支持 `authorization_code`(PKCE)、`refresh_token`(轮换 + 重放检测)、`client_credentials`(机密客户端)。带 `openid` scope 时响应额外包含 `id_token`。",
611 request_body = TokenRequest,
612 responses(
613 (status = 200, description = "令牌响应", body = serde_json::Value),
614 (status = 400, description = "`invalid_grant` / `invalid_scope` / `unsupported_grant_type`"),
615 (status = 401, description = "`invalid_client`")
616 )
617)]
618#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
619pub async fn token(
620 svc: web::Data<AuthSvc>,
621 web_cfg: web::Data<WebConfig>,
622 req: HttpRequest,
623 request_id: crate::session::RequestId,
624 form: Result<web::Form<TokenRequest>, actix_web::Error>,
625) -> HttpResponse {
626 let Ok(form) = form else {
627 return cors(&svc, &web_cfg, &req, failure("invalid_request", false));
628 };
629 let context = GrantContext {
630 authorization: req
631 .headers()
632 .get(header::AUTHORIZATION)
633 .and_then(|value| value.to_str().ok())
634 .map(str::to_owned),
635 client_ip: client_ip(&req),
636 user_agent: user_agent(&req),
637 };
638 let response = exchange(&svc, &context, &form, Some(request_id.as_str())).await;
639 cors(&svc, &web_cfg, &req, response)
640}
641
642#[derive(Default)]
643struct GrantContext {
644 authorization: Option<String>,
645 client_ip: Option<String>,
646 user_agent: Option<String>,
647}
648
649#[derive(Debug, thiserror::Error)]
650#[error("{message}")]
651pub struct LocalGrantError {
652 pub status: u16,
653 pub message: String,
654}
655
656/// 在单体内复用 HTTP token 端点的客户端、PKCE 和刷新轮换校验。
657pub async fn exchange_local(
658 svc: &AuthSvc,
659 form: &TokenRequest,
660) -> Result<serde_json::Value, LocalGrantError> {
661 use actix_web::body::MessageBody;
662 let response = exchange(
663 svc,
664 &GrantContext::default(),
665 form,
666 Some(&uuid::Uuid::now_v7().to_string()),
667 )
668 .await;
669 let status = response.status();
670 let bytes = response
671 .into_body()
672 .try_into_bytes()
673 .map_err(|_| LocalGrantError {
674 status: 500,
675 message: "token response body is not buffered".to_owned(),
676 })?;
677 if !status.is_success() {
678 return Err(LocalGrantError {
679 status: status.as_u16(),
680 message: format!(
681 "token grant rejected ({status}): {}",
682 String::from_utf8_lossy(&bytes)
683 ),
684 });
685 }
686 serde_json::from_slice(&bytes).map_err(|error| LocalGrantError {
687 status: 500,
688 message: error.to_string(),
689 })
690}
691
692#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
693async fn exchange(
694 svc: &AuthSvc,
695 req: &GrantContext,
696 form: &TokenRequest,
697 request_id: Option<&str>,
698) -> HttpResponse {
699 let record = match authenticate_client(svc, req, form) {
700 Ok(record) => record,
701 Err(error) => return client_failure(error),
702 };
703
704 match form.grant_type.as_str() {
705 "authorization_code" => exchange_code(svc, req, form, &record, request_id).await,
706 "refresh_token" => exchange_refresh(svc, req, form, &record, request_id).await,
707 "client_credentials" => exchange_client_credentials(svc, form, &record, request_id).await,
708 _ => failure("unsupported_grant_type", false),
709 }
710}
711
712fn client_failure(error: ClientAuthFailure) -> HttpResponse {
713 HttpResponse::build(error.status())
714 .insert_header(("cache-control", "no-store"))
715 .json(serde_json::json!({ "error": error.code() }))
716}
717
718struct AccessTokenInput<'a> {
719 svc: &'a AuthSvc,
720 record: &'a ClientRecord,
721 user_id: i64,
722 username: &'a str,
723 version: i32,
724 session_id: Option<String>,
725 mfa_verified: bool,
726 scopes: &'a [String],
727}
728
729/// 访问令牌实际生效的寿命:配置值 clamp 到 60..=86400,签发与 `expires_in` 都用它。
730pub(crate) fn effective_access_ttl(svc: &AuthSvc) -> i64 {
731 clamp_access_ttl(svc.auth().oauth.access_token_ttl_secs)
732}
733
734fn clamp_access_ttl(raw: i64) -> i64 {
735 raw.clamp(60, 86_400)
736}
737
738fn access_claims(input: &AccessTokenInput<'_>) -> crate::jwt::AppClaims {
739 let now = crate::jwt::now_secs();
740 let ttl = effective_access_ttl(input.svc);
741 crate::jwt::AppClaims {
742 is_admin: false,
743 issuer: input.svc.signing().issuer().to_owned(),
744 app_id: input.record.client_id.clone(),
745 user_id: input.user_id,
746 audience: input.record.audience().to_owned(),
747 token_id: uuid::Uuid::now_v7().to_string(),
748 issued_at: now,
749 expires_at: now + ttl,
750 security_version: input.version,
751 session_id: input.session_id.clone(),
752 mfa_verified: input.mfa_verified,
753 scope: Some(input.scopes.to_vec()),
754 username: Some(input.username.to_owned()),
755 token_type: "access".into(),
756 }
757}
758
759#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
760async fn exchange_code(
761 svc: &AuthSvc,
762 req: &GrantContext,
763 form: &TokenRequest,
764 record: &ClientRecord,
765 request_id: Option<&str>,
766) -> HttpResponse {
767 let Some(code) = form.code.as_deref() else {
768 return failure("invalid_request", false);
769 };
770 let redirect_uri = form.redirect_uri.as_deref().unwrap_or_default();
771 if code.len() != 43 || redirect_uri.is_empty() {
772 return failure("invalid_grant", false);
773 }
774
775 let result: AuthResult<serde_json::Value> = async {
776 let raw: String = svc
777 .cache()
778 .get_del(&format!("auth:oauth:code:{}", sha256_hex(code.as_bytes())))
779 .await?
780 .ok_or(AuthError::InvalidCredentials)?;
781 let grant: AuthorizationCode = serde_json::from_str(&raw)?;
782 let verifier_ok = form
783 .code_verifier
784 .as_deref()
785 .is_some_and(|verifier| verifier_matches(verifier, &grant.challenge));
786 if grant.expires_at <= crate::jwt::now_secs()
787 || grant.client_id != record.client_id
788 || grant.redirect_uri != redirect_uri
789 || !verifier_ok
790 {
791 return Err(AuthError::InvalidCredentials);
792 }
793 let access = svc
794 .check_session(grant.user_id, grant.version, &grant.session_id, request_id)
795 .await
796 .map_err(|_| AuthError::InvalidCredentials)?;
797 let user = crate::jwt::authority::user(grant.user_id)
798 .await
799 .map_err(|_| AuthError::InvalidCredentials)?;
800
801 let claims = access_claims(&AccessTokenInput {
802 svc,
803 record,
804 user_id: grant.user_id,
805 username: &access.username,
806 version: grant.version,
807 session_id: Some(grant.session_id.clone()),
808 mfa_verified: access.mfa_verified,
809 scopes: &grant.scopes,
810 });
811 let access_token = svc.signing().sign(&claims)?;
812
813 let mut body = serde_json::json!({
814 "access_token": access_token,
815 "token_type": "Bearer",
816 "expires_in": effective_access_ttl(svc),
817 "scope": grant.scopes.join(" "),
818 });
819
820 if grant
821 .scopes
822 .iter()
823 .any(|scope| scope == crate::jwt::scope::OPENID)
824 {
825 let id_token = crate::server::oauth::id_token::build(
826 svc,
827 &crate::server::oauth::id_token::IdTokenInput {
828 client_id: &record.client_id,
829 user_id: grant.user_id,
830 username: &user.username,
831 displayname: user.displayname.as_deref(),
832 email: &user.email,
833 email_verified: user.email_verified_at.is_some(),
834 scopes: &grant.scopes,
835 access_token: &access_token,
836 session_id: Some(&grant.session_id),
837 auth_time: grant.auth_time,
838 nonce: grant.nonce.as_deref(),
839 },
840 )?;
841 body["id_token"] = serde_json::Value::String(id_token);
842 }
843
844 let issued = crate::server::oauth::refresh::issue(
845 svc,
846 crate::server::oauth::refresh::Grant {
847 user_id: grant.user_id,
848 session_id: &grant.session_id,
849 client_id: &record.client_id,
850 scopes: &grant.scopes,
851 ip_address: req.client_ip.clone(),
852 user_agent: req.user_agent.clone(),
853 },
854 request_id,
855 )
856 .await?;
857 body["refresh_token"] = serde_json::Value::String(issued.token);
858 Ok(body)
859 }
860 .await;
861
862 match result {
863 Ok(body) => token_response(body),
864 Err(error) => auth_failure(error),
865 }
866}
867
868#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
869async fn exchange_refresh(
870 svc: &AuthSvc,
871 req: &GrantContext,
872 form: &TokenRequest,
873 record: &ClientRecord,
874 request_id: Option<&str>,
875) -> HttpResponse {
876 let Some(refresh_token) = form.refresh_token.as_deref() else {
877 return failure("invalid_request", false);
878 };
879 let result: AuthResult<serde_json::Value> = async {
880 let rotated = crate::server::oauth::refresh::rotate(
881 svc,
882 refresh_token,
883 &record.client_id,
884 req.client_ip.clone(),
885 req.user_agent.clone(),
886 request_id,
887 )
888 .await?;
889
890 // 允许 scope 收窄:请求的 scope 必须是原授权 scope 的子集。
891 let scopes = match form.scope.as_deref() {
892 Some(raw) if !raw.trim().is_empty() => {
893 let requested: Vec<String> =
894 raw.split_ascii_whitespace().map(str::to_owned).collect();
895 if !requested.iter().all(|scope| rotated.scopes.contains(scope)) {
896 return Err(AuthError::InvalidScope);
897 }
898 requested
899 }
900 _ => rotated.scopes.clone(),
901 };
902
903 let access = svc
904 .check_session(
905 rotated.user_id,
906 session_version(svc, rotated.session_id, request_id).await?,
907 &rotated.session_id.to_string(),
908 request_id,
909 )
910 .await
911 .map_err(|_| AuthError::InvalidCredentials)?;
912 let user = crate::jwt::authority::user(rotated.user_id)
913 .await
914 .map_err(|_| AuthError::InvalidCredentials)?;
915 let claims = access_claims(&AccessTokenInput {
916 svc,
917 record,
918 user_id: rotated.user_id,
919 username: &access.username,
920 version: access.security_version,
921 session_id: Some(rotated.session_id.to_string()),
922 mfa_verified: access.mfa_verified,
923 scopes: &scopes,
924 });
925 let access_token = svc.signing().sign(&claims)?;
926
927 let mut body = serde_json::json!({
928 "access_token": access_token,
929 "token_type": "Bearer",
930 "expires_in": effective_access_ttl(svc),
931 "scope": scopes.join(" "),
932 "refresh_token": rotated.token,
933 });
934 if scopes
935 .iter()
936 .any(|scope| scope == crate::jwt::scope::OPENID)
937 {
938 let id_token = crate::server::oauth::id_token::build(
939 svc,
940 &crate::server::oauth::id_token::IdTokenInput {
941 client_id: &record.client_id,
942 user_id: rotated.user_id,
943 username: &user.username,
944 displayname: user.displayname.as_deref(),
945 email: &user.email,
946 email_verified: user.email_verified_at.is_some(),
947 scopes: &scopes,
948 access_token: &access_token,
949 session_id: Some(&rotated.session_id.to_string()),
950 auth_time: rotated.auth_time,
951 nonce: None,
952 },
953 )?;
954 body["id_token"] = serde_json::Value::String(id_token);
955 }
956 Ok(body)
957 }
958 .await;
959
960 match result {
961 Ok(body) => token_response(body),
962 Err(error) => auth_failure(error),
963 }
964}
965
966async fn session_version(
967 svc: &AuthSvc,
968 session_id: uuid::Uuid,
969 _request_id: Option<&str>,
970) -> AuthResult<i32> {
971 use sea_orm::EntityTrait;
972 // security_version 判定走主库:副本延迟会让刚吊销的旧版本短暂放行。
973 let row = crate::models::users::user_sessions::Entity::find_by_id(session_id)
974 .one(svc.db().write())
975 .await?
976 .ok_or(AuthError::InvalidCredentials)?;
977 Ok(row.security_version)
978}
979
980/// client_credentials 的 scope:缺省只发独立配置的「服务令牌 scope」子集,
981/// 显式请求也必须落在该子集内(`Err(())` 对应 `invalid_scope`)。
982fn service_token_scopes(record: &ClientRecord, requested: Option<&str>) -> Result<Vec<String>, ()> {
983 let allowed = &record.service_scopes;
984 match requested.map(str::trim).filter(|raw| !raw.is_empty()) {
985 Some(raw) => {
986 let requested: Vec<String> = raw.split_ascii_whitespace().map(str::to_owned).collect();
987 if requested
988 .iter()
989 .all(|scope| allowed.iter().any(|allowed| allowed == scope))
990 {
991 Ok(requested)
992 } else {
993 Err(())
994 }
995 }
996 None => Ok(allowed.clone()),
997 }
998}
999
1000#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
1001async fn exchange_client_credentials(
1002 svc: &AuthSvc,
1003 form: &TokenRequest,
1004 record: &ClientRecord,
1005 request_id: Option<&str>,
1006) -> HttpResponse {
1007 if !record.is_confidential() {
1008 return client_failure(ClientAuthFailure::UnauthorizedClient);
1009 }
1010 let scopes = match service_token_scopes(record, form.scope.as_deref()) {
1011 Ok(scopes) => scopes,
1012 Err(()) => return failure("invalid_scope", false),
1013 };
1014
1015 let now = crate::jwt::now_secs();
1016 let ttl = effective_access_ttl(svc);
1017 let claims = crate::jwt::AppClaims {
1018 is_admin: false,
1019 issuer: svc.signing().issuer().to_owned(),
1020 app_id: record.client_id.clone(),
1021 // 服务令牌没有用户主体;下游用 `token_type == "service"` 区分。
1022 user_id: 0,
1023 audience: record.audience().to_owned(),
1024 token_id: uuid::Uuid::now_v7().to_string(),
1025 issued_at: now,
1026 expires_at: now + ttl,
1027 security_version: 0,
1028 session_id: None,
1029 mfa_verified: false,
1030 scope: Some(scopes.clone()),
1031 username: None,
1032 token_type: "service".into(),
1033 };
1034 match svc.signing().sign(&claims) {
1035 Ok(token) => token_response(serde_json::json!({
1036 "access_token": token,
1037 "token_type": "Bearer",
1038 "expires_in": effective_access_ttl(svc),
1039 "scope": scopes.join(" "),
1040 })),
1041 Err(error) => auth_failure(error),
1042 }
1043}
1044
1045fn token_response(body: serde_json::Value) -> HttpResponse {
1046 HttpResponse::Ok()
1047 .insert_header(("cache-control", "no-store"))
1048 .insert_header(("pragma", "no-cache"))
1049 .json(body)
1050}
1051
1052fn client_ip(req: &HttpRequest) -> Option<String> {
1053 req.connection_info()
1054 .realip_remote_addr()
1055 .map(str::to_owned)
1056}
1057
1058fn user_agent(req: &HttpRequest) -> Option<String> {
1059 req.headers()
1060 .get(header::USER_AGENT)
1061 .and_then(|value| value.to_str().ok())
1062 .map(str::to_owned)
1063}
1064
1065#[derive(Debug, Deserialize, utoipa::ToSchema)]
1066pub struct RevokeRequest {
1067 pub token: String,
1068 #[serde(default)]
1069 pub client_id: Option<String>,
1070 #[serde(default)]
1071 pub client_secret: Option<String>,
1072}
1073
1074/// RFC 7009:撤销刷新令牌;未知令牌也返回 200,避免探测。
1075#[utoipa::path(
1076 post,
1077 path = "/api/v1/oauth/revoke",
1078 tag = "oauth",
1079 operation_id = "oauth_revoke",
1080 summary = "令牌撤销(RFC 7009)",
1081 request_body = RevokeRequest,
1082 responses(
1083 (status = 200, description = "已处理(无论令牌是否存在)"),
1084 (status = 401, description = "`invalid_client`")
1085 )
1086)]
1087#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
1088pub async fn revoke(
1089 svc: web::Data<AuthSvc>,
1090 web_cfg: web::Data<WebConfig>,
1091 request_id: crate::session::RequestId,
1092 req: HttpRequest,
1093 form: Result<web::Form<RevokeRequest>, actix_web::Error>,
1094) -> HttpResponse {
1095 let Ok(form) = form else {
1096 return cors(&svc, &web_cfg, &req, failure("invalid_request", false));
1097 };
1098 // RFC 7009:撤销也要过客户端认证,且只撤本客户端名下的令牌,禁止跨客户端撤销。
1099 let Some(record) = form
1100 .client_id
1101 .as_deref()
1102 .and_then(|client_id| svc.clients().get(client_id))
1103 else {
1104 return cors(
1105 &svc,
1106 &web_cfg,
1107 &req,
1108 client_failure(ClientAuthFailure::InvalidClient),
1109 );
1110 };
1111 if let Err(error) = check_client_secret(&record, form.client_secret.as_deref()) {
1112 return cors(&svc, &web_cfg, &req, client_failure(error));
1113 }
1114 if let Err(error) = crate::server::oauth::refresh::revoke(
1115 &svc,
1116 &form.token,
1117 &record.client_id,
1118 Some(request_id.as_str()),
1119 )
1120 .await
1121 {
1122 return cors(&svc, &web_cfg, &req, auth_failure(error));
1123 }
1124 cors(
1125 &svc,
1126 &web_cfg,
1127 &req,
1128 HttpResponse::Ok()
1129 .insert_header(("cache-control", "no-store"))
1130 .finish(),
1131 )
1132}
1133
1134fn auth_failure(error: AuthError) -> HttpResponse {
1135 if error.status_code().is_server_error() {
1136 tracing::error!(%error, "OAuth operation failed");
1137 failure("temporarily_unavailable", true)
1138 } else {
1139 failure("invalid_grant", false)
1140 }
1141}
1142
1143fn failure(error: &str, unavailable: bool) -> HttpResponse {
1144 let mut response = if unavailable {
1145 HttpResponse::ServiceUnavailable()
1146 } else {
1147 HttpResponse::BadRequest()
1148 };
1149 response
1150 .insert_header(("cache-control", "no-store"))
1151 .json(serde_json::json!({ "error": error }))
1152}
1153
1154fn authorization_error(svc: &AuthSvc, query: &AuthorizeRequest, error: &str) -> HttpResponse {
1155 redirect_error(
1156 &svc.auth().oauth.public_url,
1157 &query.redirect_uri,
1158 query.state.as_deref(),
1159 error,
1160 )
1161}
1162
1163/// 把错误 302 回客户端:`redirect_uri` 必须已通过注册校验,否则400(不向未验证地址重定向)。
1164fn redirect_error(
1165 issuer: &str,
1166 redirect_uri: &str,
1167 state: Option<&str>,
1168 error: &str,
1169) -> HttpResponse {
1170 let Ok(mut redirect) = Url::parse(redirect_uri) else {
1171 return failure("invalid_request", false);
1172 };
1173 redirect.query_pairs_mut().append_pair("error", error);
1174 if let Some(state) = state.filter(|state| !state.is_empty()) {
1175 redirect.query_pairs_mut().append_pair("state", state);
1176 }
1177 redirect.query_pairs_mut().append_pair("iss", issuer);
1178 HttpResponse::Found()
1179 .insert_header(("location", redirect.as_str()))
1180 .insert_header(("cache-control", "no-store"))
1181 .insert_header(("referrer-policy", "no-referrer"))
1182 .finish()
1183}
1184
1185/// 降级重定向目标:客户端存在且 `redirect_uri` 通过注册校验才重定向。
1186fn raw_error_target<'a>(
1187 raw: &'a RawAuthorizeParams,
1188 record: Option<&'a ClientRecord>,
1189 wildcard: bool,
1190) -> Option<(&'a str, Option<&'a str>)> {
1191 let redirect_uri = raw.redirect_uri.as_deref()?;
1192 let record = record?;
1193 if !record.redirect_allowed(redirect_uri, wildcard) {
1194 return None;
1195 }
1196 Some((redirect_uri, raw.state.as_deref()))
1197}
1198
1199/// 整体解析失败的降级路径:从原始查询串取 client_id / redirect_uri,
1200/// 验证通过就 302 携 `invalid_request` 回去,避免客户端只看到裸 400。
1201fn authorize_parse_failure(svc: &AuthSvc, query_string: &str) -> HttpResponse {
1202 let Ok(raw) = web::Query::<RawAuthorizeParams>::from_query(query_string) else {
1203 return failure("invalid_request", false);
1204 };
1205 let record = raw
1206 .client_id
1207 .as_deref()
1208 .and_then(|client_id| svc.clients().get(client_id));
1209 let wildcard = svc.auth().oauth.allow_wildcard_redirect;
1210 match raw_error_target(&raw, record.as_deref(), wildcard) {
1211 Some((redirect_uri, state)) => redirect_error(
1212 &svc.auth().oauth.public_url,
1213 redirect_uri,
1214 state,
1215 "invalid_request",
1216 ),
1217 None => failure("invalid_request", false),
1218 }
1219}
1220
1221pub fn cors(
1222 svc: &AuthSvc,
1223 web_cfg: &WebConfig,
1224 req: &HttpRequest,
1225 mut response: HttpResponse,
1226) -> HttpResponse {
1227 let Some(origin) = request_origin(req) else {
1228 return response;
1229 };
1230 let wildcard = web_cfg.allow_any_origin();
1231 if !wildcard && !web_allows(web_cfg, &origin) && !granted_origin(svc, req, &origin) {
1232 return response;
1233 }
1234 let value = if wildcard { String::from("*") } else { origin };
1235 let Ok(value) = HeaderValue::from_str(&value) else {
1236 return response;
1237 };
1238 let headers = response.headers_mut();
1239 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
1240 if !wildcard {
1241 headers.append(header::VARY, HeaderValue::from_static("origin"));
1242 }
1243 if web_cfg.cors_allow_credentials && !wildcard {
1244 headers.insert(
1245 header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
1246 HeaderValue::from_static("true"),
1247 );
1248 }
1249 let expose = web_cfg.expose_headers();
1250 if !expose.is_empty()
1251 && let Ok(value) = HeaderValue::from_str(&expose.join(", "))
1252 {
1253 headers.insert(header::ACCESS_CONTROL_EXPOSE_HEADERS, value);
1254 }
1255 response
1256}
1257
1258fn request_origin(req: &HttpRequest) -> Option<String> {
1259 req.headers()
1260 .get(header::ORIGIN)
1261 .and_then(|value| value.to_str().ok())
1262 .map(str::trim)
1263 .filter(|origin| !origin.is_empty())
1264 .map(str::to_owned)
1265}
1266
1267fn web_allows(web_cfg: &WebConfig, origin: &str) -> bool {
1268 crate::envelope::cors::web_allows(web_cfg, origin)
1269}
1270
1271fn granted_origin(svc: &AuthSvc, req: &HttpRequest, origin: &str) -> bool {
1272 if req.path() == "/api/v1/oauth/socket-tickets" {
1273 return svc
1274 .auth()
1275 .oauth
1276 .socket_origins
1277 .iter()
1278 .any(|value| value == origin);
1279 }
1280 svc.clients()
1281 .list()
1282 .iter()
1283 .flat_map(|client| client.redirect_uris.clone())
1284 .filter_map(|raw| Url::parse(&raw.replace("*.", "")).ok())
1285 .any(|url| url.origin().ascii_serialization() == origin)
1286}
1287
1288pub async fn preflight(
1289 svc: web::Data<AuthSvc>,
1290 web_cfg: web::Data<WebConfig>,
1291 req: HttpRequest,
1292) -> HttpResponse {
1293 let web_cfg = web_cfg.get_ref().clone();
1294 let response = HttpResponse::NoContent()
1295 .insert_header((
1296 header::ACCESS_CONTROL_ALLOW_METHODS.as_str(),
1297 web_cfg.allow_methods().join(", "),
1298 ))
1299 .insert_header((
1300 header::ACCESS_CONTROL_ALLOW_HEADERS.as_str(),
1301 web_cfg.allow_headers().join(", "),
1302 ))
1303 .insert_header((
1304 header::ACCESS_CONTROL_MAX_AGE.as_str(),
1305 web_cfg.cors_max_age_secs.to_string(),
1306 ))
1307 .finish();
1308 cors(&svc, &web_cfg, &req, response)
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313 use super::*;
1314 use crate::models::users::oauth_clients::{ClientKind, ClientSource};
1315 use crate::server::oauth::client::{ClientSpec, MIN_SECRET_LEN};
1316
1317 fn make_record(kind: ClientKind, secret: Option<&str>, scopes: &[&str]) -> ClientRecord {
1318 ClientRecord::from_spec(
1319 ClientSpec {
1320 client_id: "api",
1321 name: "Api",
1322 kind,
1323 audience: None,
1324 redirect_uris: vec!["https://app.example/callback".into()],
1325 post_logout_redirect_uris: vec![],
1326 backchannel_logout_uris: vec![],
1327 scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
1328 service_scopes: vec![],
1329 source: ClientSource::Config,
1330 enabled: true,
1331 secret,
1332 secret_hash: None,
1333 },
1334 false,
1335 )
1336 .unwrap()
1337 }
1338
1339 #[test]
1340 fn pkce_s256_uses_the_rfc7636_vector_and_rejects_changed_verifiers() {
1341 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1342 let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1343 assert!(valid_challenge(challenge));
1344 assert!(verifier_matches(verifier, challenge));
1345 assert!(!verifier_matches("short", challenge));
1346 assert!(!verifier_matches(&format!("{verifier}x"), challenge));
1347 }
1348
1349 #[test]
1350 fn registration_checks_issuer_redirect_transport_and_unique_client_ids() {
1351 let mut config = OAuthConfig {
1352 public_url: "https://auth.example".into(),
1353 clients: vec![crate::config::auth::OAuthClient {
1354 client_id: "api".into(),
1355 redirect_uris: vec!["https://app.example/oauth/callback".into()],
1356 scopes: vec!["guild".into()],
1357 ..Default::default()
1358 }],
1359 socket_origins: vec!["https://app.example".into()],
1360 rpc_key: "unit-test-rpc-key-0123456789abcd".into(),
1361 ..Default::default()
1362 };
1363 assert!(validate_config(&config, "https://auth.example").is_ok());
1364 // 空或过短的共享密钥让服务直接起不来。
1365 let configured = config.rpc_key.clone();
1366 config.rpc_key = "short".into();
1367 assert!(validate_config(&config, "https://auth.example").is_err());
1368 config.rpc_key = configured;
1369 assert!(validate_config(&config, "another-issuer").is_err());
1370 config.clients[0].redirect_uris = vec!["http://external.example/callback".into()];
1371 assert!(validate_config(&config, "https://auth.example").is_err());
1372 config.clients[0].redirect_uris = vec!["https://app.example/callback#fragment".into()];
1373 assert!(validate_config(&config, "https://auth.example").is_err());
1374 // 通配 redirect 只允许 https,且可以关闭。
1375 config.clients[0].redirect_uris = vec!["https://*.app.example/callback".into()];
1376 assert!(validate_config(&config, "https://auth.example").is_ok());
1377 config.allow_wildcard_redirect = false;
1378 assert!(validate_config(&config, "https://auth.example").is_err());
1379 }
1380
1381 #[test]
1382 fn prompt_parsing_accepts_known_values_and_rejects_mixed_none() {
1383 assert_eq!(parse_prompt(None).unwrap(), Prompt::default());
1384 assert!(parse_prompt(Some("login")).unwrap().login);
1385 assert!(parse_prompt(Some("consent")).unwrap().consent);
1386 let both = parse_prompt(Some("login consent")).unwrap();
1387 assert!(both.login && both.consent && !both.none);
1388 assert!(parse_prompt(Some("none")).unwrap().none);
1389 assert!(parse_prompt(Some("none login")).is_err());
1390 assert!(parse_prompt(Some("select_account")).is_err());
1391 assert!(parse_prompt(Some(" ")).is_err());
1392 }
1393
1394 #[test]
1395 fn access_ttl_is_clamped_to_the_supported_window() {
1396 // 边界与非法输入:超界配置值收敛到 60..=86400,`expires_in` 必须回写生效值。
1397 assert_eq!(clamp_access_ttl(0), 60);
1398 assert_eq!(clamp_access_ttl(-5), 60);
1399 assert_eq!(clamp_access_ttl(59), 60);
1400 assert_eq!(clamp_access_ttl(600), 600);
1401 assert_eq!(clamp_access_ttl(86_400), 86_400);
1402 assert_eq!(clamp_access_ttl(86_401), 86_400);
1403 }
1404
1405 #[test]
1406 fn client_credentials_scope_defaults_to_the_service_subset() {
1407 let mut record = make_record(
1408 ClientKind::Confidential,
1409 Some(&"s".repeat(MIN_SECRET_LEN)),
1410 &["openid", "profile"],
1411 );
1412 record.service_scopes = vec!["openid".into()];
1413 // 缺省下发独立配置的子集,不再发全部注册 scope。
1414 assert_eq!(service_token_scopes(&record, None).unwrap(), vec!["openid"]);
1415 assert_eq!(
1416 service_token_scopes(&record, Some(" ")).unwrap(),
1417 vec!["openid"]
1418 );
1419 // 显式请求也必须落在子集内。
1420 assert_eq!(
1421 service_token_scopes(&record, Some("openid")).unwrap(),
1422 vec!["openid"]
1423 );
1424 assert!(service_token_scopes(&record, Some("profile")).is_err());
1425 assert!(service_token_scopes(&record, Some("openid profile")).is_err());
1426 assert!(service_token_scopes(&record, Some("unknown")).is_err());
1427 // 未配置子集:不发任何 scope,也不接受显式请求。
1428 let plain = make_record(
1429 ClientKind::Confidential,
1430 Some(&"s".repeat(MIN_SECRET_LEN)),
1431 &["profile"],
1432 );
1433 assert!(service_token_scopes(&plain, None).unwrap().is_empty());
1434 assert!(service_token_scopes(&plain, Some("profile")).is_err());
1435 }
1436
1437 #[test]
1438 fn client_secret_policy_rejects_wrong_or_unexpected_credentials() {
1439 let secret = "s".repeat(MIN_SECRET_LEN);
1440 let confidential = make_record(ClientKind::Confidential, Some(&secret), &["openid"]);
1441 let public = make_record(ClientKind::Public, None, &["openid"]);
1442 // 机密客户端:正确 secret 通过,错误/缺失/空一律 invalid_client。
1443 assert!(check_client_secret(&confidential, Some(&secret)).is_ok());
1444 assert!(check_client_secret(&confidential, Some("wrong-secret")).is_err());
1445 assert!(check_client_secret(&confidential, None).is_err());
1446 assert!(check_client_secret(&confidential, Some("")).is_err());
1447 // public 客户端:只能裸 client_id,带任何 secret 都是 invalid_client(RFC 6749 §2.3)。
1448 assert!(check_client_secret(&public, None).is_ok());
1449 assert!(check_client_secret(&public, Some("")).is_ok());
1450 assert!(check_client_secret(&public, Some("anything")).is_err());
1451 assert!(check_client_secret(&public, Some(&secret)).is_err());
1452 }
1453
1454 #[test]
1455 fn authorize_state_is_optional_and_bounded() {
1456 // 规范对齐:state 可选;实现只保留上限防撑爆重定向 URL。
1457 assert!(valid_state(None));
1458 assert!(valid_state(Some("x")));
1459 assert!(valid_state(Some("")));
1460 assert!(valid_state(Some(&"a".repeat(MAX_STATE_LEN))));
1461 assert!(!valid_state(Some(&"a".repeat(MAX_STATE_LEN + 1))));
1462
1463 let base = "response_type=code&client_id=api&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback\
1464 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256";
1465 let parsed = web::Query::<AuthorizeRequest>::from_query(base).unwrap();
1466 assert!(parsed.state.is_none());
1467 let with_state = format!("{base}&state=abc");
1468 let parsed = web::Query::<AuthorizeRequest>::from_query(&with_state).unwrap();
1469 assert_eq!(parsed.state.as_deref(), Some("abc"));
1470 }
1471
1472 #[test]
1473 fn parse_failure_redirects_only_to_registered_redirect_uri() {
1474 let record = make_record(ClientKind::Public, None, &["openid"]);
1475 let raw = |query: &str| web::Query::<RawAuthorizeParams>::from_query(query).unwrap();
1476
1477 // 合法 client + 注册过的 redirect_uri → 允许302 回错误。
1478 let ok = raw("client_id=api&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&state=s");
1479 let target = raw_error_target(&ok, Some(&record), false);
1480 assert_eq!(target, Some(("https://app.example/callback", Some("s"))));
1481
1482 // 非法输入:缺 redirect_uri、客户端未注册、redirect_uri 未注册 → 都不重定向。
1483 let missing = raw("client_id=api");
1484 assert_eq!(raw_error_target(&missing, Some(&record), false), None);
1485 let unregistered = raw("client_id=api&redirect_uri=https%3A%2F%2Fevil.example%2Fx");
1486 assert_eq!(raw_error_target(&unregistered, Some(&record), false), None);
1487 let no_client = raw("redirect_uri=https%3A%2F%2Fapp.example%2Fcallback");
1488 assert_eq!(raw_error_target(&no_client, None, false), None);
1489 }
1490
1491 #[test]
1492 fn redirect_error_echoes_state_and_iss() {
1493 let response = redirect_error(
1494 "https://auth.example",
1495 "https://app.example/cb",
1496 Some("s1"),
1497 "invalid_request",
1498 );
1499 assert_eq!(response.status(), actix_web::http::StatusCode::FOUND);
1500 let location = response
1501 .headers()
1502 .get("location")
1503 .unwrap()
1504 .to_str()
1505 .unwrap();
1506 assert!(location.starts_with("https://app.example/cb?"));
1507 assert!(location.contains("error=invalid_request"));
1508 assert!(location.contains("state=s1"));
1509 assert!(location.contains("iss=https%3A%2F%2Fauth.example"));
1510 // state 缺省时不回传空参数。
1511 let response = redirect_error(
1512 "https://auth.example",
1513 "https://app.example/cb",
1514 None,
1515 "invalid_request",
1516 );
1517 let location = response
1518 .headers()
1519 .get("location")
1520 .unwrap()
1521 .to_str()
1522 .unwrap();
1523 assert!(!location.contains("state="));
1524 // 非法 redirect_uri 不重定向。
1525 let response = redirect_error("https://auth.example", "not a url", None, "invalid_request");
1526 assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST);
1527 }
1528}
Deleted lib/auth/src/server/oauth/refresh.rs
@@ -1,345 +0,0 @@
1//! 刷新令牌:落表 `oauth_refresh_tokens`,每次使用都轮换,命中已用令牌视为重放并撤销整条链。
2
3use chrono::{DateTime, Duration, Utc};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
6};
7use uuid::Uuid;
8
9use crate::models::users::{oauth_refresh_tokens as tokens, user_sessions};
10use crate::server::AuthSvc;
11use crate::server::errors::{AuthError, AuthResult};
12use crate::server::share::crypto::{random_b64url, sha256_hex};
13
14/// 明文令牌长度:48 字节随机 → 64 字符 base64url。
15const TOKEN_BYTES: usize = 48;
16
17pub struct Issued {
18 pub token: String,
19 pub expires_at: DateTime<Utc>,
20}
21
22struct Prepared {
23 token: String,
24 expires_at: DateTime<Utc>,
25}
26
27fn prepare(ttl_secs: i64, absolute_cap: Option<DateTime<Utc>>) -> Prepared {
28 let expires_at = capped_expiry(Utc::now(), ttl_secs, absolute_cap);
29 Prepared {
30 token: random_b64url(TOKEN_BYTES),
31 expires_at,
32 }
33}
34
35/// 轮换后的过期时间:取 `now + ttl` 与链绝对上限的较小者。绝对上限是链首发的
36/// `expires_at`(首发时刻 + ttl),因此轮换只换发新令牌,不延长整条链的绝对寿命。
37fn capped_expiry(
38 now: DateTime<Utc>,
39 ttl_secs: i64,
40 absolute_cap: Option<DateTime<Utc>>,
41) -> DateTime<Utc> {
42 let expires_at = now + Duration::seconds(ttl_secs.clamp(60, 31_536_000));
43 absolute_cap.map_or(expires_at, |cap| expires_at.min(cap))
44}
45
46#[allow(clippy::too_many_arguments)]
47async fn persist<C: ConnectionTrait>(
48 prepared: &Prepared,
49 user_id: i64,
50 session_id: Uuid,
51 client_id: &str,
52 scopes: &[String],
53 ip_address: Option<String>,
54 user_agent: Option<String>,
55 db: &C,
56 _request_id: Option<&str>,
57) -> AuthResult<()> {
58 tokens::ActiveModel {
59 token_hash: Set(hash(&prepared.token)),
60 user_id: Set(user_id),
61 session_id: Set(session_id),
62 client_id: Set(client_id.to_owned()),
63 scopes: Set(serde_json::json!(scopes)),
64 expires_at: Set(prepared.expires_at),
65 ip_address: Set(ip_address),
66 user_agent: Set(user_agent),
67 ..Default::default()
68 }
69 .insert(db)
70 .await?;
71 Ok(())
72}
73
74pub struct Grant<'a> {
75 pub user_id: i64,
76 pub session_id: &'a str,
77 pub client_id: &'a str,
78 pub scopes: &'a [String],
79 pub ip_address: Option<String>,
80 pub user_agent: Option<String>,
81}
82
83pub struct Rotated {
84 pub user_id: i64,
85 pub session_id: Uuid,
86 pub scopes: Vec<String>,
87 pub token: String,
88 pub expires_at: DateTime<Utc>,
89 /// 原始认证时间(会话建立时间),供 id_token 的 `auth_time` 使用(OIDC Core §12.2)。
90 pub auth_time: i64,
91}
92
93fn hash(token: &str) -> String {
94 sha256_hex(token.as_bytes())
95}
96
97/// 撤销归属判定:只有令牌名下的客户端才能撤它。
98fn owns_token(row_client_id: &str, client_id: &str) -> bool {
99 !client_id.is_empty() && row_client_id == client_id
100}
101
102#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
103pub async fn issue(
104 svc: &AuthSvc,
105 grant: Grant<'_>,
106 request_id: Option<&str>,
107) -> AuthResult<Issued> {
108 let session_id = Uuid::parse_str(grant.session_id).map_err(|_| AuthError::Unauthenticated)?;
109 // 链首发:now + ttl 就是本链的绝对上限。
110 let prepared = prepare(svc.auth().oauth.refresh_token_ttl_secs, None);
111
112 persist(
113 &prepared,
114 grant.user_id,
115 session_id,
116 grant.client_id,
117 grant.scopes,
118 grant.ip_address,
119 grant.user_agent,
120 svc.db().write(),
121 request_id,
122 )
123 .await?;
124
125 Ok(Issued {
126 token: prepared.token,
127 expires_at: prepared.expires_at,
128 })
129}
130
131/// 轮换:旧令牌一次性作废,新令牌在链绝对寿命内接续(到期时间不再重置)。
132#[tracing::instrument(skip_all, fields(client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
133pub async fn rotate(
134 svc: &AuthSvc,
135 raw_token: &str,
136 client_id: &str,
137 ip_address: Option<String>,
138 user_agent: Option<String>,
139 request_id: Option<&str>,
140) -> AuthResult<Rotated> {
141 let token_hash = hash(raw_token);
142 let row = tokens::Entity::find()
143 .filter(tokens::Column::TokenHash.eq(token_hash.as_str()))
144 .one(svc.db().read())
145 .await?
146 .ok_or(AuthError::InvalidCredentials)?;
147
148 if row.client_id != client_id {
149 return Err(AuthError::InvalidCredentials);
150 }
151
152 let now = Utc::now();
153 let replayed = row.revoked_at.is_some() || row.replaced_by.is_some();
154 if replayed {
155 // 已用令牌再次出现:说明泄漏,整条链(同会话同客户端)立刻失效。
156 revoke_chain(svc, row.session_id, client_id, request_id).await?;
157 return Err(AuthError::InvalidCredentials);
158 }
159 if row.expires_at <= now {
160 tokens::Entity::update_many()
161 .col_expr(
162 tokens::Column::RevokedAt,
163 sea_orm::sea_query::Expr::value(now),
164 )
165 .filter(tokens::Column::Id.eq(row.id))
166 .filter(tokens::Column::RevokedAt.is_null())
167 .exec(svc.db().write())
168 .await?;
169 return Err(AuthError::InvalidCredentials);
170 }
171
172 // 会话本身必须仍然有效(登出、security_version 变更、过期都会让它失效)。
173 let session = user_sessions::Entity::find_by_id(row.session_id)
174 .one(svc.db().read())
175 .await?
176 .ok_or(AuthError::InvalidCredentials)?;
177 svc.check_session(
178 row.user_id,
179 session.security_version,
180 &row.session_id.to_string(),
181 request_id,
182 )
183 .await
184 .map_err(|_| AuthError::InvalidCredentials)?;
185
186 let scopes: Vec<String> = serde_json::from_value(row.scopes.clone()).unwrap_or_default();
187
188 // 先预生成新令牌,再用条件更新原子抢占旧行(revoked_at/replaced_by 仍为空才算赢)。
189 // 输掉竞争说明旧令牌已被并发使用,按重放处理;赢家的 replaced_by 先于新令牌落库,
190 // 消除「先签发后标记」窗口内两个令牌并存的竞态。
191 // 绝对上限取旧令牌的 expires_at(链首发 + ttl),轮换不延长绝对寿命。
192 let prepared = prepare(
193 svc.auth().oauth.refresh_token_ttl_secs,
194 Some(row.expires_at),
195 );
196 // 抢占与落库必须在同一事务:否则 persist 失败会留下指向不存在新令牌的 replaced_by,
197 // 客户端重试被判重放,整条链被撤销且无法恢复。
198 let txn = svc.db().write().begin().await?;
199 let claim = tokens::Entity::update_many()
200 .col_expr(
201 tokens::Column::RevokedAt,
202 sea_orm::sea_query::Expr::value(now),
203 )
204 .col_expr(
205 tokens::Column::ReplacedBy,
206 sea_orm::sea_query::Expr::value(hash(&prepared.token)),
207 )
208 .filter(tokens::Column::Id.eq(row.id))
209 .filter(tokens::Column::RevokedAt.is_null())
210 .filter(tokens::Column::ReplacedBy.is_null())
211 .exec(&txn)
212 .await?;
213 if claim.rows_affected == 0 {
214 txn.rollback().await?;
215 revoke_chain(svc, row.session_id, client_id, request_id).await?;
216 return Err(AuthError::InvalidCredentials);
217 }
218
219 persist(
220 &prepared,
221 row.user_id,
222 row.session_id,
223 client_id,
224 &scopes,
225 ip_address,
226 user_agent,
227 &txn,
228 request_id,
229 )
230 .await?;
231 txn.commit().await?;
232 Ok(Rotated {
233 user_id: row.user_id,
234 session_id: row.session_id,
235 scopes,
236 token: prepared.token,
237 expires_at: prepared.expires_at,
238 auth_time: session.created_at.timestamp(),
239 })
240}
241
242/// 撤销一条链上所有仍在有效期的令牌(重放检测、登出、改密都会用到)。
243#[tracing::instrument(skip_all, fields(session_id = %session_id, client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
244pub async fn revoke_chain(
245 svc: &AuthSvc,
246 session_id: Uuid,
247 client_id: &str,
248 request_id: Option<&str>,
249) -> AuthResult<u64> {
250 let result = tokens::Entity::update_many()
251 .col_expr(
252 tokens::Column::RevokedAt,
253 sea_orm::sea_query::Expr::value(Utc::now()),
254 )
255 .filter(tokens::Column::SessionId.eq(session_id))
256 .filter(tokens::Column::ClientId.eq(client_id))
257 .filter(tokens::Column::RevokedAt.is_null())
258 .exec(svc.db().write())
259 .await?;
260 Ok(result.rows_affected)
261}
262
263/// 会话级撤销:登出时把该会话在所有客户端下的刷新令牌一起作废。
264#[tracing::instrument(skip_all, fields(session_id = %session_id, request_id = %request_id.unwrap_or_default()))]
265pub async fn revoke_session(
266 svc: &AuthSvc,
267 session_id: Uuid,
268 request_id: Option<&str>,
269) -> AuthResult<u64> {
270 let result = tokens::Entity::update_many()
271 .col_expr(
272 tokens::Column::RevokedAt,
273 sea_orm::sea_query::Expr::value(Utc::now()),
274 )
275 .filter(tokens::Column::SessionId.eq(session_id))
276 .filter(tokens::Column::RevokedAt.is_null())
277 .exec(svc.db().write())
278 .await?;
279 Ok(result.rows_affected)
280}
281
282/// RFC 7009 撤销端点:只撤刷新令牌,未知令牌按成功处理(避免探测)。
283/// `client_id` 必填且令牌必须属于该客户端,禁止跨客户端撤销。
284#[tracing::instrument(skip_all, fields(client_id = %client_id, request_id = %request_id.unwrap_or_default()))]
285pub async fn revoke(
286 svc: &AuthSvc,
287 raw_token: &str,
288 client_id: &str,
289 request_id: Option<&str>,
290) -> AuthResult<()> {
291 let row = tokens::Entity::find()
292 .filter(tokens::Column::TokenHash.eq(hash(raw_token).as_str()))
293 .one(svc.db().write())
294 .await?;
295 let Some(row) = row else {
296 return Ok(());
297 };
298 if !owns_token(&row.client_id, client_id) {
299 return Ok(());
300 }
301 // 单行撤销会留下已经发出的后继令牌,必须整条链作废。
302 revoke_chain(svc, row.session_id, &row.client_id, request_id).await?;
303 Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn at(secs: i64) -> DateTime<Utc> {
311 DateTime::from_timestamp(secs, 0).unwrap()
312 }
313
314 #[test]
315 fn rotation_never_extends_the_absolute_deadline() {
316 let now = at(1_000);
317 // 无上限:now + ttl。
318 assert_eq!(capped_expiry(now, 600, None), at(1_600));
319 // 链上限更早:取上限,轮换不续命。
320 assert_eq!(capped_expiry(now, 600, Some(at(1_300))), at(1_300));
321 // 链上限更晚:仍受 ttl 约束。
322 assert_eq!(capped_expiry(now, 600, Some(at(9_999))), at(1_600));
323 // 相等边界。
324 assert_eq!(capped_expiry(now, 600, Some(at(1_600))), at(1_600));
325 }
326
327 #[test]
328 fn ttl_is_clamped_to_supported_range() {
329 let now = at(0);
330 assert_eq!(capped_expiry(now, 0, None), at(60));
331 assert_eq!(capped_expiry(now, -5, None), at(60));
332 assert_eq!(capped_expiry(now, 31_536_001, None), at(31_536_000));
333 // 非法 ttl 也不能把链上限推出去。
334 assert_eq!(capped_expiry(now, -5, Some(at(30))), at(30));
335 }
336
337 #[test]
338 fn revocation_is_bound_to_the_owning_client() {
339 assert!(owns_token("api", "api"));
340 // 跨客户端与空 client_id 一律不生效。
341 assert!(!owns_token("api", "other"));
342 assert!(!owns_token("api", ""));
343 assert!(!owns_token("", ""));
344 }
345}
Deleted lib/auth/src/server/oauth/userinfo.rs
@@ -1,153 +0,0 @@
1//! OIDC userinfo:`Authorization: Bearer <access_token>`,claims 按令牌 scope 裁剪。
2//!
3//! 校验复用认证中间件同一条链路:本地验签 → 受众 → 会话存活(`user_sessions`)。
4
5use actix_web::{HttpRequest, HttpResponse, web};
6use serde_json::json;
7
8use crate::server::AuthSvc;
9
10/// 只接受 `Authorization: Bearer` 头承载令牌(RFC 6750 §2.3:查询串会被日志/代理记录,禁止)。
11fn bearer(req: &HttpRequest) -> Option<String> {
12 if let Some(value) = req
13 .headers()
14 .get(actix_web::http::header::AUTHORIZATION)
15 .and_then(|value| value.to_str().ok())
16 && let Some((scheme, rest)) = value.split_once(' ')
17 && scheme.eq_ignore_ascii_case("bearer")
18 {
19 let token = rest.trim();
20 if !token.is_empty() {
21 return Some(token.to_owned());
22 }
23 }
24 None
25}
26
27fn unauthorized(error: &str) -> HttpResponse {
28 HttpResponse::Unauthorized()
29 .insert_header((
30 "www-authenticate",
31 format!("Bearer error=\"{error}\", error_description=\"invalid access token\""),
32 ))
33 .insert_header(("cache-control", "no-store"))
34 .json(json!({ "error": error }))
35}
36
37#[utoipa::path(
38 get,
39 path = "/api/v1/oauth/userinfo",
40 tag = "oauth",
41 operation_id = "oauth_userinfo",
42 summary = "OIDC userinfo",
43 description = "需要 `openid` scope;`profile` 返回 `preferred_username` / `name`,`email` 返回 `email` / `email_verified`。令牌只接受 `Authorization: Bearer` 头(RFC 6750 §2.3),查询串携带一律 401。",
44 responses(
45 (status = 200, description = "标准 claims", body = serde_json::Value),
46 (status = 401, description = "`invalid_token`"),
47 (status = 403, description = "`insufficient_scope`")
48 )
49)]
50#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
51pub async fn userinfo(
52 _svc: web::Data<AuthSvc>,
53 req: HttpRequest,
54 request_id: crate::session::RequestId,
55) -> HttpResponse {
56 let Some(token) = bearer(&req) else {
57 return unauthorized("invalid_token");
58 };
59 let claims = match crate::jwt::authority::authenticate(&token).await {
60 Ok(claims) => claims,
61 Err(error) => {
62 tracing::debug!(%error, "userinfo rejected an access token");
63 return unauthorized("invalid_token");
64 }
65 };
66 if crate::jwt::scope::require(&claims, crate::jwt::scope::OPENID).is_err() {
67 return HttpResponse::Forbidden()
68 .insert_header((
69 "www-authenticate",
70 "Bearer error=\"insufficient_scope\", scope=\"openid\"",
71 ))
72 .insert_header(("cache-control", "no-store"))
73 .json(json!({ "error": "insufficient_scope" }));
74 }
75
76 let user = match crate::jwt::authority::user(claims.user_id).await {
77 Ok(user) => user,
78 Err(error) => {
79 tracing::debug!(%error, "userinfo could not load the user");
80 return unauthorized("invalid_token");
81 }
82 };
83
84 let scopes = claims.scope.clone().unwrap_or_default();
85 let has = |scope: &str| scopes.iter().any(|value| value == scope);
86 let mut body = json!({ "sub": user.id.to_string() });
87 if has("profile") {
88 body["preferred_username"] = json!(user.username);
89 if let Some(displayname) = &user.displayname {
90 body["name"] = json!(displayname);
91 }
92 if let Some(birth_date) = user.birth_date {
93 body["birthdate"] = json!(birth_date.to_string());
94 }
95 body["updated_at"] = json!(user.updated_at.timestamp());
96 }
97 if has("email") {
98 body["email"] = json!(user.email);
99 body["email_verified"] = json!(user.email_verified_at.is_some());
100 }
101
102 HttpResponse::Ok()
103 .insert_header(("cache-control", "no-store"))
104 .insert_header(("pragma", "no-cache"))
105 .json(body)
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use actix_web::test::TestRequest;
112
113 #[test]
114 fn bearer_comes_only_from_the_authorization_header() {
115 // 正常:Authorization 头。
116 let req = TestRequest::default()
117 .insert_header(("authorization", "Bearer tok1"))
118 .to_http_request();
119 assert_eq!(bearer(&req).as_deref(), Some("tok1"));
120
121 // 回归:查询串令牌一律拒绝(RFC 6750 §2.3),不落日志/代理。
122 let req = TestRequest::default()
123 .uri("/api/v1/oauth/userinfo?access_token=leak")
124 .to_http_request();
125 assert_eq!(bearer(&req), None);
126
127 // 非法输入:错误 scheme、空令牌、畸形头。
128 let req = TestRequest::default()
129 .insert_header(("authorization", "Basic dXNlcjpwYXNz"))
130 .to_http_request();
131 assert_eq!(bearer(&req), None);
132 let req = TestRequest::default()
133 .insert_header(("authorization", "Bearer "))
134 .to_http_request();
135 assert_eq!(bearer(&req), None);
136 let req = TestRequest::default().to_http_request();
137 assert_eq!(bearer(&req), None);
138 }
139
140 #[test]
141 fn unauthorized_carries_www_authenticate_and_no_store() {
142 let response = unauthorized("invalid_token");
143 assert_eq!(response.status(), actix_web::http::StatusCode::UNAUTHORIZED);
144 let challenge = response
145 .headers()
146 .get("www-authenticate")
147 .unwrap()
148 .to_str()
149 .unwrap();
150 assert!(challenge.contains("Bearer"));
151 assert!(challenge.contains("invalid_token"));
152 }
153}
Modified lib/auth/src/server/auth_svc.rs View lib/auth/src/server/auth_svc.rs
@@ -46,7 +46,6 @@
4646 apps: Arc<AppRegistry>,
4747 signing: Arc<crate::server::keys::SigningState>,
4848 jwt: crate::jwt::JwtState,
49 clients: Arc<crate::server::oauth::client::ClientRegistry>,
5049 session_max_age_secs: u64,
5150}
5251
@@ -63,13 +62,21 @@
6362 jwt_config: JwtConfig,
6463 session_max_age_secs: u64,
6564 ) -> AuthResult<Self> {
66 crate::server::oauth::protocol::validate_config(&auth.oauth, &jwt_config.issuer)?;
65 let rpc_key = auth.rpc_key.trim();
66 if !rpc_key.is_empty() && rpc_key.len() < 32 {
67 return Err(AuthError::Unavailable(
68 "auth.rpc_key must be at least 32 characters".to_owned(),
69 ));
70 }
71 if rpc_key.starts_with("REPLACE_WITH_") {
72 return Err(AuthError::Unavailable(
73 "auth.rpc_key is still set to the placeholder value".to_owned(),
74 ));
75 }
6776 let signing = crate::server::keys::SigningState::from_config(&jwt_config)?;
6877 let jwt_state = crate::jwt::JwtState::load(&jwt_config)
6978 .await
7079 .map_err(|error| AuthError::Unavailable(error.to_string()))?;
71 // 客户端注册表:配置种子落库后加载,之后由管理接口维护。
72 let clients = crate::server::oauth::client::ClientRegistry::load(&db, &auth.oauth).await?;
7380 let svc = Self {
7481 db,
7582 cache,
@@ -80,7 +87,6 @@
8087 apps: Arc::new(apps),
8188 signing: Arc::new(signing),
8289 jwt: jwt_state,
83 clients: Arc::new(clients),
8490 session_max_age_secs: session_max_age_secs.max(1),
8591 };
8692 crate::jwt::authority::install(Arc::new(svc.clone()))
@@ -122,10 +128,6 @@
122128
123129 pub fn events(&self) -> Option<&EventHandler> {
124130 self.events.as_ref()
125 }
126
127 pub fn clients(&self) -> &crate::server::oauth::client::ClientRegistry {
128 &self.clients
129131 }
130132
131133 /// 逻辑会话有效期(秒):启动时从配置读取一次,热路径避免重读配置文件。
Modified lib/auth/src/server/keys.rs View lib/auth/src/server/keys.rs
@@ -1,6 +1,5 @@
11use crate::config::jwt::JwtConfig;
22use crate::jwt::{Jwks, JwtError, SigningKey, sign, signing_key};
3use actix_web::web;
43use serde::Serialize;
54
65use crate::server::errors::{AuthError, AuthResult};
@@ -59,15 +58,6 @@
5958 pub fn sign<T: Serialize>(&self, claims: &T) -> AuthResult<String> {
6059 sign(&self.key, &self.kid, claims).map_err(map)
6160 }
62
63 /// 签 id_token:标题 `typ` 为 `JWT`,与访问令牌的 `at+jwt` 区分。
64 pub fn sign_id_token<T: Serialize>(&self, claims: &T) -> AuthResult<String> {
65 crate::jwt::sign_typed(&self.key, &self.kid, crate::jwt::ID_TOKEN_TYP, claims).map_err(map)
66 }
67}
68
69pub async fn jwks(svc: web::Data<crate::server::AuthSvc>) -> web::Json<Jwks> {
70 web::Json(svc.signing().jwks().clone())
7161}
7262
7363fn map(error: JwtError) -> AuthError {
Modified lib/auth/src/server/migrate.rs View lib/auth/src/server/migrate.rs
@@ -5,6 +5,11 @@
55
66pub async fn sync_schema(db: &crate::db::DbConn) -> AuthResult<()> {
77 crate::db::migrate::EntityFirst::new(&[USER_PREFIX, SPACE_PREFIX])
8 .before([
9 "DROP TABLE IF EXISTS oauth_refresh_tokens",
10 "DROP TABLE IF EXISTS oauth_consents",
11 "DROP TABLE IF EXISTS oauth_clients",
12 ])
813 .after([
914 // 邮箱查询一律走 lower(email),表达式唯一索引把大小写变体挡在数据库层。
1015 "CREATE UNIQUE INDEX IF NOT EXISTS auth_users_lower_email_idx ON auth_users (lower(email))",
Modified lib/auth/src/server/mod.rs View lib/auth/src/server/mod.rs
@@ -5,7 +5,6 @@
55pub mod http;
66pub mod keys;
77pub mod migrate;
8pub mod oauth;
98pub mod pow;
109pub mod share;
1110
@@ -19,4 +18,3 @@
1918 access_token, app_token, challenge, health, login, logout, me, openapi, organization,
2019 password_reset, register, router, socket_ticket, ssh_key, token,
2120};
22pub use oauth::{client, protocol};
Modified lib/auth/src/lib.rs View lib/auth/src/lib.rs
@@ -10,8 +10,6 @@
1010pub mod runtime;
1111pub mod server;
1212
13pub use runtime as oauth;
14
1513pub use cache;
1614pub use config;
1715pub use crypt;
Modified lib/auth/Cargo.toml View lib/auth/Cargo.toml
@@ -61,7 +61,6 @@
6161 "trace",
6262] }
6363opentelemetry_sdk = "0.32.1"
64percent-encoding = "2.3.2"
6564prost = "0.14.4"
6665prost-types = "0.14.4"
6766rand = "0.10.3"
Modified lib/auth/OpenApi.Auth.json View lib/auth/OpenApi.Auth.json
@@ -10,34 +10,6 @@
1010 "version": "1.0.0"
1111 },
1212 "paths": {
13 "/.well-known/oauth-authorization-server": {
14 "get": {
15 "tags": [
16 "oauth"
17 ],
18 "summary": "OAuth 授权服务器元数据(含 OIDC 字段)",
19 "operationId": "oauth_metadata",
20 "responses": {
21 "200": {
22 "description": "RFC 8414 元数据"
23 }
24 }
25 }
26 },
27 "/.well-known/openid-configuration": {
28 "get": {
29 "tags": [
30 "oauth"
31 ],
32 "summary": "OIDC 发现文档",
33 "operationId": "oauth_openid_configuration",
34 "responses": {
35 "200": {
36 "description": "OpenID Provider 元数据"
37 }
38 }
39 }
40 },
4113 "/api/v1/oauth/access-tokens": {
4214 "get": {
4315 "tags": [
@@ -306,7 +278,7 @@
306278 "auth"
307279 ],
308280 "summary": "签发内部应用令牌",
309 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。",
281 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。",
310282 "operationId": "auth_app_token_issue",
311283 "requestBody": {
312284 "content": {
@@ -414,117 +386,6 @@
414386 }
415387 }
416388 },
417 "/api/v1/oauth/authorize": {
418 "get": {
419 "tags": [
420 "oauth"
421 ],
422 "summary": "授权端点(授权码 + PKCE S256)",
423 "description": "未登录跳到 `/login`;首次授权跳 `/consent`;`prompt=none` 直接返回 `login_required` / `consent_required`。",
424 "operationId": "oauth_authorize",
425 "parameters": [
426 {
427 "name": "response_type",
428 "in": "query",
429 "description": "固定为 `code`",
430 "required": true,
431 "schema": {
432 "type": "string"
433 }
434 },
435 {
436 "name": "client_id",
437 "in": "query",
438 "description": "已注册的客户端 id",
439 "required": true,
440 "schema": {
441 "type": "string"
442 }
443 },
444 {
445 "name": "redirect_uri",
446 "in": "query",
447 "description": "已注册的回调地址,支持子域通配",
448 "required": true,
449 "schema": {
450 "type": "string"
451 }
452 },
453 {
454 "name": "state",
455 "in": "query",
456 "description": "16..=512 字符的不透明串",
457 "required": true,
458 "schema": {
459 "type": "string"
460 }
461 },
462 {
463 "name": "code_challenge",
464 "in": "query",
465 "description": "PKCE S256 challenge(43 字符 base64url)",
466 "required": true,
467 "schema": {
468 "type": "string"
469 }
470 },
471 {
472 "name": "code_challenge_method",
473 "in": "query",
474 "description": "固定为 `S256`",
475 "required": true,
476 "schema": {
477 "type": "string"
478 }
479 },
480 {
481 "name": "scope",
482 "in": "query",
483 "description": "空格分隔,必须 ⊆ 客户端登记 scope",
484 "required": false,
485 "schema": {
486 "type": "string"
487 }
488 },
489 {
490 "name": "nonce",
491 "in": "query",
492 "description": "OIDC nonce,原样写入 id_token",
493 "required": false,
494 "schema": {
495 "type": "string"
496 }
497 },
498 {
499 "name": "prompt",
500 "in": "query",
501 "description": "`none` / `login` / `consent`",
502 "required": false,
503 "schema": {
504 "type": "string"
505 }
506 },
507 {
508 "name": "max_age",
509 "in": "query",
510 "description": "会话最大年龄(秒),超出就重新登录",
511 "required": false,
512 "schema": {
513 "type": "integer",
514 "format": "int64"
515 }
516 }
517 ],
518 "responses": {
519 "302": {
520 "description": "重定向回 `redirect_uri`,带 `code` / `error` / `state` / `iss`"
521 },
522 "400": {
523 "description": "`invalid_request`(客户端或回调未注册)"
524 }
525 }
526 }
527 },
528389 "/api/v1/oauth/challenge": {
529390 "post": {
530391 "tags": [
@@ -571,69 +432,6 @@
571432 }
572433 }
573434 },
574 "/api/v1/oauth/consent": {
575 "get": {
576 "tags": [
577 "oauth"
578 ],
579 "summary": "同意页所需的客户端与 scope 预览",
580 "operationId": "oauth_consent_preview",
581 "responses": {
582 "200": {
583 "description": "客户端名称与待授权 scope",
584 "content": {
585 "application/json": {
586 "schema": {
587 "$ref": "#/components/schemas/ConsentPreview"
588 }
589 }
590 }
591 },
592 "400": {
593 "description": "`invalid_request`"
594 },
595 "401": {
596 "description": "`login_required`"
597 }
598 }
599 },
600 "post": {
601 "tags": [
602 "oauth"
603 ],
604 "summary": "提交同意决定",
605 "description": "授权参数从查询串读取;`decision=allow` 落库并回 `/api/v1/oauth/authorize?...`,`deny` 回客户端并带 `error=access_denied`。",
606 "operationId": "oauth_consent_decide",
607 "requestBody": {
608 "content": {
609 "application/json": {
610 "schema": {
611 "$ref": "#/components/schemas/ConsentForm"
612 }
613 }
614 },
615 "required": true
616 },
617 "responses": {
618 "200": {
619 "description": "下一步地址",
620 "content": {
621 "application/json": {
622 "schema": {
623 "$ref": "#/components/schemas/ConsentDecision"
624 }
625 }
626 }
627 },
628 "400": {
629 "description": "`invalid_request`"
630 },
631 "401": {
632 "description": "`login_required`"
633 }
634 }
635 }
636 },
637435 "/api/v1/oauth/login": {
638436 "post": {
639437 "tags": [
@@ -1082,62 +880,6 @@
1082880 }
1083881 }
1084882 },
1085 "/api/v1/oauth/logout": {
1086 "get": {
1087 "tags": [
1088 "oauth"
1089 ],
1090 "summary": "单点登出(RP-Initiated Logout)",
1091 "description": "撤销当前会话与其全客户端刷新令牌,并向 `backchannel_logout_uris` 发 `logout_token`;最后回跳已注册的 `post_logout_redirect_uri`。GET 带验证通过的 `id_token_hint`(签名有效且绑定当前会话)时免确认直接登出,否则返回确认页,确认后 POST 完成登出。",
1092 "operationId": "oauth_logout",
1093 "parameters": [
1094 {
1095 "name": "id_token_hint",
1096 "in": "query",
1097 "description": "之前签发的 id_token(校验签名且须绑定当前会话)",
1098 "required": false,
1099 "schema": {
1100 "type": "string"
1101 }
1102 },
1103 {
1104 "name": "post_logout_redirect_uri",
1105 "in": "query",
1106 "description": "必须在客户端注册过",
1107 "required": false,
1108 "schema": {
1109 "type": "string"
1110 }
1111 },
1112 {
1113 "name": "state",
1114 "in": "query",
1115 "description": "原样回传",
1116 "required": false,
1117 "schema": {
1118 "type": "string"
1119 }
1120 },
1121 {
1122 "name": "client_id",
1123 "in": "query",
1124 "description": "无 id_token_hint 时可用",
1125 "required": false,
1126 "schema": {
1127 "type": "string"
1128 }
1129 }
1130 ],
1131 "responses": {
1132 "200": {
1133 "description": "GET 未验证 hint 时返回确认页"
1134 },
1135 "302": {
1136 "description": "回到 `post_logout_redirect_uri`,否则回站点首页"
1137 }
1138 }
1139 }
1140 },
1141883 "/api/v1/oauth/me": {
1142884 "get": {
1143885 "tags": [
@@ -1581,33 +1323,6 @@
15811323 }
15821324 }
15831325 },
1584 "/api/v1/oauth/revoke": {
1585 "post": {
1586 "tags": [
1587 "oauth"
1588 ],
1589 "summary": "令牌撤销(RFC 7009)",
1590 "operationId": "oauth_revoke",
1591 "requestBody": {
1592 "content": {
1593 "application/json": {
1594 "schema": {
1595 "$ref": "#/components/schemas/RevokeRequest"
1596 }
1597 }
1598 },
1599 "required": true
1600 },
1601 "responses": {
1602 "200": {
1603 "description": "已处理(无论令牌是否存在)"
1604 },
1605 "401": {
1606 "description": "`invalid_client`"
1607 }
1608 }
1609 }
1610 },
16111326 "/api/v1/oauth/session/logout": {
16121327 "post": {
16131328 "tags": [
@@ -1657,7 +1372,7 @@
16571372 "auth"
16581373 ],
16591374 "summary": "用会话换取平台访问令牌",
1660 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。",
1375 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。",
16611376 "operationId": "auth_token_issue",
16621377 "requestBody": {
16631378 "content": {
@@ -1988,68 +1703,6 @@
19881703 }
19891704 }
19901705 },
1991 "/api/v1/oauth/token": {
1992 "post": {
1993 "tags": [
1994 "oauth"
1995 ],
1996 "summary": "令牌端点",
1997 "description": "支持 `authorization_code`(PKCE)、`refresh_token`(轮换 + 重放检测)、`client_credentials`(机密客户端)。带 `openid` scope 时响应额外包含 `id_token`。",
1998 "operationId": "oauth_token",
1999 "requestBody": {
2000 "content": {
2001 "application/json": {
2002 "schema": {
2003 "$ref": "#/components/schemas/TokenRequest"
2004 }
2005 }
2006 },
2007 "required": true
2008 },
2009 "responses": {
2010 "200": {
2011 "description": "令牌响应",
2012 "content": {
2013 "application/json": {
2014 "schema": {}
2015 }
2016 }
2017 },
2018 "400": {
2019 "description": "`invalid_grant` / `invalid_scope` / `unsupported_grant_type`"
2020 },
2021 "401": {
2022 "description": "`invalid_client`"
2023 }
2024 }
2025 }
2026 },
2027 "/api/v1/oauth/userinfo": {
2028 "get": {
2029 "tags": [
2030 "oauth"
2031 ],
2032 "summary": "OIDC userinfo",
2033 "description": "需要 `openid` scope;`profile` 返回 `preferred_username` / `name`,`email` 返回 `email` / `email_verified`。令牌只接受 `Authorization: Bearer` 头(RFC 6750 §2.3),查询串携带一律 401。",
2034 "operationId": "oauth_userinfo",
2035 "responses": {
2036 "200": {
2037 "description": "标准 claims",
2038 "content": {
2039 "application/json": {
2040 "schema": {}
2041 }
2042 }
2043 },
2044 "401": {
2045 "description": "`invalid_token`"
2046 },
2047 "403": {
2048 "description": "`insufficient_scope`"
2049 }
2050 }
2051 }
2052 },
20531706 "/api/v1/organizations": {
20541707 "get": {
20551708 "tags": [
@@ -4532,56 +4185,6 @@
45324185 }
45334186 }
45344187 },
4535 "ConsentDecision": {
4536 "type": "object",
4537 "required": [
4538 "redirect"
4539 ],
4540 "properties": {
4541 "redirect": {
4542 "type": "string",
4543 "description": "下一步跳转:允许则回 `/api/v1/oauth/authorize?...`,拒绝则回客户端并带 `error=access_denied`。"
4544 }
4545 }
4546 },
4547 "ConsentForm": {
4548 "type": "object",
4549 "description": "同意页提交体:授权参数在查询串里,请求体只带决定。",
4550 "required": [
4551 "decision"
4552 ],
4553 "properties": {
4554 "decision": {
4555 "type": "string"
4556 }
4557 }
4558 },
4559 "ConsentPreview": {
4560 "type": "object",
4561 "required": [
4562 "client_id",
4563 "client_name",
4564 "scopes",
4565 "username"
4566 ],
4567 "properties": {
4568 "client_id": {
4569 "type": "string"
4570 },
4571 "client_name": {
4572 "type": "string"
4573 },
4574 "scopes": {
4575 "type": "array",
4576 "items": {
4577 "type": "string"
4578 }
4579 },
4580 "username": {
4581 "type": "string"
4582 }
4583 }
4584 },
45854188 "CreateInviteBody": {
45864189 "type": "object",
45874190 "properties": {
@@ -7997,29 +7600,6 @@
79977600 }
79987601 }
79997602 },
8000 "RevokeRequest": {
8001 "type": "object",
8002 "required": [
8003 "token"
8004 ],
8005 "properties": {
8006 "client_id": {
8007 "type": [
8008 "string",
8009 "null"
8010 ]
8011 },
8012 "client_secret": {
8013 "type": [
8014 "string",
8015 "null"
8016 ]
8017 },
8018 "token": {
8019 "type": "string"
8020 }
8021 }
8022 },
80237603 "RoleSummary": {
80247604 "type": "object",
80257605 "required": [
@@ -8678,4 +8258,4 @@
86788258 }
86798259 }
86808260 }
8681}
No newline at end of file
8261}
Modified lib/config/src/auth.rs View lib/config/src/auth.rs
@@ -3,137 +3,6 @@
33
44pub const TOTP_KEY_MIN_BYTES: usize = 32;
55
6#[derive(Clone, Deserialize)]
7#[serde(default)]
8pub struct OAuthConfig {
9 pub public_url: String,
10 pub clients: Vec<OAuthClient>,
11 pub socket_origins: Vec<String>,
12 #[serde(default = "default_access_token_ttl")]
13 pub access_token_ttl_secs: i64,
14 #[serde(default = "default_id_token_ttl")]
15 pub id_token_ttl_secs: i64,
16 #[serde(default = "default_refresh_token_ttl")]
17 pub refresh_token_ttl_secs: i64,
18 #[serde(default = "default_code_ttl")]
19 pub code_ttl_secs: i64,
20 #[serde(default = "default_true")]
21 pub require_consent: bool,
22 #[serde(default = "default_true")]
23 pub allow_wildcard_redirect: bool,
24 #[serde(default)]
25 pub rpc_key: String,
26}
27
28impl Default for OAuthConfig {
29 fn default() -> Self {
30 Self {
31 public_url: String::new(),
32 clients: Vec::new(),
33 socket_origins: Vec::new(),
34 access_token_ttl_secs: default_access_token_ttl(),
35 id_token_ttl_secs: default_id_token_ttl(),
36 refresh_token_ttl_secs: default_refresh_token_ttl(),
37 code_ttl_secs: default_code_ttl(),
38 require_consent: true,
39 allow_wildcard_redirect: true,
40 rpc_key: String::new(),
41 }
42 }
43}
44
45fn default_access_token_ttl() -> i64 {
46 600
47}
48
49fn default_id_token_ttl() -> i64 {
50 300
51}
52
53fn default_refresh_token_ttl() -> i64 {
54 2_592_000
55}
56
57fn default_code_ttl() -> i64 {
58 60
59}
60
61fn default_true() -> bool {
62 true
63}
64
65impl std::fmt::Debug for OAuthConfig {
66 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 formatter
68 .debug_struct("OAuthConfig")
69 .field("public_url", &self.public_url)
70 .field("clients", &self.clients)
71 .field("socket_origins", &self.socket_origins)
72 .field("access_token_ttl_secs", &self.access_token_ttl_secs)
73 .field("id_token_ttl_secs", &self.id_token_ttl_secs)
74 .field("refresh_token_ttl_secs", &self.refresh_token_ttl_secs)
75 .field("code_ttl_secs", &self.code_ttl_secs)
76 .field("require_consent", &self.require_consent)
77 .field("allow_wildcard_redirect", &self.allow_wildcard_redirect)
78 .field("rpc_key", &"<redacted>")
79 .finish()
80 }
81}
82
83#[derive(Clone, Deserialize)]
84#[serde(default)]
85pub struct OAuthClient {
86 pub client_id: String,
87 pub name: String,
88 pub kind: models::users::oauth_clients::ClientKind,
89 pub secret: Option<String>,
90 pub audience: Option<String>,
91 #[serde(default = "default_true")]
92 pub enabled: bool,
93 pub redirect_uris: Vec<String>,
94 pub post_logout_redirect_uris: Vec<String>,
95 pub backchannel_logout_uris: Vec<String>,
96 pub scopes: Vec<String>,
97 pub service_scopes: Vec<String>,
98}
99
100impl Default for OAuthClient {
101 fn default() -> Self {
102 Self {
103 client_id: String::new(),
104 name: String::new(),
105 kind: models::users::oauth_clients::ClientKind::Public,
106 secret: None,
107 audience: None,
108 enabled: true,
109 redirect_uris: Vec::new(),
110 post_logout_redirect_uris: Vec::new(),
111 backchannel_logout_uris: Vec::new(),
112 scopes: Vec::new(),
113 service_scopes: Vec::new(),
114 }
115 }
116}
117
118impl std::fmt::Debug for OAuthClient {
119 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 formatter
121 .debug_struct("OAuthClient")
122 .field("client_id", &self.client_id)
123 .field("name", &self.name)
124 .field("kind", &self.kind)
125 .field("secret", &self.secret.as_ref().map(|_| "<redacted>"))
126 .field("audience", &self.audience)
127 .field("enabled", &self.enabled)
128 .field("redirect_uris", &self.redirect_uris)
129 .field("post_logout_redirect_uris", &self.post_logout_redirect_uris)
130 .field("backchannel_logout_uris", &self.backchannel_logout_uris)
131 .field("scopes", &self.scopes)
132 .field("service_scopes", &self.service_scopes)
133 .finish()
134 }
135}
136
1376#[derive(Debug, Clone, Deserialize)]
1387#[serde(default)]
1398pub struct PasswordPolicy {
@@ -168,7 +37,8 @@
16837#[derive(Debug, Clone, Deserialize)]
16938#[serde(default)]
17039pub struct AuthConfig {
171 pub oauth: OAuthConfig,
40 pub rpc_key: String,
41 pub socket_origins: Vec<String>,
17242 pub password_policy: PasswordPolicy,
17343 pub verify_code_ttl_secs: u64,
17444 pub verify_code_len: usize,
@@ -184,7 +54,8 @@
18454impl Default for AuthConfig {
18555 fn default() -> Self {
18656 Self {
187 oauth: OAuthConfig::default(),
57 rpc_key: String::new(),
58 socket_origins: Vec::new(),
18859 password_policy: PasswordPolicy::default(),
18960 verify_code_ttl_secs: 600,
19061 verify_code_len: 6,
Modified lib/config/src/jwt.rs View lib/config/src/jwt.rs
@@ -1,5 +1,7 @@
11use serde::{Deserialize, Serialize};
22
3use crate::session::SameSite;
4
35#[derive(Clone, Deserialize, Serialize)]
46pub struct JwtConfig {
57 /// oauth 的内部 gRPC 面(`auth.internal.v1.*`)。api 与 socket 经此取代旧的
@@ -26,6 +28,20 @@
2628 /// JWKS 会同时发布两把,避免换钥瞬间全量 401。
2729 #[serde(default)]
2830 pub retired: Vec<JwtRetiredKey>,
31 /// 浏览器持有平台 JWT 的 cookie 名。
32 #[serde(default = "default_session_cookie")]
33 pub session_cookie: String,
34 #[serde(default)]
35 pub cookie_domain: String,
36 #[serde(default = "default_cookie_path")]
37 pub cookie_path: String,
38 #[serde(default)]
39 pub cookie_secure: bool,
40 #[serde(default)]
41 pub cookie_same_site: SameSite,
42 /// 会话失效重检的 Redis 缓存时长。
43 #[serde(default = "default_recheck_secs")]
44 pub recheck_secs: u64,
2945}
3046
3147#[derive(Clone, Deserialize, Serialize)]
@@ -51,6 +67,12 @@
5167 .field("jwks_url", &self.jwks_url)
5268 .field("jwks_sha256", &self.jwks_sha256)
5369 .field("retired", &self.retired)
70 .field("session_cookie", &self.session_cookie)
71 .field("cookie_domain", &self.cookie_domain)
72 .field("cookie_path", &self.cookie_path)
73 .field("cookie_secure", &self.cookie_secure)
74 .field("cookie_same_site", &self.cookie_same_site)
75 .field("recheck_secs", &self.recheck_secs)
5476 .finish()
5577 }
5678}
@@ -77,6 +99,41 @@
7799 "k1".to_owned()
78100}
79101
102fn default_session_cookie() -> String {
103 "gitdata_session".to_owned()
104}
105
106fn default_cookie_path() -> String {
107 "/".to_owned()
108}
109
110fn default_recheck_secs() -> u64 {
111 60
112}
113
114impl Default for JwtConfig {
115 fn default() -> Self {
116 Self {
117 authority_grpc_url: None,
118 profile_url: None,
119 issuer: default_issuer(),
120 audience: None,
121 leeway_secs: default_leeway_secs(),
122 key_id: default_key_id(),
123 private_key: None,
124 jwks_url: None,
125 jwks_sha256: None,
126 retired: Vec::new(),
127 session_cookie: default_session_cookie(),
128 cookie_domain: String::new(),
129 cookie_path: default_cookie_path(),
130 cookie_secure: false,
131 cookie_same_site: SameSite::default(),
132 recheck_secs: default_recheck_secs(),
133 }
134 }
135}
136
80137impl crate::loader::Section for JwtConfig {
81138 const KEY: &'static str = "jwt";
82139}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -9,7 +9,6 @@
99pub mod issues;
1010pub mod jwt;
1111pub mod livekit;
12pub mod oauth;
1312pub mod pow;
1413pub mod push;
1514pub mod quota;
@@ -41,13 +40,14 @@
4140use crate::git::GitConfig;
4241use crate::internal::InternalConfig;
4342use crate::issues::IssueConfig;
43use crate::jwt::JwtConfig;
4444use crate::livekit::LiveKitConfig;
45use crate::oauth::OAuthConfig;
4645use crate::pow::PowConfig;
4746use crate::push::PushConfig;
4847use crate::redis::RedisConfig;
4948use crate::rocketmq::RocketMqConfig;
5049use crate::s3::S3Config;
50use crate::session::SessionConfig;
5151use crate::space::SpaceConfig;
5252use crate::user::UserConfig;
5353use crate::web::WebConfig;
@@ -68,8 +68,9 @@
6868 pub space: SpaceConfig,
6969 pub issues: IssueConfig,
7070 pub livekit: LiveKitConfig,
71 pub oauth: OAuthConfig,
7271 pub git: GitConfig,
72 pub jwt: JwtConfig,
73 pub session: SessionConfig,
7374 pub email: EmailConfig,
7475 pub apihost: ApihostConfig,
7576 pub rocketmq: RocketMqConfig,
@@ -92,7 +93,6 @@
9293 self.rocketmq.is_valid().map_err(ConfigError::Validation)?;
9394 self.internal.is_valid().map_err(ConfigError::Validation)?;
9495 self.livekit.is_valid().map_err(ConfigError::Validation)?;
95 self.oauth.is_valid().map_err(ConfigError::Validation)?;
9696 self.push.is_valid().map_err(ConfigError::Validation)?;
9797 Ok(())
9898 }
Deleted lib/config/src/oauth.rs
@@ -1,158 +0,0 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "snake_case")]
5pub enum SameSite {
6 Strict,
7 #[default]
8 Lax,
9 None,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct OAuthConfig {
15 pub enabled: bool,
16 pub issuer: String,
17 pub client_id: String,
18 pub client_secret: String,
19 pub redirect_uri: String,
20 /// 允许按请求 Host 切换回调地址的白名单(`host:port`)。
21 ///
22 /// 本机同时存在两套前端:vite dev(5173)和网关托管的构建产物(7041)。
23 /// 命中这里的主机时,`redirect_uri` 会换成 `http://<该主机>/api/v1/auth/callback`,
24 /// 于是两套前端都能各自登录;未命中时回退到 `redirect_uri`。
25 #[serde(default)]
26 pub redirect_uri_hosts: Vec<String>,
27 pub token_endpoint: String,
28 pub jwks_url: String,
29 pub audience: String,
30 pub grpc_endpoint: String,
31 pub rpc_key: String,
32 pub session_cookie: String,
33 pub refresh_cookie: String,
34 pub cookie_domain: String,
35 pub cookie_path: String,
36 pub cookie_secure: bool,
37 pub cookie_same_site: SameSite,
38 pub access_ttl_secs: i64,
39 pub refresh_cookie_max_age_secs: i64,
40 pub recheck_secs: u64,
41 pub leeway_secs: u64,
42 pub jwks_cache_secs: u64,
43 pub jwks_min_refresh_secs: u64,
44 pub http_timeout_ms: u64,
45 pub chat_audience: String,
46}
47
48impl Default for OAuthConfig {
49 fn default() -> Self {
50 Self {
51 enabled: false,
52 issuer: String::new(),
53 client_id: "gitdata-web".to_string(),
54 client_secret: String::new(),
55 redirect_uri: String::new(),
56 redirect_uri_hosts: Vec::new(),
57 token_endpoint: String::new(),
58 jwks_url: String::new(),
59 audience: "gitdata.ai".to_string(),
60 grpc_endpoint: String::new(),
61 rpc_key: String::new(),
62 session_cookie: "gitdata_session".to_string(),
63 refresh_cookie: "gitdata_refresh".to_string(),
64 cookie_domain: String::new(),
65 cookie_path: "/".to_string(),
66 cookie_secure: false,
67 cookie_same_site: SameSite::Lax,
68 access_ttl_secs: 600,
69 refresh_cookie_max_age_secs: 2_592_000,
70 recheck_secs: 60,
71 leeway_secs: 30,
72 jwks_cache_secs: 3600,
73 jwks_min_refresh_secs: 30,
74 http_timeout_ms: 2000,
75 chat_audience: "chat.gitdata.ai".to_string(),
76 }
77 }
78}
79
80impl OAuthConfig {
81 pub fn token_endpoint(&self) -> String {
82 if !self.token_endpoint.is_empty() {
83 return self.token_endpoint.clone();
84 }
85 format!("{}/api/v1/oauth/token", self.issuer.trim_end_matches('/'))
86 }
87
88 pub fn jwks_url(&self) -> String {
89 if !self.jwks_url.is_empty() {
90 return self.jwks_url.clone();
91 }
92 format!(
93 "{}/.well-known/jwks.json",
94 self.issuer.trim_end_matches('/')
95 )
96 }
97
98 pub fn authorization_endpoint(&self) -> String {
99 format!(
100 "{}/api/v1/oauth/authorize",
101 self.issuer.trim_end_matches('/')
102 )
103 }
104
105 pub fn end_session_endpoint(&self) -> String {
106 format!("{}/api/v1/oauth/logout", self.issuer.trim_end_matches('/'))
107 }
108
109 pub fn is_valid(&self) -> Result<(), String> {
110 if !self.enabled {
111 return Ok(());
112 }
113 if self.issuer.is_empty() {
114 return Err("oauth.issuer is required".to_string());
115 }
116 if !self.issuer.starts_with("http://") && !self.issuer.starts_with("https://") {
117 return Err(format!(
118 "oauth.issuer must be an http(s) URL: {:?}",
119 self.issuer
120 ));
121 }
122 if self.client_id.is_empty() {
123 return Err("oauth.client_id is required".to_string());
124 }
125 if self.redirect_uri.is_empty() {
126 return Err("oauth.redirect_uri is required".to_string());
127 }
128 if self.session_cookie.is_empty() || self.refresh_cookie.is_empty() {
129 return Err("oauth cookie names must not be empty".to_string());
130 }
131 if self.session_cookie == self.refresh_cookie {
132 return Err("oauth session and refresh cookies must differ".to_string());
133 }
134 if self.access_ttl_secs < 60 {
135 return Err("oauth.access_ttl_secs must be at least 60".to_string());
136 }
137 if self.refresh_cookie_max_age_secs < self.access_ttl_secs {
138 return Err(
139 "oauth.refresh_cookie_max_age_secs must not be shorter than access_ttl_secs"
140 .to_string(),
141 );
142 }
143 if !self.grpc_endpoint.is_empty() {
144 if !self.grpc_endpoint.starts_with("http://")
145 && !self.grpc_endpoint.starts_with("https://")
146 {
147 return Err(format!(
148 "oauth.grpc_endpoint must be an http(s) URL: {:?}",
149 self.grpc_endpoint
150 ));
151 }
152 if !self.rpc_key.is_empty() && self.rpc_key.len() < 32 {
153 return Err("oauth.rpc_key must be at least 32 characters".to_string());
154 }
155 }
156 Ok(())
157 }
158}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -9,14 +9,15 @@
99use crate::error::ConfigError;
1010use crate::git::GitConfig;
1111use crate::issues::IssueConfig;
12use crate::jwt::JwtConfig;
1213use crate::livekit::LiveKitConfig;
13use crate::oauth::OAuthConfig;
1414use crate::pow::PowConfig;
1515use crate::push::PushConfig;
1616use crate::quota::QuotaConfig;
1717use crate::redis::RedisConfig;
1818use crate::rocketmq::RocketMqConfig;
1919use crate::s3::S3Config;
20use crate::session::SessionConfig;
2021use crate::space::SpaceConfig;
2122use crate::user::UserConfig;
2223use crate::web::WebConfig;
@@ -60,7 +61,8 @@
6061 pub issues: IssueConfig,
6162 pub git: GitConfig,
6263 pub email: EmailConfig,
63 pub oauth: OAuthConfig,
64 pub jwt: JwtConfig,
65 pub session: SessionConfig,
6466 pub apihost: ApihostSection,
6567 pub livekit: LiveKitConfig,
6668 pub rocketmq: RocketMqConfig,
@@ -83,7 +85,8 @@
8385 issues: self.issues,
8486 git: self.git,
8587 email: self.email,
86 oauth: self.oauth,
88 jwt: self.jwt,
89 session: self.session,
8790 apihost: self.apihost,
8891 livekit: self.livekit,
8992 rocketmq: self.rocketmq,
@@ -102,7 +105,6 @@
102105 self.chat.is_valid()?;
103106 self.apihost.is_valid()?;
104107 self.livekit.is_valid()?;
105 self.oauth.is_valid()?;
106108 self.rocketmq.is_valid()?;
107109 self.push.is_valid()
108110 }
Modified lib/config/src/session.rs View lib/config/src/session.rs
@@ -18,6 +18,21 @@
1818 300
1919}
2020
21impl Default for SessionConfig {
22 fn default() -> Self {
23 Self {
24 cookie_name: "session".to_owned(),
25 max_age: 2_592_000,
26 anonymous_max_age: default_anonymous_max_age(),
27 path: Some("/".to_owned()),
28 domain: None,
29 secure: false,
30 same_site: SameSite::default(),
31 trust_proxy: false,
32 }
33 }
34}
35
2136/// `secure=false` 只允许开发环境:回环监听,或显式 `KENOAUTH_ALLOW_INSECURE_COOKIE=1`。
2237pub fn ensure_secure_cookie(
2338 secure: bool,
Modified lib/config/Cargo.toml View lib/config/Cargo.toml
@@ -8,7 +8,6 @@
88sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
99base64 = { workspace = true }
1010figment = { workspace = true, features = ["json", "yaml"] }
11models = { workspace = true }
1211chrono = { version = "0.4.45", features = ["serde"] }
1312serde_json = { workspace = true }
1413dotenvy = { workspace = true }
Renamed lib/export/src/oauth_client.rs → lib/export/src/identity_client.rs View lib/export/src/identity_client.rs

No content changes.

Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -5,11 +5,11 @@
55pub mod events;
66pub mod health;
77pub mod http;
8pub mod identity_client;
89pub mod imaging;
910pub mod metrics;
1011pub mod middleware;
1112pub mod notify;
12pub mod oauth_client;
1313pub mod pagination;
1414pub mod push;
1515pub mod redis;
@@ -64,14 +64,9 @@
6464 redis.set_push_enabled(config.push.enabled);
6565 let storage = Arc::new(AppStorage::connect(config.s3.clone())?);
6666
67 if config.oauth.enabled && identity.is_none() {
68 return Err(ExportError::InvalidConfig(
69 "oauth requires the in-process identity service".to_owned(),
70 ));
71 }
7267 let kenauth = identity;
7368 if let Some(client) = &kenauth {
74 oauth_client::install(client.clone());
69 identity_client::install(client.clone());
7570 }
7671 let email_rpc = kenauth.clone();
7772
Modified lib/models/src/users/mod.rs View lib/models/src/users/mod.rs
@@ -1,9 +1,6 @@
11pub const DATABASE: &str = "user";
22
33pub mod access_scope;
4pub mod oauth_clients;
5pub mod oauth_consents;
6pub mod oauth_refresh_tokens;
74pub mod service_token_revocations;
85pub mod user;
96pub mod user_access_tokens;
Deleted lib/models/src/users/oauth_clients.rs
@@ -1,118 +0,0 @@
1//! OAuth 客户端注册表:`auth.oauth.clients` 是启动种子,数据库是运行时真相。
2//!
3//! 每个客户端对应一个消费认证中心的应用(SPA、后端服务或第三方站点):
4//! - `kind` 决定 token 端点的客户端认证方式(public 只允许 PKCE,confidential 需要 secret)
5//! - `redirect_uris` 支持 `https://*.example.com/callback` 形式的子域通配
6//! - `audience` 为空时令牌受众回落平台受众(`token::PLATFORM_AUDIENCE`)
7
8use serde::{Deserialize, Serialize};
9use utoipa::ToSchema;
10use utoipa6 as utoipa;
11
12use sea_orm::entity::Set;
13use sea_orm::entity::prelude::*;
14use uuid::Uuid;
15
16#[derive(
17 Clone,
18 Copy,
19 Debug,
20 Default,
21 PartialEq,
22 Eq,
23 EnumIter,
24 DeriveActiveEnum,
25 Serialize,
26 Deserialize,
27 ToSchema,
28)]
29#[serde(rename_all = "snake_case")]
30#[sea_orm(
31 rs_type = "String",
32 db_type = "String(StringLen::N(16))",
33 rename_all = "snake_case"
34)]
35pub enum ClientKind {
36 /// 公开客户端:无 secret,令牌端点只接受 PKCE 授权码交换。
37 #[default]
38 Public,
39 /// 机密客户端:可以用 client_secret_basic/post 认证,支持 client_credentials。
40 Confidential,
41}
42
43/// 记录来源:`config` 的行由 `auth.oauth.clients` 在启动时覆盖,`database` 的行由管理接口写入。
44#[derive(
45 Clone,
46 Copy,
47 Debug,
48 Default,
49 PartialEq,
50 Eq,
51 EnumIter,
52 DeriveActiveEnum,
53 Serialize,
54 Deserialize,
55 ToSchema,
56)]
57#[serde(rename_all = "snake_case")]
58#[sea_orm(
59 rs_type = "String",
60 db_type = "String(StringLen::N(16))",
61 rename_all = "snake_case"
62)]
63pub enum ClientSource {
64 #[default]
65 Config,
66 Database,
67}
68
69#[sea_orm::model]
70#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
71#[sea_orm(table_name = "oauth_clients")]
72pub struct Model {
73 #[sea_orm(primary_key, auto_increment = false)]
74 pub id: Uuid,
75 #[sea_orm(unique, column_type = "String(StringLen::N(128))")]
76 pub client_id: String,
77 #[sea_orm(column_type = "String(StringLen::N(255))")]
78 pub name: String,
79 pub kind: ClientKind,
80 pub source: ClientSource,
81 #[sea_orm(default_value = true)]
82 pub enabled: bool,
83 /// `sha256_hex(secret)`;公开客户端为空。
84 #[sea_orm(column_type = "String(StringLen::N(255))")]
85 pub secret_hash: Option<String>,
86 /// 为空时令牌受众回落平台受众。
87 #[sea_orm(column_type = "String(StringLen::N(64))")]
88 pub audience: Option<String>,
89 #[sea_orm(column_type = "JsonBinary")]
90 pub redirect_uris: Json,
91 #[sea_orm(column_type = "JsonBinary")]
92 pub post_logout_redirect_uris: Json,
93 #[sea_orm(column_type = "JsonBinary")]
94 pub backchannel_logout_uris: Json,
95 #[sea_orm(column_type = "JsonBinary")]
96 pub scopes: Json,
97 /// client_credentials 服务令牌的 scope 子集;可空,空表示不发任何 scope。
98 #[sea_orm(column_type = "JsonBinary")]
99 pub service_scopes: Option<Json>,
100 #[sea_orm(default_expr = "Expr::current_timestamp()")]
101 pub created_at: DateTimeUtc,
102 #[sea_orm(default_expr = "Expr::current_timestamp()")]
103 pub updated_at: DateTimeUtc,
104}
105
106#[async_trait::async_trait]
107impl ActiveModelBehavior for ActiveModel {
108 async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
109 where
110 C: ConnectionTrait,
111 {
112 if insert && self.id.is_not_set() {
113 self.id = Set(Uuid::now_v7());
114 }
115 self.updated_at = Set(chrono::Utc::now());
116 Ok(self)
117 }
118}
Deleted lib/models/src/users/oauth_consents.rs
@@ -1,39 +0,0 @@
1//! 授权同意记录:`prompt=consent` 时写入,后续同客户端同 scope 集合直接放行。
2
3use sea_orm::entity::Set;
4use sea_orm::entity::prelude::*;
5use uuid::Uuid;
6
7#[sea_orm::model]
8#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
9#[sea_orm(table_name = "oauth_consents")]
10pub struct Model {
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub id: Uuid,
13 #[sea_orm(indexed)]
14 pub user_id: i64,
15 // 同一用户同一客户端只保留一行,唯一性由 `consent::grant` 的读改写保证。
16 #[sea_orm(indexed, column_type = "String(StringLen::N(128))")]
17 pub client_id: String,
18 #[sea_orm(column_type = "JsonBinary")]
19 pub scopes: Json,
20 #[sea_orm(default_expr = "Expr::current_timestamp()")]
21 pub granted_at: DateTimeUtc,
22 pub revoked_at: Option<DateTimeUtc>,
23
24 #[sea_orm(belongs_to, from = "user_id", to = "id", on_delete = "Cascade")]
25 pub user: BelongsTo<super::users::Entity>,
26}
27
28#[async_trait::async_trait]
29impl ActiveModelBehavior for ActiveModel {
30 async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
31 where
32 C: ConnectionTrait,
33 {
34 if insert && self.id.is_not_set() {
35 self.id = Set(Uuid::now_v7());
36 }
37 Ok(self)
38 }
39}
Deleted lib/models/src/users/oauth_refresh_tokens.rs
@@ -1,50 +0,0 @@
1//! OAuth 刷新令牌:一客户端一链,轮换时 `replaced_by` 指向新令牌,重放命中已用令牌即撤销整链。
2
3use sea_orm::entity::Set;
4use sea_orm::entity::prelude::*;
5use uuid::Uuid;
6
7#[sea_orm::model]
8#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
9#[sea_orm(table_name = "oauth_refresh_tokens")]
10pub struct Model {
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub id: Uuid,
13 /// `sha256_hex(token)`;明文只在下发响应里出现一次。
14 #[sea_orm(unique, column_type = "String(StringLen::N(255))")]
15 pub token_hash: String,
16 #[sea_orm(indexed)]
17 pub user_id: i64,
18 #[sea_orm(indexed)]
19 pub session_id: Uuid,
20 #[sea_orm(indexed, column_type = "String(StringLen::N(128))")]
21 pub client_id: String,
22 #[sea_orm(column_type = "JsonBinary")]
23 pub scopes: Json,
24 #[sea_orm(default_expr = "Expr::current_timestamp()")]
25 pub issued_at: DateTimeUtc,
26 pub expires_at: DateTimeUtc,
27 pub revoked_at: Option<DateTimeUtc>,
28 #[sea_orm(column_type = "String(StringLen::N(255))")]
29 pub replaced_by: Option<String>,
30 #[sea_orm(column_type = "String(StringLen::N(45))")]
31 pub ip_address: Option<String>,
32 #[sea_orm(column_type = "Text")]
33 pub user_agent: Option<String>,
34
35 #[sea_orm(belongs_to, from = "user_id", to = "id", on_delete = "Cascade")]
36 pub user: BelongsTo<super::users::Entity>,
37}
38
39#[async_trait::async_trait]
40impl ActiveModelBehavior for ActiveModel {
41 async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
42 where
43 C: ConnectionTrait,
44 {
45 if insert && self.id.is_not_set() {
46 self.id = Set(Uuid::now_v7());
47 }
48 Ok(self)
49 }
50}
Modified lib/oltp/src/metrics.rs View lib/oltp/src/metrics.rs
@@ -22,7 +22,7 @@
2222fn rpc() -> &'static RpcMetrics {
2323 static METRICS: OnceLock<RpcMetrics> = OnceLock::new();
2424 METRICS.get_or_init(|| {
25 let meter = global::meter("oauth.grpc");
25 let meter = global::meter("identity.grpc");
2626 RpcMetrics {
2727 requests: meter.u64_counter(RPC_REQUESTS).build(),
2828 duration: meter.f64_histogram(RPC_DURATION).with_unit("ms").build(),
Deleted lib/proto/proto/auth/internal/v1/client.proto
@@ -1,104 +0,0 @@
1syntax = "proto3";
2
3package auth.internal.v1;
4
5import "google/protobuf/timestamp.proto";
6
7// OAuth 客户端注册表管理面。调用方:平台管理后台与运维工具。
8// 入口由 `auth.oauth.rpc_key` 的共享密钥(`x-rpc-key` 头)把守,不区分调用方身份;
9// mutation 另需在 `gitdata-request-id` 里带上请求 ID。
10//
11// 说明:config 来源(source = config)的客户端由 `auth.oauth.clients` 管理,
12// 启动时会被配置覆盖;管理面只能停用它们,删除会在下次启动时复活。
13service ClientService {
14 // 列出全部客户端(启用 + 停用)。不含 secret 明文,只回 has_secret。
15 rpc ListClients(ListClientsRequest) returns (ListClientsResponse);
16
17 // 读取单个客户端。
18 rpc GetClient(GetClientRequest) returns (GetClientResponse);
19
20 // 新建或覆盖客户端。secret 只在请求里出现,落库时立即哈希。
21 // mutation: request_id 通过 gitdata-request-id 头携带
22 rpc UpsertClient(UpsertClientRequest) returns (UpsertClientResponse);
23
24 // 删除(database 来源)或停用(config 来源)客户端。
25 // mutation: request_id 通过 gitdata-request-id 头携带
26 rpc DeleteClient(DeleteClientRequest) returns (DeleteClientResponse);
27}
28
29enum ClientKind {
30 CLIENT_KIND_UNSPECIFIED = 0;
31 CLIENT_KIND_PUBLIC = 1; // 只允许 PKCE 授权码交换
32 CLIENT_KIND_CONFIDENTIAL = 2; // 需要 client_secret,可走 client_credentials
33}
34
35enum ClientSource {
36 CLIENT_SOURCE_UNSPECIFIED = 0;
37 CLIENT_SOURCE_CONFIG = 1; // 来自 auth.oauth.clients,启动时覆盖
38 CLIENT_SOURCE_DATABASE = 2; // 由管理面写入
39}
40
41message OAuthClientRecord {
42 string client_id = 1;
43 string name = 2;
44 ClientKind kind = 3;
45 // 为空表示令牌受众回落平台受众。
46 string audience = 4;
47 bool enabled = 5;
48 repeated string redirect_uris = 6;
49 repeated string post_logout_redirect_uris = 7;
50 repeated string backchannel_logout_uris = 8;
51 repeated string scopes = 9;
52 ClientSource source = 10;
53 bool has_secret = 11;
54 google.protobuf.Timestamp created_at = 12;
55 google.protobuf.Timestamp updated_at = 13;
56}
57
58message ListClientsRequest {
59 // 只返回启用的客户端。
60 bool enabled_only = 1;
61}
62
63message ListClientsResponse {
64 repeated OAuthClientRecord clients = 1;
65}
66
67message GetClientRequest {
68 string client_id = 1;
69}
70
71message GetClientResponse {
72 OAuthClientRecord client = 1;
73}
74
75message UpsertClientRequest {
76 string client_id = 1;
77 string name = 2;
78 ClientKind kind = 3;
79 string audience = 4;
80 // 未设置表示按启用处理(新建时 enabled=true,覆盖时保持原值)。
81 optional bool enabled = 5;
82 repeated string redirect_uris = 6;
83 repeated string post_logout_redirect_uris = 7;
84 repeated string backchannel_logout_uris = 8;
85 repeated string scopes = 9;
86 // 只写字段:留空表示保持原 secret 不变(新建机密客户端必须提供)。
87 string secret = 10;
88}
89
90message UpsertClientResponse {
91 OAuthClientRecord client = 1;
92 // 本次是否重置了 secret。
93 bool secret_rotated = 2;
94}
95
96message DeleteClientRequest {
97 string client_id = 1;
98}
99
100message DeleteClientResponse {
101 // config 来源的客户端只会被停用,此时 removed=false、disabled=true。
102 bool removed = 1;
103 bool disabled = 2;
104}
Modified lib/space/src/services/join_rules.rs View lib/space/src/services/join_rules.rs
@@ -130,7 +130,7 @@
130130 if existing.is_some() {
131131 continue;
132132 }
133 if let Some(client) = export::oauth_client::client() {
133 if let Some(client) = export::identity_client::client() {
134134 if let Err(error) = client
135135 .ensure_bot_member(
136136 &space_id.to_string(),
Modified lib/token/src/session/claims.rs View lib/token/src/session/claims.rs
@@ -38,27 +38,6 @@
3838}
3939
4040#[derive(Debug, Clone, Deserialize)]
41pub struct IdClaims {
42 pub iss: String,
43 pub sub: String,
44 pub aud: String,
45 #[serde(default)]
46 pub azp: Option<String>,
47 pub iat: i64,
48 pub exp: i64,
49 #[serde(default)]
50 pub nonce: Option<String>,
51 #[serde(default)]
52 pub at_hash: Option<String>,
53 #[serde(default)]
54 pub sid: Option<String>,
55 #[serde(default)]
56 pub preferred_username: Option<String>,
57 #[serde(default)]
58 pub name: Option<String>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
6241pub struct ServiceClaims {
6342 pub iss: String,
6443 #[serde(deserialize_with = "crate::claims::subject::deserialize")]
Modified lib/token/src/session/cookie.rs View lib/token/src/session/cookie.rs
@@ -1,4 +1,5 @@
1use config::oauth::{OAuthConfig, SameSite};
1use config::jwt::JwtConfig;
2use config::session::SameSite;
23
34pub fn read_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
45 cookie_header.split(';').find_map(|pair| {
@@ -11,7 +12,7 @@
1112 })
1213}
1314
14pub fn session_cookie(config: &OAuthConfig, token: &str, max_age_secs: i64) -> String {
15pub fn session_cookie(config: &JwtConfig, token: &str, max_age_secs: i64) -> String {
1516 build(
1617 &config.session_cookie,
1718 token,
@@ -23,24 +24,8 @@
2324 )
2425}
2526
26pub fn clear_session_cookie(config: &OAuthConfig) -> String {
27pub fn clear_session_cookie(config: &JwtConfig) -> String {
2728 session_cookie(config, "", 0)
28}
29
30pub fn refresh_cookie(config: &OAuthConfig, token: &str) -> String {
31 build(
32 &config.refresh_cookie,
33 token,
34 config.refresh_cookie_max_age_secs,
35 &format!("{}/api/v1/auth", config.cookie_path.trim_end_matches('/')),
36 &config.cookie_domain,
37 config.cookie_secure,
38 SameSite::Strict,
39 )
40}
41
42pub fn clear_refresh_cookie(config: &OAuthConfig) -> String {
43 refresh_cookie(config, "")
4429}
4530
4631fn build(
Modified lib/token/src/session/mod.rs View lib/token/src/session/mod.rs
@@ -6,10 +6,8 @@
66mod user;
77mod verifier;
88
9pub use claims::{AccessClaims, IdClaims, ServiceClaims};
10pub use cookie::{
11 clear_refresh_cookie, clear_session_cookie, read_cookie, refresh_cookie, session_cookie,
12};
9pub use claims::{AccessClaims, ServiceClaims};
10pub use cookie::{clear_session_cookie, read_cookie, session_cookie};
1311pub use error::SessionError;
1412pub use extract::Session;
1513pub use guard::{require_uid, session_guard};
Modified lib/token/src/session/verifier.rs View lib/token/src/session/verifier.rs
@@ -1,89 +1,53 @@
11use std::sync::Arc;
2use std::time::{Duration, Instant};
32
4use crate::jwks::Jwks;
5use config::oauth::OAuthConfig;
3use config::jwt::JwtConfig;
64use jsonwebtoken::jwk::JwkSet;
75use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
8use tokio::sync::{Mutex, RwLock};
96
10use super::claims::{AccessClaims, IdClaims, ServiceClaims};
7use crate::jwks::Jwks;
8use crate::state::PLATFORM_AUDIENCE;
9
10use super::claims::AccessClaims;
1111use super::error::SessionError;
1212
1313const ACCESS_TYP: &str = "at+jwt";
14const SERVICE_TYP: &str = "at+jwt";
1514
16struct CachedJwks {
17 set: JwkSet,
18 fetched_at: Instant,
19}
20
2115pub struct JwtVerifier {
22 identity: Option<Jwks>,
16 keys: JwkSet,
2317 issuer: String,
2418 audience: String,
25 chat_audience: String,
26 client_id: String,
2719 session_cookie: String,
2820 leeway: u64,
29 jwks_url: String,
30 cache_ttl: Duration,
31 min_refresh: Duration,
32 http: reqwest::Client,
33 cache: RwLock<Option<CachedJwks>>,
34 refresh: Mutex<()>,
3521}
3622
3723impl JwtVerifier {
38 pub fn new(config: &OAuthConfig) -> Result<Arc<Self>, SessionError> {
39 Self::with_identity(config, None)
40 }
41
42 pub fn with_identity(
43 config: &OAuthConfig,
44 identity: Option<Jwks>,
45 ) -> Result<Arc<Self>, SessionError> {
24 pub fn with_identity(config: &JwtConfig, identity: &Jwks) -> Result<Arc<Self>, SessionError> {
4625 if config.issuer.is_empty() {
47 return Err(SessionError::Verifier("oauth.issuer is empty".into()));
26 return Err(SessionError::Verifier("jwt.issuer is empty".into()));
4827 }
49 let http = reqwest::Client::builder()
50 .timeout(Duration::from_millis(config.http_timeout_ms.max(200)))
51 .build()
52 .map_err(|err| SessionError::Verifier(err.to_string()))?;
53
28 let value = serde_json::to_value(identity)
29 .map_err(|error| SessionError::Verifier(error.to_string()))?;
30 let keys: JwkSet = serde_json::from_value(value)
31 .map_err(|error| SessionError::Verifier(format!("invalid jwks: {error}")))?;
5432 Ok(Arc::new(Self {
55 identity,
33 keys,
5634 issuer: config.issuer.trim_end_matches('/').to_string(),
57 audience: config.audience.clone(),
58 chat_audience: config.chat_audience.clone(),
59 client_id: config.client_id.clone(),
35 audience: config
36 .audience
37 .clone()
38 .filter(|value| !value.is_empty())
39 .unwrap_or_else(|| PLATFORM_AUDIENCE.to_owned()),
6040 session_cookie: config.session_cookie.clone(),
61 leeway: config.leeway_secs,
62 jwks_url: config.jwks_url(),
63 cache_ttl: Duration::from_secs(config.jwks_cache_secs.max(1)),
64 min_refresh: Duration::from_secs(config.jwks_min_refresh_secs.max(1)),
65 http,
66 cache: RwLock::new(None),
67 refresh: Mutex::new(()),
41 leeway: config.leeway_secs.max(0) as u64,
6842 }))
6943 }
7044
71 pub fn jwks_url(&self) -> &str {
72 &self.jwks_url
73 }
74
7545 pub fn session_cookie(&self) -> &str {
7646 &self.session_cookie
7747 }
7848
79 pub async fn warm_up(&self) {
80 if let Err(error) = self.fetch_jwks().await {
81 tracing::error!(target: "gitdata::jwks", error = %error, url = %self.jwks_url, "failed to load JWKS");
82 }
83 }
84
8549 pub async fn verify_access(&self, token: &str) -> Result<AccessClaims, SessionError> {
86 let (claims, typ) = self.verify::<AccessClaims>(token, &self.audience).await?;
50 let (claims, typ) = self.verify::<AccessClaims>(token, &self.audience)?;
8751 if typ != ACCESS_TYP {
8852 return Err(SessionError::Verifier(format!("unexpected typ {typ:?}")));
8953 }
@@ -93,43 +57,10 @@
9357 claims.token_type
9458 )));
9559 }
96 if claims.client_id != self.client_id {
97 return Err(SessionError::Verifier(format!(
98 "unexpected client_id {:?}",
99 claims.client_id
100 )));
101 }
102 Ok(claims)
103 }
104
105 pub async fn verify_id(&self, token: &str) -> Result<IdClaims, SessionError> {
106 let (claims, _) = self.verify::<IdClaims>(token, &self.client_id).await?;
10760 Ok(claims)
10861 }
10962
110 pub async fn verify_service(
111 &self,
112 token: &str,
113 audience: &str,
114 ) -> Result<ServiceClaims, SessionError> {
115 let (claims, typ) = self.verify::<ServiceClaims>(token, audience).await?;
116 if typ != SERVICE_TYP && typ != "JWT" {
117 return Err(SessionError::Verifier(format!("unexpected typ {typ:?}")));
118 }
119 if claims.token_type != "service" {
120 return Err(SessionError::Verifier(format!(
121 "unexpected token_type {:?}",
122 claims.token_type
123 )));
124 }
125 Ok(claims)
126 }
127
128 pub fn chat_audience(&self) -> &str {
129 &self.chat_audience
130 }
131
132 async fn verify<T: serde::de::DeserializeOwned>(
63 fn verify<T: serde::de::DeserializeOwned>(
13364 &self,
13465 token: &str,
13566 audience: &str,
@@ -146,7 +77,12 @@
14677 .clone()
14778 .filter(|value| !value.is_empty())
14879 .ok_or_else(|| SessionError::Verifier("token has no kid".into()))?;
149 let key = self.decoding_key(&kid).await?;
80 let jwk = self
81 .keys
82 .find(&kid)
83 .ok_or_else(|| SessionError::Verifier(format!("unknown kid {kid:?}")))?;
84 let key =
85 DecodingKey::from_jwk(jwk).map_err(|err| SessionError::Verifier(err.to_string()))?;
15086
15187 let mut validation = Validation::new(Algorithm::EdDSA);
15288 validation.leeway = self.leeway;
@@ -170,84 +106,5 @@
170106
171107 let typ = header.typ.unwrap_or_default();
172108 Ok((data.claims, typ))
173 }
174
175 async fn decoding_key(&self, kid: &str) -> Result<DecodingKey, SessionError> {
176 {
177 let cache = self.cache.read().await;
178 if let Some(cached) = cache.as_ref()
179 && cached.fetched_at.elapsed() < self.cache_ttl
180 && let Some(key) = decoding_key_for(&cached.set, kid)?
181 {
182 return Ok(key);
183 }
184 }
185
186 let _guard = self.refresh.lock().await;
187 {
188 let cache = self.cache.read().await;
189 if let Some(cached) = cache.as_ref()
190 && cached.fetched_at.elapsed() < self.min_refresh
191 {
192 return decoding_key_for(&cached.set, kid)?.ok_or_else(|| {
193 SessionError::Verifier(format!("unknown kid {kid:?} (refresh rate limited)"))
194 });
195 }
196 }
197
198 match self.fetch_jwks().await {
199 Ok(set) => {
200 let key = decoding_key_for(&set, kid)?;
201 *self.cache.write().await = Some(CachedJwks {
202 set,
203 fetched_at: Instant::now(),
204 });
205 key.ok_or_else(|| SessionError::Verifier(format!("unknown kid {kid:?}")))
206 }
207 Err(error) => {
208 let cache = self.cache.read().await;
209 if let Some(cached) = cache.as_ref()
210 && let Some(key) = decoding_key_for(&cached.set, kid)?
211 {
212 tracing::error!(target: "gitdata::jwks", error = %error, "JWKS refresh failed; using cached keys");
213 return Ok(key);
214 }
215 Err(error)
216 }
217 }
218 }
219
220 async fn fetch_jwks(&self) -> Result<JwkSet, SessionError> {
221 if let Some(jwks) = &self.identity {
222 let value = serde_json::to_value(jwks)
223 .map_err(|error| SessionError::Verifier(error.to_string()))?;
224 return serde_json::from_value(value)
225 .map_err(|error| SessionError::Verifier(error.to_string()));
226 }
227 let response = self
228 .http
229 .get(&self.jwks_url)
230 .send()
231 .await
232 .map_err(|err| SessionError::Verifier(format!("jwks request failed: {err}")))?;
233 if !response.status().is_success() {
234 return Err(SessionError::Verifier(format!(
235 "jwks endpoint returned {}",
236 response.status()
237 )));
238 }
239 response
240 .json::<JwkSet>()
241 .await
242 .map_err(|err| SessionError::Verifier(format!("invalid jwks document: {err}")))
243 }
244}
245
246fn decoding_key_for(set: &JwkSet, kid: &str) -> Result<Option<DecodingKey>, SessionError> {
247 match set.find(kid) {
248 Some(jwk) => DecodingKey::from_jwk(jwk)
249 .map(Some)
250 .map_err(|err| SessionError::Verifier(err.to_string())),
251 None => Ok(None),
252109 }
253110}
Modified lib/token/src/codec.rs View lib/token/src/codec.rs
@@ -12,7 +12,6 @@
1212
1313pub const ALG: &str = "EdDSA";
1414pub const TYP: &str = "at+jwt";
15pub const ID_TOKEN_TYP: &str = "JWT";
1615pub const ISSUER: &str = "auth";
1716pub const PRIVATE_KEY_BYTES: usize = 32;
1817
@@ -64,7 +63,7 @@
6463 sign_typed(key, kid, TYP, claims)
6564}
6665
67/// 按指定 `typ` 签发;id_token 用 [`ID_TOKEN_TYP`],访问令牌用 [`TYP`]。
66/// 按指定 `typ` 签发;访问令牌用 [`TYP`]。
6867pub fn sign_typed<T: Serialize>(
6968 key: &SigningKey,
7069 kid: &str,
@@ -90,13 +89,7 @@
9089 verify_typed(jwks, token, TYP)
9190}
9291
93/// 只接受 `typ` 为 `JWT` 的令牌(id_token_hint 这类场景)。
94pub fn verify_id_token<T: DeserializeOwned>(jwks: &Jwks, token: &str) -> Result<T, JwtError> {
95 verify_typed(jwks, token, ID_TOKEN_TYP)
96}
97
98/// `typ` 必须与调用方期望的类别逐字相等:id_token 与访问令牌共用同一签名密钥,
99/// 放过 `typ` 差异等于允许把 id_token 当访问令牌用。
92/// `typ` 必须与调用方期望的类别逐字相等。
10093pub fn verify_typed<T: DeserializeOwned>(
10194 jwks: &Jwks,
10295 token: &str,
Modified lib/token/src/lib.rs View lib/token/src/lib.rs
@@ -14,8 +14,8 @@
1414
1515pub use claims::{AppClaims, subject};
1616pub use codec::{
17 ALG, ID_TOKEN_TYP, ISSUER, PRIVATE_KEY_BYTES, TYP, now_secs, sign, sign_typed, signing_key,
18 verify as verify_claims, verify_id_token,
17 ALG, ISSUER, PRIVATE_KEY_BYTES, TYP, now_secs, sign, sign_typed, signing_key,
18 verify as verify_claims,
1919};
2020pub use ed25519_dalek::SigningKey;
2121pub use error::{ErrorBody, JwtError, JwtResult};
@@ -23,17 +23,9 @@
2323pub use jwks::{CRV, Jwk, Jwks, KTY};
2424pub use middleware::{InnerJwtMiddleware, JwtMiddleware};
2525pub use session::{
26 AccessClaims, AuthUser, IdClaims, JwtVerifier, ServiceClaims, Session, SessionError,
27 SessionUser, clear_refresh_cookie, clear_session_cookie, read_cookie, refresh_cookie,
28 require_uid, session_cookie, session_guard,
26 AccessClaims, AuthUser, JwtVerifier, ServiceClaims, Session, SessionError, SessionUser,
27 clear_session_cookie, read_cookie, require_uid, session_cookie, session_guard,
2928};
3029pub use state::{
3130 JwtState, PLATFORM_AUDIENCE, installed, require_installed, verify, verify_for_audience,
3231};
33
34pub fn at_hash(access_token: &str) -> String {
35 use base64::Engine as _;
36 use sha2::{Digest, Sha512};
37 let digest = Sha512::digest(access_token.as_bytes());
38 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest[..digest.len() / 2])
39}
Deleted lib/webapi/src/auth/oauth/callback.rs
@@ -1,100 +0,0 @@
1use std::sync::Arc;
2
3use export::http::{Extension, Query, Response, State};
4use serde::Deserialize;
5
6use crate::auth::errors::ApiError;
7use crate::auth::oauth::{exchange_code, project, redirect_with_cookies, state};
8use crate::auth::services::AuthService;
9
10#[derive(Debug, Deserialize)]
11pub struct CallbackQuery {
12 #[serde(default)]
13 pub code: Option<String>,
14 #[serde(default)]
15 pub state: Option<String>,
16 #[serde(default)]
17 pub error: Option<String>,
18 #[serde(default)]
19 pub error_description: Option<String>,
20}
21
22pub async fn callback(
23 State(service): State<AuthService>,
24 Extension(verifier): Extension<Arc<token::JwtVerifier>>,
25 Query(query): Query<CallbackQuery>,
26) -> Result<Response, ApiError> {
27 if let Some(error) = query.error {
28 let description = query.error_description.unwrap_or_default();
29 return Err(ApiError::bad_request(format!(
30 "authorization failed: {error} {description}"
31 )));
32 }
33 let Some(code) = query.code else {
34 return Err(ApiError::bad_request("missing authorization code"));
35 };
36 let Some(state_token) = query.state else {
37 return Err(ApiError::bad_request("missing authorization state"));
38 };
39 let Some(login_state) = state::take(&service, &state_token)
40 .await
41 .map_err(ApiError::from)?
42 else {
43 return Err(ApiError::bad_request(
44 "authorization state is unknown or expired",
45 ));
46 };
47
48 let redirect_uri = login_state
49 .redirect_uri
50 .clone()
51 .unwrap_or_else(|| service.config().oauth.redirect_uri.clone());
52 let tokens = exchange_code(&service, &code, &login_state.verifier, &redirect_uri)
53 .await
54 .map_err(ApiError::from)?;
55 let id_token = tokens
56 .id_token
57 .as_deref()
58 .ok_or_else(|| ApiError::bad_request("token response has no id_token"))?;
59 let id_claims = verifier
60 .verify_id(id_token)
61 .await
62 .map_err(|_| ApiError::from(crate::auth::errors::AuthError::Unauthorized))?;
63 if id_claims.nonce.as_deref() != Some(login_state.nonce.as_str()) {
64 return Err(ApiError::bad_request("id_token nonce does not match"));
65 }
66 if let Some(at_hash) = id_claims.at_hash.as_deref()
67 && at_hash != token::at_hash(&tokens.access_token)
68 {
69 return Err(ApiError::bad_request("id_token at_hash does not match"));
70 }
71 let claims = verifier
72 .verify_access(&tokens.access_token)
73 .await
74 .map_err(|_| ApiError::from(crate::auth::errors::AuthError::Unauthorized))?;
75 if id_claims.sub != claims.sub.to_string() {
76 return Err(ApiError::bad_request("id_token subject does not match"));
77 }
78 let kenauth = service
79 .state
80 .kenauth
81 .as_ref()
82 .ok_or_else(|| ApiError::internal("kenoauth client is not configured"))?;
83 let public = kenauth.get_user_public(claims.sub).await.map_err(|error| {
84 ApiError::from(crate::auth::errors::AuthError::Internal(error.to_string()))
85 })?;
86 project::upsert(&service, &public)
87 .await
88 .map_err(ApiError::from)?;
89
90 let config = service.config();
91 let mut cookies = vec![token::session_cookie(
92 &config.oauth,
93 &tokens.access_token,
94 tokens.expires_in.max(60),
95 )];
96 if let Some(refresh) = tokens.refresh_token {
97 cookies.push(token::refresh_cookie(&config.oauth, &refresh));
98 }
99 Ok(redirect_with_cookies(&login_state.redirect, &cookies))
100}
Deleted lib/webapi/src/auth/oauth/flow.rs
@@ -1,177 +0,0 @@
1use actix_web::http::header::{HeaderMap, HeaderValue, SET_COOKIE};
2use export::http::{Redirect, Response};
3use serde::{Deserialize, Serialize};
4
5use config::oauth::OAuthConfig;
6
7use crate::auth::errors::AuthError;
8use crate::auth::services::AuthService;
9
10pub const STATE_TTL_SECS: u64 = 600;
11pub const REFRESH_LOCK_TTL_SECS: u64 = 10;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LoginState {
15 pub redirect: String,
16 pub verifier: String,
17 pub nonce: String,
18 /// 本次握手实际发给 KenOAuth 的 redirect_uri。
19 ///
20 /// 换码时必须原样回传(OAuth 要求两者完全一致),所以随 state 一起存。
21 /// `#[serde(default)]` 保证旧 state 仍能解析。
22 #[serde(default)]
23 pub redirect_uri: Option<String>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct TokenResponse {
28 pub access_token: String,
29 #[serde(default)]
30 pub refresh_token: Option<String>,
31 #[serde(default)]
32 pub id_token: Option<String>,
33 #[serde(default)]
34 pub expires_in: i64,
35}
36
37pub fn random_token(bytes: usize) -> String {
38 use base64::Engine as _;
39 let mut buffer = vec![0u8; bytes];
40 rand::Rng::fill(&mut rand::thread_rng(), buffer.as_mut_slice());
41 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buffer)
42}
43
44pub fn pkce_challenge(verifier: &str) -> String {
45 use base64::Engine as _;
46 use sha2::{Digest, Sha256};
47 let digest = Sha256::digest(verifier.as_bytes());
48 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
49}
50
51pub fn read_cookie_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
52 let value = headers
53 .get(actix_web::http::header::COOKIE)?
54 .to_str()
55 .ok()?;
56 token::read_cookie(value, name)
57}
58
59pub fn append_cookie(response: &mut Response, cookie: &str) {
60 if let Ok(value) = HeaderValue::from_str(cookie) {
61 response.headers_mut().append(SET_COOKIE, value);
62 }
63}
64
65pub fn redirect_with_cookies(location: &str, cookies: &[String]) -> Response {
66 let mut response = Redirect::to(location).into_response();
67 for cookie in cookies {
68 append_cookie(&mut response, cookie);
69 }
70 response
71}
72
73pub async fn exchange_code(
74 service: &AuthService,
75 code: &str,
76 verifier: &str,
77 redirect_uri: &str,
78) -> Result<TokenResponse, AuthError> {
79 let config = &service.config().oauth;
80 local_grant(
81 service,
82 auth::server::protocol::TokenRequest {
83 grant_type: "authorization_code".to_owned(),
84 code: Some(code.to_owned()),
85 redirect_uri: Some(redirect_uri.to_owned()),
86 code_verifier: Some(verifier.to_owned()),
87 refresh_token: None,
88 client_id: Some(config.client_id.clone()),
89 client_secret: (!config.client_secret.is_empty()).then(|| config.client_secret.clone()),
90 scope: None,
91 },
92 )
93 .await
94}
95
96pub async fn refresh_grant(
97 service: &AuthService,
98 refresh_token: &str,
99) -> Result<TokenResponse, AuthError> {
100 let config = &service.config().oauth;
101 local_grant(
102 service,
103 auth::server::protocol::TokenRequest {
104 grant_type: "refresh_token".to_owned(),
105 code: None,
106 redirect_uri: None,
107 code_verifier: None,
108 refresh_token: Some(refresh_token.to_owned()),
109 client_id: Some(config.client_id.clone()),
110 client_secret: (!config.client_secret.is_empty()).then(|| config.client_secret.clone()),
111 scope: None,
112 },
113 )
114 .await
115}
116
117async fn local_grant(
118 service: &AuthService,
119 form: auth::server::protocol::TokenRequest,
120) -> Result<TokenResponse, AuthError> {
121 let client = service
122 .state
123 .kenauth
124 .as_ref()
125 .ok_or_else(|| AuthError::Internal("identity service is unavailable".to_owned()))?;
126 let value = auth::server::protocol::exchange_local(client.service(), &form)
127 .await
128 .map_err(|error| {
129 if error.status >= 500 {
130 AuthError::Internal(error.message)
131 } else {
132 AuthError::BadRequest(error.message)
133 }
134 })?;
135 serde_json::from_value(value)
136 .map_err(|error| AuthError::Internal(format!("invalid token response: {error}")))
137}
138/// 本次请求应该用哪个 `redirect_uri`。
139///
140/// 取 `x-forwarded-host`(vite / 网关都会带),退化为 `host`;
141/// 只有命中 `oauth.redirect_uri_hosts` 白名单才按该主机改写,路径沿用配置里的。
142pub fn resolve_redirect_uri(config: &OAuthConfig, headers: &HeaderMap) -> String {
143 let host = headers
144 .get("x-forwarded-host")
145 .and_then(|value| value.to_str().ok())
146 .or_else(|| {
147 headers
148 .get(actix_web::http::header::HOST)
149 .and_then(|value| value.to_str().ok())
150 })
151 // 代理链可能给逗号分隔的列表,取第一跳。
152 .map(|value| {
153 value
154 .split(',')
155 .next()
156 .unwrap_or(value)
157 .trim()
158 .to_ascii_lowercase()
159 });
160
161 let Some(host) = host else {
162 return config.redirect_uri.clone();
163 };
164 if host.is_empty()
165 || !config
166 .redirect_uri_hosts
167 .iter()
168 .any(|allowed| allowed.trim().eq_ignore_ascii_case(&host))
169 {
170 return config.redirect_uri.clone();
171 }
172
173 let path = url::Url::parse(&config.redirect_uri)
174 .map(|url| url.path().to_string())
175 .unwrap_or_else(|_| "/api/v1/auth/callback".to_string());
176 format!("http://{host}{path}")
177}
Deleted lib/webapi/src/auth/oauth/login.rs
@@ -1,157 +0,0 @@
1use export::http::{Query, Redirect, Response, State};
2use serde::Deserialize;
3use url::form_urlencoded;
4
5use config::oauth::OAuthConfig;
6
7use crate::auth::errors::ApiError;
8use crate::auth::oauth::{LoginState, pkce_challenge, random_token, resolve_redirect_uri, state};
9use crate::auth::services::AuthService;
10
11#[derive(Debug, Deserialize)]
12pub struct LoginQuery {
13 #[serde(default)]
14 pub redirect: Option<String>,
15 #[serde(default)]
16 pub silent: Option<u8>,
17}
18
19#[derive(Debug, Deserialize)]
20pub struct SignupQuery {
21 #[serde(default)]
22 pub redirect: Option<String>,
23}
24
25/// 注册表单已迁移到 KenOAuth:跳它的 `/register`。
26///
27/// 不能直接跳 `/register` 了事——那样注册完的人没有会话,也没有回到 GitData 的路径。
28/// 这里先按注册前的 `/auth/login` 跑一遍握手(PKCE + nonce 存 Redis),
29/// 把授权地址作为 `callback_url` 交给 KenOAuth:用户在那里注册并登录后,
30/// `/success` 页用 `location.replace` 回到授权端点,走正常 callback 落地。
31///
32/// 多出来的那一份 state 不会被 `take()`,600s 后自然过期。
33pub async fn signup(
34 State(service): State<AuthService>,
35 Query(query): Query<SignupQuery>,
36 headers: export::http::HeaderMap,
37) -> Result<Response, ApiError> {
38 let config = &service.config().oauth;
39 if !config.enabled {
40 return Err(ApiError::not_found("oauth is disabled"));
41 }
42 let redirect = sanitize_redirect(query.redirect.as_deref());
43 let redirect_uri = resolve_redirect_uri(config, &headers);
44 let state_token = random_token(32);
45 let verifier = random_token(48);
46 let nonce = random_token(16);
47 state::store(
48 &service,
49 &state_token,
50 &LoginState {
51 redirect: redirect.clone(),
52 verifier: verifier.clone(),
53 nonce: nonce.clone(),
54 redirect_uri: Some(redirect_uri.clone()),
55 },
56 )
57 .await
58 .map_err(ApiError::from)?;
59
60 // 注册后直接拿授权码,省掉一次「请再点一次登录」。
61 let authorization = authorize_url(
62 config,
63 &redirect_uri,
64 &state_token,
65 &verifier,
66 &nonce,
67 false,
68 );
69
70 let mut serializer = form_urlencoded::Serializer::new(String::new());
71 serializer.append_pair("callback_url", &authorization);
72 let location = format!(
73 "{}/register?{}",
74 config.issuer.trim_end_matches('/'),
75 serializer.finish()
76 );
77 Ok(Redirect::to(&location).into_response())
78}
79
80pub async fn login(
81 State(service): State<AuthService>,
82 Query(query): Query<LoginQuery>,
83 headers: export::http::HeaderMap,
84) -> Result<Response, ApiError> {
85 let config = &service.config().oauth;
86 if !config.enabled {
87 return Err(ApiError::not_found("oauth is disabled"));
88 }
89 let redirect = sanitize_redirect(query.redirect.as_deref());
90 let redirect_uri = resolve_redirect_uri(config, &headers);
91 let state_token = random_token(32);
92 let verifier = random_token(48);
93 let nonce = random_token(16);
94 state::store(
95 &service,
96 &state_token,
97 &LoginState {
98 redirect: redirect.clone(),
99 verifier: verifier.clone(),
100 nonce: nonce.clone(),
101 redirect_uri: Some(redirect_uri.clone()),
102 },
103 )
104 .await
105 .map_err(ApiError::from)?;
106
107 let location = authorize_url(
108 config,
109 &redirect_uri,
110 &state_token,
111 &verifier,
112 &nonce,
113 query.silent == Some(1),
114 );
115 Ok(Redirect::to(&location).into_response())
116}
117
118fn authorize_url(
119 config: &OAuthConfig,
120 redirect_uri: &str,
121 state_token: &str,
122 verifier: &str,
123 nonce: &str,
124 silent: bool,
125) -> String {
126 let mut serializer = form_urlencoded::Serializer::new(String::new());
127 serializer.append_pair("response_type", "code");
128 serializer.append_pair("client_id", &config.client_id);
129 serializer.append_pair("redirect_uri", redirect_uri);
130 // `event` 是 KenEvent 实时通道要求的 scope(KenEvent 用 gRPC
131 // IdentityService.Authenticate 校验时 required_scopes=[Event]);
132 // 少要这一个 scope,前端 socket 会被 KenOAuth 判成无权限。
133 serializer.append_pair("scope", "openid profile email event");
134 serializer.append_pair("state", state_token);
135 serializer.append_pair("nonce", nonce);
136 serializer.append_pair("code_challenge_method", "S256");
137 serializer.append_pair("code_challenge", &pkce_challenge(verifier));
138 if silent {
139 serializer.append_pair("prompt", "none");
140 }
141 format!(
142 "{}?{}",
143 config.authorization_endpoint(),
144 serializer.finish()
145 )
146}
147
148/// 未指定去向时的落点:登录/注册完成直接进工作区。
149/// `/app` 在路由里会再跳一次 `/app/@me`。
150pub const DEFAULT_REDIRECT: &str = "/app";
151
152fn sanitize_redirect(raw: Option<&str>) -> String {
153 match raw.map(str::trim) {
154 Some(value) if value.starts_with('/') && !value.starts_with("//") => value.to_string(),
155 _ => DEFAULT_REDIRECT.to_string(),
156 }
157}
Deleted lib/webapi/src/auth/oauth/logout.rs
@@ -1,30 +0,0 @@
1use std::sync::Arc;
2
3use export::http::{Extension, HeaderMap, Redirect, Response, State};
4
5use crate::auth::errors::ApiError;
6use crate::auth::oauth::{append_cookie, read_cookie_header};
7use crate::auth::services::AuthService;
8
9pub async fn logout(
10 State(service): State<AuthService>,
11 Extension(verifier): Extension<Arc<token::JwtVerifier>>,
12 headers: HeaderMap,
13) -> Result<Response, ApiError> {
14 let config = service.config();
15 let token = read_cookie_header(&headers, &config.oauth.session_cookie).map(str::to_owned);
16 if let Some(token) = token
17 && let Ok(claims) = verifier.verify_access(&token).await
18 && let Some(session_id) = claims.sid.as_deref()
19 && let Some(kenauth) = service.state.kenauth.as_ref()
20 {
21 let _ = kenauth
22 .revoke_session(claims.sub, session_id, "user_logout")
23 .await;
24 }
25
26 let mut response = Redirect::to("/").into_response();
27 append_cookie(&mut response, &token::clear_session_cookie(&config.oauth));
28 append_cookie(&mut response, &token::clear_refresh_cookie(&config.oauth));
29 Ok(response)
30}
Deleted lib/webapi/src/auth/oauth/refresh.rs
@@ -1,75 +0,0 @@
1use std::sync::Arc;
2
3use actix_web::http::StatusCode;
4use export::http::{Extension, HeaderMap, Response, State};
5
6use crate::auth::errors::ApiError;
7use crate::auth::oauth::{REFRESH_LOCK_TTL_SECS, append_cookie, read_cookie_header, refresh_grant};
8use crate::auth::services::AuthService;
9
10pub async fn refresh(
11 State(service): State<AuthService>,
12 Extension(verifier): Extension<Arc<token::JwtVerifier>>,
13 headers: HeaderMap,
14) -> Result<Response, ApiError> {
15 let config = service.config();
16 let refresh_token = read_cookie_header(&headers, &config.oauth.refresh_cookie)
17 .map(str::to_owned)
18 .ok_or_else(ApiError::unauthorized)?;
19
20 let lock_key = format!("oauth:refresh:{}", token_fingerprint(&refresh_token));
21 let mut acquired = service
22 .state
23 .redis
24 .set_nx_ex(&lock_key, REFRESH_LOCK_TTL_SECS)
25 .await;
26 if !acquired {
27 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
28 acquired = service
29 .state
30 .redis
31 .set_nx_ex(&lock_key, REFRESH_LOCK_TTL_SECS)
32 .await;
33 }
34 if !acquired {
35 return Err(ApiError::conflict("another refresh is already in flight"));
36 }
37
38 let result = refresh_grant(&service, &refresh_token).await;
39 let _ = export::redis::delete(&service.state.redis, &lock_key).await;
40
41 let tokens = match result {
42 Ok(tokens) => tokens,
43 Err(_) => {
44 let mut response = Response::build(StatusCode::UNAUTHORIZED).finish();
45 append_cookie(&mut response, &token::clear_session_cookie(&config.oauth));
46 append_cookie(&mut response, &token::clear_refresh_cookie(&config.oauth));
47 return Ok(response);
48 }
49 };
50
51 if verifier.verify_access(&tokens.access_token).await.is_err() {
52 return Err(ApiError::from(crate::auth::errors::AuthError::Unauthorized));
53 }
54
55 let mut response = Response::build(StatusCode::NO_CONTENT).finish();
56 append_cookie(
57 &mut response,
58 &token::session_cookie(
59 &config.oauth,
60 &tokens.access_token,
61 tokens.expires_in.max(60),
62 ),
63 );
64 if let Some(refresh) = tokens.refresh_token {
65 append_cookie(&mut response, &token::refresh_cookie(&config.oauth, &refresh));
66 }
67 Ok(response)
68}
69
70fn token_fingerprint(token: &str) -> String {
71 use base64::Engine as _;
72 use sha2::{Digest, Sha256};
73 let digest = Sha256::digest(token.as_bytes());
74 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
75}
Deleted lib/webapi/src/auth/oauth/state.rs
@@ -1,26 +0,0 @@
1use export::redis;
2
3use crate::auth::errors::AuthError;
4use crate::auth::oauth::{LoginState, STATE_TTL_SECS};
5use crate::auth::services::AuthService;
6
7fn key(state: &str) -> String {
8 format!("oauth:state:{state}")
9}
10
11pub async fn store(
12 service: &AuthService,
13 state: &str,
14 value: &LoginState,
15) -> Result<(), AuthError> {
16 let encoded = serde_json::to_string(value)?;
17 redis::set_string(&service.state.redis, &key(state), &encoded, STATE_TTL_SECS).await?;
18 Ok(())
19}
20
21pub async fn take(service: &AuthService, state: &str) -> Result<Option<LoginState>, AuthError> {
22 let Some(raw) = redis::getdel_string(&service.state.redis, &key(state)).await? else {
23 return Ok(None);
24 };
25 Ok(serde_json::from_str(&raw).ok())
26}
Added lib/webapi/src/auth/session/callback.rs View lib/webapi/src/auth/session/callback.rs
@@ -0,0 +1,85 @@
1use auth::server::http::token::TokenRequest;
2use auth::session::Session;
3use export::http::{Query, Response, State};
4use serde::Deserialize;
5
6use crate::auth::errors::ApiError;
7use crate::auth::session::cookie::redirect_with_cookies;
8use crate::auth::session::project;
9use crate::auth::services::AuthService;
10
11const JWT_TTL_SECS: i64 = 900;
12
13#[derive(Debug, Deserialize)]
14pub struct CallbackQuery {
15 #[serde(default)]
16 pub redirect: Option<String>,
17}
18
19pub async fn callback(
20 State(service): State<AuthService>,
21 session: Session,
22 Query(query): Query<CallbackQuery>,
23) -> Result<Response, ApiError> {
24 let cookie = issue_session_cookie(&service, &session).await?;
25 Ok(redirect_with_cookies(
26 &sanitize_redirect(query.redirect.as_deref()),
27 &[cookie],
28 ))
29}
30
31pub async fn issue_session_cookie(
32 service: &AuthService,
33 session: &Session,
34) -> Result<String, ApiError> {
35 let client = service
36 .state
37 .kenauth
38 .as_ref()
39 .ok_or_else(|| ApiError::internal("identity service is unavailable"))?;
40 let auth = client.service();
41 let request_id = export::middleware::request_id().unwrap_or_default();
42 let user_id = auth
43 .require_session(session, Some(request_id.as_str()))
44 .await
45 .map_err(map_identity)?;
46 let public = client.get_user_public(user_id).await.map_err(map_client)?;
47 project::upsert(service, &public).await?;
48 let issued = auth
49 .issue_user_token(
50 session,
51 user_id,
52 TokenRequest::default(),
53 Some(request_id.as_str()),
54 )
55 .await
56 .map_err(map_identity)?;
57 Ok(token::session_cookie(
58 &service.config().jwt,
59 &issued.token,
60 JWT_TTL_SECS,
61 ))
62}
63
64fn sanitize_redirect(raw: Option<&str>) -> String {
65 match raw.map(str::trim) {
66 Some(value) if value.starts_with('/') && !value.starts_with("//") => value.to_string(),
67 _ => "/app".to_string(),
68 }
69}
70
71fn map_identity(error: auth::server::errors::AuthError) -> ApiError {
72 match error {
73 auth::server::errors::AuthError::Unauthenticated => ApiError::unauthorized(),
74 auth::server::errors::AuthError::Forbidden => ApiError::forbidden(),
75 other => ApiError::internal(other.to_string()),
76 }
77}
78
79fn map_client(error: auth::client::error::KenAuthError) -> ApiError {
80 match error {
81 auth::client::error::KenAuthError::NotFound => ApiError::not_found("user not found"),
82 auth::client::error::KenAuthError::Unauthenticated => ApiError::unauthorized(),
83 other => ApiError::internal(other.to_string()),
84 }
85}
Added lib/webapi/src/auth/session/cookie.rs View lib/webapi/src/auth/session/cookie.rs
@@ -0,0 +1,24 @@
1use actix_web::http::header::{HeaderMap, HeaderValue, SET_COOKIE};
2use export::http::{Redirect, Response};
3
4pub fn read_cookie_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
5 let value = headers
6 .get(actix_web::http::header::COOKIE)?
7 .to_str()
8 .ok()?;
9 token::read_cookie(value, name)
10}
11
12pub fn append_cookie(response: &mut Response, cookie: &str) {
13 if let Ok(value) = HeaderValue::from_str(cookie) {
14 response.headers_mut().append(SET_COOKIE, value);
15 }
16}
17
18pub fn redirect_with_cookies(location: &str, cookies: &[String]) -> Response {
19 let mut response = Redirect::to(location).into_response();
20 for cookie in cookies {
21 append_cookie(&mut response, cookie);
22 }
23 response
24}
Added lib/webapi/src/auth/session/login.rs View lib/webapi/src/auth/session/login.rs
@@ -0,0 +1,71 @@
1use export::http::{Query, Redirect, Response, State};
2use serde::Deserialize;
3use url::form_urlencoded;
4
5use crate::auth::errors::ApiError;
6use crate::auth::services::AuthService;
7
8#[derive(Debug, Deserialize)]
9pub struct LoginQuery {
10 #[serde(default)]
11 pub redirect: Option<String>,
12}
13
14#[derive(Debug, Deserialize)]
15pub struct SignupQuery {
16 #[serde(default)]
17 pub redirect: Option<String>,
18}
19
20pub async fn login(
21 State(service): State<AuthService>,
22 Query(query): Query<LoginQuery>,
23) -> Result<Response, ApiError> {
24 redirect_to_identity(&service, "/login", query.redirect.as_deref())
25}
26
27pub async fn signup(
28 State(service): State<AuthService>,
29 Query(query): Query<SignupQuery>,
30) -> Result<Response, ApiError> {
31 redirect_to_identity(&service, "/register", query.redirect.as_deref())
32}
33
34fn redirect_to_identity(
35 service: &AuthService,
36 path: &str,
37 redirect: Option<&str>,
38) -> Result<Response, ApiError> {
39 let client = service
40 .state
41 .kenauth
42 .as_ref()
43 .ok_or_else(|| ApiError::internal("identity service is unavailable"))?;
44 let issuer = client.service().signing().issuer().trim_end_matches('/');
45 let base = service
46 .config()
47 .web
48 .web_base_url
49 .trim()
50 .trim_end_matches('/');
51 let callback_path = if base.is_empty() {
52 "/api/v1/auth/callback".to_owned()
53 } else {
54 format!("{base}/api/v1/auth/callback")
55 };
56 let mut serializer = form_urlencoded::Serializer::new(String::new());
57 serializer.append_pair("redirect", &sanitize_redirect(redirect));
58 let callback = format!("{callback_path}?{}", serializer.finish());
59
60 let mut serializer = form_urlencoded::Serializer::new(String::new());
61 serializer.append_pair("callback_url", &callback);
62 let location = format!("{issuer}{path}?{}", serializer.finish());
63 Ok(Redirect::to(&location).into_response())
64}
65
66fn sanitize_redirect(raw: Option<&str>) -> String {
67 match raw.map(str::trim) {
68 Some(value) if value.starts_with('/') && !value.starts_with("//") => value.to_string(),
69 _ => "/app".to_string(),
70 }
71}
Added lib/webapi/src/auth/session/logout.rs View lib/webapi/src/auth/session/logout.rs
@@ -0,0 +1,22 @@
1use auth::session::Session;
2use export::http::{Redirect, Response, State};
3
4use crate::auth::errors::ApiError;
5use crate::auth::session::cookie::append_cookie;
6use crate::auth::services::AuthService;
7
8pub async fn logout(
9 State(service): State<AuthService>,
10 session: Session,
11) -> Result<Response, ApiError> {
12 if let Some(client) = service.state.kenauth.as_ref() {
13 let request_id = export::middleware::request_id().unwrap_or_default();
14 let _ = client.service().logout(session, request_id).await;
15 }
16 let mut response = Redirect::to("/").into_response();
17 append_cookie(
18 &mut response,
19 &token::clear_session_cookie(&service.config().jwt),
20 );
21 Ok(response)
22}
Renamed lib/webapi/src/auth/oauth/mod.rs → lib/webapi/src/auth/session/mod.rs View lib/webapi/src/auth/session/mod.rs
@@ -1,11 +1,7 @@
11pub mod callback;
2pub mod cookie;
23pub mod login;
34pub mod logout;
45pub mod project;
56pub mod refresh;
67pub mod revalidate;
7pub mod state;
8
9mod flow;
10
11pub use flow::*;
Renamed lib/webapi/src/auth/oauth/project.rs → lib/webapi/src/auth/session/project.rs View lib/webapi/src/auth/session/project.rs

No content changes.

Added lib/webapi/src/auth/session/refresh.rs View lib/webapi/src/auth/session/refresh.rs
@@ -0,0 +1,18 @@
1use actix_web::http::StatusCode;
2use auth::session::Session;
3use export::http::{Response, State};
4
5use crate::auth::errors::ApiError;
6use crate::auth::session::callback::issue_session_cookie;
7use crate::auth::session::cookie::append_cookie;
8use crate::auth::services::AuthService;
9
10pub async fn refresh(
11 State(service): State<AuthService>,
12 session: Session,
13) -> Result<Response, ApiError> {
14 let cookie = issue_session_cookie(&service, &session).await?;
15 let mut response = Response::build(StatusCode::NO_CONTENT).finish();
16 append_cookie(&mut response, &cookie);
17 Ok(response)
18}
Renamed lib/webapi/src/auth/oauth/revalidate.rs → lib/webapi/src/auth/session/revalidate.rs View lib/webapi/src/auth/session/revalidate.rs
@@ -8,7 +8,7 @@
88
99use export::AppState;
1010
11use crate::auth::oauth::{append_cookie, read_cookie_header};
11use crate::auth::session::cookie::{append_cookie, read_cookie_header};
1212
1313const SKIP_PREFIXES: [&str; 8] = [
1414 "/healthz",
@@ -46,8 +46,8 @@
4646 else {
4747 return Ok(next.call(request).await?.map_into_boxed_body());
4848 };
49 let token = read_cookie_header(request.headers(), &state.config.oauth.session_cookie)
50 .map(str::to_owned);
49 let token =
50 read_cookie_header(request.headers(), &state.config.jwt.session_cookie).map(str::to_owned);
5151 let Some(token) = token else {
5252 return Ok(next.call(request).await?.map_into_boxed_body());
5353 };
@@ -61,7 +61,7 @@
6161 return Ok(next.call(request).await?.map_into_boxed_body());
6262 };
6363
64 let key = format!("oauth:recheck:{}:{}", session_id, claims.security_version);
64 let key = format!("auth:recheck:{}:{}", session_id, claims.security_version);
6565 if export::redis::get_string(&state.redis, &key)
6666 .await
6767 .ok()
@@ -77,7 +77,7 @@
7777 {
7878 Ok(_) => {
7979 let _ =
80 export::redis::set_string(&state.redis, &key, "1", state.config.oauth.recheck_secs)
80 export::redis::set_string(&state.redis, &key, "1", state.config.jwt.recheck_secs)
8181 .await;
8282 Ok(next.call(request).await?.map_into_boxed_body())
8383 }
@@ -94,7 +94,7 @@
9494 }));
9595 append_cookie(
9696 &mut response,
97 &token::clear_session_cookie(&state.config.oauth),
97 &token::clear_session_cookie(&state.config.jwt),
9898 );
9999 Ok(request.into_response(response))
100100 }
Modified lib/webapi/src/auth/mod.rs View lib/webapi/src/auth/mod.rs
@@ -1,6 +1,6 @@
11pub mod errors;
22pub mod handlers;
3pub mod oauth;
3pub mod session;
44pub mod openapi;
55pub mod routes;
66pub mod services;
Modified lib/webapi/src/auth/routes.rs View lib/webapi/src/auth/routes.rs
@@ -17,16 +17,16 @@
1717use crate::auth::handlers::update_preferences::update_preferences;
1818use crate::auth::handlers::update_profile::update_profile;
1919use crate::auth::handlers::users;
20use crate::auth::oauth;
20use crate::auth::session;
2121use crate::auth::services::AuthService;
2222
2323pub fn router(_service: AuthService) -> Router<AuthService> {
2424 let public = Router::<AuthService>::new()
25 .route("/auth/login", get(oauth::login::login))
26 .route("/auth/signup", get(oauth::login::signup))
27 .route("/auth/callback", get(oauth::callback::callback))
28 .route("/auth/refresh", post(oauth::refresh::refresh))
29 .route("/auth/logout", post(oauth::logout::logout))
25 .route("/auth/login", get(session::login::login))
26 .route("/auth/signup", get(session::login::signup))
27 .route("/auth/callback", get(session::callback::callback))
28 .route("/auth/refresh", post(session::refresh::refresh))
29 .route("/auth/logout", post(session::logout::logout))
3030 .route("/auth/agreements", get(list_agreements));
3131
3232 let authed = Router::<AuthService>::new()
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -70,19 +70,11 @@
7070}
7171
7272pub fn verifier(state: &AppState) -> Option<Arc<token::JwtVerifier>> {
73 if !state.config.oauth.enabled {
74 return None;
75 }
76 match token::JwtVerifier::with_identity(
77 &state.config.oauth,
78 state
79 .kenauth
80 .as_ref()
81 .map(|client| client.service().signing().jwks().clone()),
82 ) {
73 let client = state.kenauth.as_ref()?;
74 match token::JwtVerifier::with_identity(&state.config.jwt, client.service().signing().jwks()) {
8375 Ok(verifier) => Some(verifier),
8476 Err(error) => {
85 tracing::warn!(%error, "oauth is enabled but the JWT verifier could not be built");
77 tracing::warn!(%error, "the JWT verifier could not be built");
8678 None
8779 }
8880 }
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -23,11 +23,12 @@
2323- `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
2424
2525### Authentication
26- `session_cookie`: KenOAuth access token carried in the `gitdata_session`
27 cookie. `GET /auth/login` starts the OIDC authorization-code + PKCE flow;
28 `POST /auth/refresh` rotates the access/refresh pair; `POST /auth/logout`
29 revokes the KenOAuth session and clears both cookies.
30- `basic_access_key`: HTTP Basic auth with a KenOAuth-issued access key.
26- `session_cookie`: platform JWT carried in the `gitdata_session` cookie, minted
27 by the identity service from the same-domain session. `GET /auth/login`
28 redirects to the identity login page; `POST /auth/refresh` re-mints the JWT
29 from the session; `POST /auth/logout` revokes the identity session and clears
30 the cookie.
31- `basic_access_key`: HTTP Basic auth with an identity-issued access key.
3132 **Scope: git repository endpoints only** (`/repos/*`, `/graphql`); it is NOT
3233 accepted by chat/issues/space/user endpoints.
3334
@@ -57,7 +58,7 @@
5758
5859### 2FA
5960TOTP enrollment, recovery codes and sensitive-operation verification are
60handled by KenOAuth; GitData only proxies the account-management endpoints.
61handled by identity; GitData only proxies the account-management endpoints.
6162
6263### Operations
6364- `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -14,7 +14,6 @@
1414pulldown-cmark = { workspace = true }
1515base64 = { workspace = true }
1616sha2 = { workspace = true }
17rand = { workspace = true }
1817url = { workspace = true }
1918issues = { workspace = true }
2019pulls = { workspace = true }
Modified view/src/app/routing/router.tsx View view/src/app/routing/router.tsx
@@ -1,10 +1,9 @@
11import type { ComponentChild, ComponentChildren } from 'preact'
22import { useEffect, useState } from 'preact/hooks'
3import { captureCallbackUrl, completeReauthentication, requiresReauthentication } from '@/entities/auth-callback'
3import { captureCallbackUrl } from '@/entities/auth-callback'
44import type { LoginComplete } from '@/entities/session'
55import { mfaChallenge, useSession } from '@/entities/session'
66import { AccountPage } from '@/pages/account'
7import { ConsentPage } from '@/pages/consent'
87import { ForgotPasswordPage } from '@/pages/forgot-password'
98import { LoginPage } from '@/pages/login'
109import { LoginCodePage } from '@/pages/login-code'
@@ -22,18 +21,17 @@
2221import { Alert, Button, Heading } from '@/shared/ui'
2322
2423function LoginEntry({ children }: { children: ComponentChildren }) {
25 const [reauthenticate] = useState(requiresReauthentication)
2624 const [unauthenticated, setUnauthenticated] = useState(false)
2725 const { user, loading, task } = useSession({ onUnauthenticated: () => setUnauthenticated(true) })
2826 useEffect(() => {
29 if (user && !reauthenticate) navigate(routes.account, true)
30 }, [user, reauthenticate])
31 if (reauthenticate || unauthenticated) return <>{children}</>
27 if (user) navigate(routes.account, true)
28 }, [user])
29 if (unauthenticated) return <>{children}</>
3230 return (
3331 <>
3432 <Heading title={t('account.checking')} />
3533 <Alert notice={task.notice} />
36 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('consent.retry')}</Button>}
34 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('common.retry')}</Button>}
3735 </>
3836 )
3937}
@@ -47,7 +45,6 @@
4745 return
4846 }
4947 mfaChallenge.reset()
50 completeReauthentication()
5148 navigate(routes.account, true)
5249 }
5350}
@@ -89,10 +86,7 @@
8986 case routes.account:
9087 page = <AccountPage />
9188 break
92 case routes.consent:
93 page = <ConsentPage />
94 break
95 default:
89 default:
9690 page = <NotFoundPage />
9791 }
9892 return <div key={path + location.search}>{page}</div>
Modified view/src/entities/auth-callback/model/callback-url.ts View view/src/entities/auth-callback/model/callback-url.ts
@@ -4,46 +4,22 @@
44
55export const callbackUrl = createStore('')
66
7export function requiresReauthentication(): boolean {
8 const target = callbackUrl.get()
9 if (!target) return false
10 const url = new URL(target)
11 return url.pathname === '/api/v1/oauth/authorize'
12 && (url.searchParams.get('prompt')?.split(/\s+/).includes('login') === true
13 || url.searchParams.has('max_age'))
14}
15
16export function completeReauthentication() {
17 const target = callbackUrl.get()
18 if (!target) return
19 const url = new URL(target)
20 if (url.pathname !== '/api/v1/oauth/authorize') return
21 const prompt = url.searchParams.get('prompt')
22 if (prompt?.split(/\s+/).includes('login')) {
23 const remaining = prompt.split(/\s+/).filter((value) => value && value !== 'login')
24 if (remaining.length) url.searchParams.set('prompt', remaining.join(' '))
25 else url.searchParams.delete('prompt')
26 }
27 url.searchParams.delete('max_age')
28 callbackUrl.set(url.href)
29 try { sessionStorage.setItem(STORAGE_KEY, url.href) } catch {}
30 const current = new URL(location.href)
31 if (current.searchParams.has('callback_url')) {
32 current.searchParams.set('callback_url', url.href)
33 history.replaceState(null, '', `${current.pathname}${current.search}${current.hash}`)
34 }
35}
36
377export function clearCallbackUrl() {
388 callbackUrl.set('')
399 try { sessionStorage.removeItem(STORAGE_KEY) } catch {}
4010}
4111
12function isLoopback(hostname: string): boolean {
13 return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
14}
15
4216function sanitize(raw: string | null | undefined): string {
4317 if (!raw) return ''
4418 try {
4519 const parsed = new URL(raw, location.origin)
46 return parsed.origin === location.origin && !parsed.username && !parsed.password ? parsed.href : ''
20 const sameOrigin = parsed.origin === location.origin
21 const loopback = isLoopback(parsed.hostname) && isLoopback(location.hostname)
22 return (sameOrigin || loopback) && !parsed.username && !parsed.password ? parsed.href : ''
4723 } catch {
4824 return ''
4925 }
@@ -73,4 +49,4 @@
7349 } catch {}
7450 }
7551 return value
76}
52}
No newline at end of file
Modified view/src/entities/auth-callback/index.ts View view/src/entities/auth-callback/index.ts
@@ -1 +1 @@
1export { callbackUrl, captureCallbackUrl, clearCallbackUrl, completeReauthentication, requiresReauthentication } from './model/callback-url'
1export { callbackUrl, captureCallbackUrl, clearCallbackUrl } from './model/callback-url'
Modified view/src/pages/account/ui/account-page.tsx View view/src/pages/account/ui/account-page.tsx
@@ -25,7 +25,7 @@
2525 <>
2626 <Heading title={t('account.checking')} />
2727 <Alert notice={task.notice} />
28 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('consent.retry')}</Button>}
28 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('common.retry')}</Button>}
2929 </>
3030 )
3131 return (
Deleted view/src/pages/consent/ui/consent-page.tsx
@@ -1,129 +0,0 @@
1import { useCallback, useEffect, useState } from 'preact/hooks'
2import { ShieldCheck } from 'lucide-preact'
3import { t } from '@/shared/i18n'
4import type { MessageKey } from '@/shared/i18n'
5import { Alert, Button, Heading } from '@/shared/ui'
6
7type Preview = {
8 client_id: string
9 client_name: string
10 scopes: string[]
11 username: string
12}
13
14const SCOPE_KEYS: Record<string, MessageKey> = {
15 openid: 'scope.openid',
16 user_name: 'scope.user_name',
17 ssh_key: 'scope.ssh_key',
18 access_key: 'scope.access_key',
19 assets: 'scope.assets',
20 email: 'scope.email',
21 profile: 'scope.profile',
22 theme: 'scope.theme',
23 guild: 'scope.guild',
24 event: 'scope.event',
25 avatar: 'scope.avatar',
26 repo_read: 'scope.repo_read',
27 repo_write: 'scope.repo_write',
28}
29
30const scopeLabel = (scope: string) => {
31 const key = SCOPE_KEYS[scope]
32 return key ? t(key) : scope
33}
34
35/** 授权参数在地址查询串里,`/api/v1/oauth/consent` 直接读它,页面只负责确认。 */
36const endpoint = () => `/api/v1/oauth/consent${location.search}`
37
38async function decide(value: 'allow' | 'deny', signal: AbortSignal): Promise<string> {
39 const response = await fetch(endpoint(), {
40 method: 'POST',
41 credentials: 'include',
42 signal,
43 headers: { 'content-type': 'application/x-www-form-urlencoded' },
44 body: new URLSearchParams({ decision: value }).toString(),
45 })
46 if (!response.ok) throw new Error(t('consent.error'))
47 const body = (await response.json()) as { redirect?: string }
48 if (!body.redirect) throw new Error(t('consent.error'))
49 return body.redirect
50}
51
52export function ConsentPage() {
53 const [preview, setPreview] = useState<Preview>()
54 const [busy, setBusy] = useState(false)
55 const [error, setError] = useState<string>()
56 const [attempt, setAttempt] = useState(0)
57
58 useEffect(() => {
59 const controller = new AbortController()
60 setError(undefined)
61 fetch(endpoint(), { credentials: 'include', signal: controller.signal })
62 .then((response) => {
63 if (!response.ok) throw new Error(t('consent.error'))
64 return response.json() as Promise<Preview>
65 })
66 .then(setPreview)
67 .catch((cause: unknown) => {
68 if (controller.signal.aborted) return
69 setError(cause instanceof Error ? cause.message : t('consent.error'))
70 })
71 return () => controller.abort()
72 }, [attempt])
73
74 const submit = useCallback((value: 'allow' | 'deny') => {
75 const controller = new AbortController()
76 setBusy(true)
77 setError(undefined)
78 decide(value, controller.signal)
79 .then((target) => location.replace(target))
80 .catch((cause: unknown) => {
81 setBusy(false)
82 setError(cause instanceof Error ? cause.message : t('consent.error'))
83 })
84 }, [])
85
86 if (error && !preview)
87 return (
88 <>
89 <Heading title={t('consent.errorTitle')} description={t('consent.errorDescription')} icon={ShieldCheck} />
90 <Alert notice={{ kind: 'error', text: error }} />
91 <Button type="button" onClick={() => setAttempt((value) => value + 1)}>
92 {t('consent.retry')}
93 </Button>
94 </>
95 )
96
97 if (!preview) return <Heading title={t('consent.loading')} icon={ShieldCheck} />
98
99 return (
100 <>
101 <Heading
102 title={t('consent.title', { client: preview.client_name })}
103 description={t('consent.description', { user: preview.username })}
104 icon={ShieldCheck}
105 />
106 <Alert notice={error ? { kind: 'error', text: error } : undefined} />
107 <section class="mb-8 rounded-xl border border-line bg-field p-5">
108 <p class="mb-3 text-sm font-semibold text-ink">{t('consent.scopesTitle')}</p>
109 <ul class="flex flex-col gap-2">
110 {preview.scopes.map((scope) => (
111 <li key={scope} class="flex items-start gap-2 text-sm leading-[1.6] text-muted">
112 <ShieldCheck size={16} class="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
113 <span>{scopeLabel(scope)}</span>
114 </li>
115 ))}
116 </ul>
117 <p class="mt-4 border-t border-line pt-3 text-xs leading-[1.65] text-muted">{t('consent.clientHint')}</p>
118 </section>
119 <div class="flex flex-col gap-3">
120 <Button type="button" busy={busy} onClick={() => submit('allow')}>
121 {t('consent.allow')}
122 </Button>
123 <Button type="button" secondary disabled={busy} onClick={() => submit('deny')}>
124 {t('consent.deny')}
125 </Button>
126 </div>
127 </>
128 )
129}
Deleted view/src/pages/consent/index.ts
@@ -1 +0,0 @@
1export { ConsentPage } from './ui/consent-page'
Modified view/src/shared/api/auth/auth.schemas.ts View view/src/shared/api/auth/auth.schemas.ts
@@ -97,25 +97,6 @@
9797 ready: boolean;
9898}
9999
100export interface ConsentDecision {
101 /** 下一步跳转:允许则回 `/api/v1/oauth/authorize?...`,拒绝则回客户端并带 `error=access_denied`。 */
102 redirect: string;
103}
104
105/**
106 * 同意页提交体:授权参数在查询串里,请求体只带决定。
107 */
108export interface ConsentForm {
109 decision: string;
110}
111
112export interface ConsentPreview {
113 client_id: string;
114 client_name: string;
115 scopes: string[];
116 username: string;
117}
118
119100export interface CreateInviteBody {
120101 max_uses?: number;
121102 /** @nullable */
@@ -160,8 +141,12 @@
160141 "open" | "approval" | "invite_only" | "private";
161142export interface CreateOrganizationBody {
162143 /** @nullable */
144 allow_invite?: boolean | null;
145 /** @nullable */
163146 allow_join?: boolean | null;
164147 /** @nullable */
148 allow_join_level?: number | null;
149 /** @nullable */
165150 avatar_url?: string | null;
166151 /** @nullable */
167152 description?: string | null;
@@ -425,7 +410,9 @@
425410
426411export type OrganizationStatus = "active" | "suspended" | "archived";
427412export type EnvelopeOrganizationSummaryData = {
413 allow_invite: boolean;
428414 allow_join: boolean;
415 allow_join_level: number;
429416 /** @nullable */
430417 archived_at?: string | null;
431418 /** @nullable */
@@ -796,7 +783,9 @@
796783}
797784
798785export type EnvelopeVecOrganizationSummaryDataItem = {
786 allow_invite: boolean;
799787 allow_join: boolean;
788 allow_join_level: number;
800789 /** @nullable */
801790 archived_at?: string | null;
802791 /** @nullable */
@@ -1068,7 +1057,9 @@
10681057}
10691058
10701059export interface OrganizationSummary {
1060 allow_invite: boolean;
10711061 allow_join: boolean;
1062 allow_join_level: number;
10721063 /** @nullable */
10731064 archived_at?: string | null;
10741065 /** @nullable */
@@ -1148,14 +1139,6 @@
11481139export interface RegisterCodeResponse {
11491140 /** @minimum 0 */
11501141 expires_in_secs: number;
1151}
1152
1153export interface RevokeRequest {
1154 /** @nullable */
1155 client_id?: string | null;
1156 /** @nullable */
1157 client_secret?: string | null;
1158 token: string;
11591142}
11601143
11611144export interface RoleSummary {
@@ -1293,8 +1276,12 @@
12931276
12941277export interface UpdateOrganizationBody {
12951278 /** @nullable */
1279 allow_invite?: boolean | null;
1280 /** @nullable */
12961281 allow_join?: boolean | null;
12971282 /** @nullable */
1283 allow_join_level?: number | null;
1284 /** @nullable */
12981285 avatar_url?: string | null;
12991286 /** @nullable */
13001287 description?: string | null;
@@ -1371,68 +1358,6 @@
13711358 * @minimum 0
13721359 */
13731360 page_size?: number;
1374};
1375
1376export type OauthAuthorizeParams = {
1377 /**
1378 * 固定为 `code`
1379 */
1380 response_type: string;
1381 /**
1382 * 已注册的客户端 id
1383 */
1384 client_id: string;
1385 /**
1386 * 已注册的回调地址,支持子域通配
1387 */
1388 redirect_uri: string;
1389 /**
1390 * 16..=512 字符的不透明串
1391 */
1392 state: string;
1393 /**
1394 * PKCE S256 challenge(43 字符 base64url)
1395 */
1396 code_challenge: string;
1397 /**
1398 * 固定为 `S256`
1399 */
1400 code_challenge_method: string;
1401 /**
1402 * 空格分隔,必须 ⊆ 客户端登记 scope
1403 */
1404 scope?: string;
1405 /**
1406 * OIDC nonce,原样写入 id_token
1407 */
1408 nonce?: string;
1409 /**
1410 * `none` / `login` / `consent`
1411 */
1412 prompt?: string;
1413 /**
1414 * 会话最大年龄(秒),超出就重新登录
1415 */
1416 max_age?: number;
1417};
1418
1419export type OauthLogoutParams = {
1420 /**
1421 * 之前签发的 id_token(校验签名且须绑定当前会话)
1422 */
1423 id_token_hint?: string;
1424 /**
1425 * 必须在客户端注册过
1426 */
1427 post_logout_redirect_uri?: string;
1428 /**
1429 * 原样回传
1430 */
1431 state?: string;
1432 /**
1433 * 无 id_token_hint 时可用
1434 */
1435 client_id?: string;
14361361};
14371362
14381363export type AuthSshKeyListParams = {
Modified view/src/shared/api/auth/auth.ts View view/src/shared/api/auth/auth.ts
@@ -19,9 +19,6 @@
1919 AuthSshKeyListParams,
2020 BanMemberBody,
2121 CheckPermissionBody,
22 ConsentDecision,
23 ConsentForm,
24 ConsentPreview,
2522 CreateInviteBody,
2623 CreateOrganizationBody,
2724 CreateRoleBody,
@@ -58,14 +55,11 @@
5855 LoginCodeRequest,
5956 LoginCodeVerifyRequest,
6057 LoginVerifyRequest,
61 OauthAuthorizeParams,
62 OauthLogoutParams,
6358 PasswordResetConfirmRequest,
6459 PasswordResetRequest,
6560 ReasonBody,
6661 RedeemInviteBody,
6762 RegisterCodeRequest,
68 RevokeRequest,
6963 SetMemberRoleBody,
7064 SetRolePermissionsBody,
7165 SshKeyAddRequest,
@@ -81,63 +75,6 @@
8175
8276import { authClient } from "../../lib/jwtmanager/http";
8377
84export type oauthMetadataResponse200 = {
85 data: void;
86 status: 200;
87};
88
89export type oauthMetadataResponseSuccess = oauthMetadataResponse200 & {
90 headers: Headers;
91};
92export type oauthMetadataResponse = oauthMetadataResponseSuccess;
93
94export const getOauthMetadataUrl = () => {
95 return `/.well-known/oauth-authorization-server`;
96};
97
98/**
99 * @summary OAuth 授权服务器元数据(含 OIDC 字段)
100 */
101export const oauthMetadata = async (
102 options?: Parameters<typeof authClient>[1],
103): Promise<oauthMetadataResponse> => {
104 return authClient<oauthMetadataResponse>(getOauthMetadataUrl(), {
105 ...options,
106 method: "GET",
107 });
108};
109
110export type oauthOpenidConfigurationResponse200 = {
111 data: void;
112 status: 200;
113};
114
115export type oauthOpenidConfigurationResponseSuccess =
116 oauthOpenidConfigurationResponse200 & {
117 headers: Headers;
118 };
119export type oauthOpenidConfigurationResponse =
120 oauthOpenidConfigurationResponseSuccess;
121
122export const getOauthOpenidConfigurationUrl = () => {
123 return `/.well-known/openid-configuration`;
124};
125
126/**
127 * @summary OIDC 发现文档
128 */
129export const oauthOpenidConfiguration = async (
130 options?: Parameters<typeof authClient>[1],
131): Promise<oauthOpenidConfigurationResponse> => {
132 return authClient<oauthOpenidConfigurationResponse>(
133 getOauthOpenidConfigurationUrl(),
134 {
135 ...options,
136 method: "GET",
137 },
138 );
139};
140
14178export type authAccessTokenListResponse200 = {
14279 data: EnvelopeVecAccessTokenSummary;
14380 status: 200;
@@ -389,7 +326,7 @@
389326};
390327
391328/**
392 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。
329 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。
393330 * @summary 签发内部应用令牌
394331 */
395332export const authAppTokenIssue = async (
@@ -428,54 +365,6 @@
428365 });
429366};
430367
431export type oauthAuthorizeResponse302 = {
432 data: void;
433 status: 302;
434};
435
436export type oauthAuthorizeResponse400 = {
437 data: void;
438 status: 400;
439};
440
441export type oauthAuthorizeResponseError = (
442 oauthAuthorizeResponse302 | oauthAuthorizeResponse400
443) & {
444 headers: Headers;
445};
446
447export type oauthAuthorizeResponse = oauthAuthorizeResponseError;
448
449export const getOauthAuthorizeUrl = (params: OauthAuthorizeParams) => {
450 const normalizedParams = new URLSearchParams();
451
452 Object.entries(params || {}).forEach(([key, value]) => {
453 if (value !== undefined) {
454 normalizedParams.append(key, value === null ? "null" : String(value));
455 }
456 });
457
458 const stringifiedParams = normalizedParams.toString();
459
460 return stringifiedParams.length > 0
461 ? `/api/v1/oauth/authorize?${stringifiedParams}`
462 : `/api/v1/oauth/authorize`;
463};
464
465/**
466 * 未登录跳到 `/login`;首次授权跳 `/consent`;`prompt=none` 直接返回 `login_required` / `consent_required`。
467 * @summary 授权端点(授权码 + PKCE S256)
468 */
469export const oauthAuthorize = async (
470 params: OauthAuthorizeParams,
471 options?: Parameters<typeof authClient>[1],
472): Promise<oauthAuthorizeResponse> => {
473 return authClient<oauthAuthorizeResponse>(getOauthAuthorizeUrl(params), {
474 ...options,
475 method: "GET",
476 });
477};
478
479368export type authChallengeResponse200 = {
480369 data: EnvelopeChallengeResponse;
481370 status: 200;
@@ -513,122 +402,6 @@
513402 });
514403};
515404
516export type oauthConsentPreviewResponse200 = {
517 data: ConsentPreview;
518 status: 200;
519};
520
521export type oauthConsentPreviewResponse400 = {
522 data: void;
523 status: 400;
524};
525
526export type oauthConsentPreviewResponse401 = {
527 data: void;
528 status: 401;
529};
530
531export type oauthConsentPreviewResponseSuccess =
532 oauthConsentPreviewResponse200 & {
533 headers: Headers;
534 };
535export type oauthConsentPreviewResponseError = (
536 oauthConsentPreviewResponse400 | oauthConsentPreviewResponse401
537) & {
538 headers: Headers;
539};
540
541export type oauthConsentPreviewResponse =
542 oauthConsentPreviewResponseSuccess | oauthConsentPreviewResponseError;
543
544export const getOauthConsentPreviewUrl = () => {
545 return `/api/v1/oauth/consent`;
546};
547
548/**
549 * @summary 同意页所需的客户端与 scope 预览
550 */
551export const oauthConsentPreview = async (
552 options?: Parameters<typeof authClient>[1],
553): Promise<oauthConsentPreviewResponse> => {
554 return authClient<oauthConsentPreviewResponse>(getOauthConsentPreviewUrl(), {
555 ...options,
556 method: "GET",
557 });
558};
559
560export type oauthConsentDecideResponse200 = {
561 data: ConsentDecision;
562 status: 200;
563};
564
565export type oauthConsentDecideResponse400 = {
566 data: void;
567 status: 400;
568};
569
570export type oauthConsentDecideResponse401 = {
571 data: void;
572 status: 401;
573};
574
575export type oauthConsentDecideResponseSuccess =
576 oauthConsentDecideResponse200 & {
577 headers: Headers;
578 };
579export type oauthConsentDecideResponseError = (
580 oauthConsentDecideResponse400 | oauthConsentDecideResponse401
581) & {
582 headers: Headers;
583};
584
585export type oauthConsentDecideResponse =
586 oauthConsentDecideResponseSuccess | oauthConsentDecideResponseError;
587
588export const getOauthConsentDecideUrl = () => {
589 return `/api/v1/oauth/consent`;
590};
591
592/**
593 * 授权参数从查询串读取;`decision=allow` 落库并回 `/api/v1/oauth/authorize?...`,`deny` 回客户端并带 `error=access_denied`。
594 * @summary 提交同意决定
595 */
596export const oauthConsentDecide = async (
597 consentForm: ConsentForm,
598 options?: Parameters<typeof authClient>[1],
599): Promise<oauthConsentDecideResponse> => {
600 const getHeaders = (
601 h?: NonNullable<RequestInit["headers"]>,
602 ): Record<string, string | readonly string[]> => {
603 if (!h) return {};
604 if (h instanceof Headers) return Object.fromEntries(h.entries());
605 if (Symbol.iterator in h) {
606 return Object.fromEntries(
607 Array.from(
608 h as Iterable<Iterable<string>>,
609 (entry) => Array.from(entry) as [string, string],
610 ),
611 );
612 }
613 const headers: Record<string, string | readonly string[]> = {};
614 for (const [name, value] of Object.entries<
615 string | readonly string[] | undefined
616 >(h)) {
617 if (value !== undefined) headers[name] = value;
618 }
619 return headers;
620 };
621 return authClient<oauthConsentDecideResponse>(getOauthConsentDecideUrl(), {
622 ...options,
623 method: "POST",
624 headers: {
625 "Content-Type": "application/json",
626 ...getHeaders(options?.headers),
627 },
628 body: JSON.stringify(consentForm),
629 });
630};
631
632405export type authLoginResponse200 = {
633406 data: EnvelopeUserLoginResponse;
634407 status: 200;
@@ -990,56 +763,6 @@
990763 });
991764};
992765
993export type oauthLogoutResponse200 = {
994 data: void;
995 status: 200;
996};
997
998export type oauthLogoutResponse302 = {
999 data: void;
1000 status: 302;
1001};
1002
1003export type oauthLogoutResponseSuccess = oauthLogoutResponse200 & {
1004 headers: Headers;
1005};
1006export type oauthLogoutResponseError = oauthLogoutResponse302 & {
1007 headers: Headers;
1008};
1009
1010export type oauthLogoutResponse =
1011 oauthLogoutResponseSuccess | oauthLogoutResponseError;
1012
1013export const getOauthLogoutUrl = (params?: OauthLogoutParams) => {
1014 const normalizedParams = new URLSearchParams();
1015
1016 Object.entries(params || {}).forEach(([key, value]) => {
1017 if (value !== undefined) {
1018 normalizedParams.append(key, value === null ? "null" : String(value));
1019 }
1020 });
1021
1022 const stringifiedParams = normalizedParams.toString();
1023
1024 return stringifiedParams.length > 0
1025 ? `/api/v1/oauth/logout?${stringifiedParams}`
1026 : `/api/v1/oauth/logout`;
1027};
1028
1029/**
1030 * 撤销当前会话与其全客户端刷新令牌,并向 `backchannel_logout_uris` 发 `logout_token`;最后回跳已注册的 `post_logout_redirect_uri`。GET 带验证通过的 `id_token_hint`(签名有效且绑定当前会话)时免确认直接登出,否则返回确认页,确认后 POST 完成登出。
1031 * @summary 单点登出(RP-Initiated Logout)
1032 */
1033export const oauthLogout = async (
1034 params?: OauthLogoutParams,
1035 options?: Parameters<typeof authClient>[1],
1036): Promise<oauthLogoutResponse> => {
1037 return authClient<oauthLogoutResponse>(getOauthLogoutUrl(params), {
1038 ...options,
1039 method: "GET",
1040 });
1041};
1042
1043766export type authMeResponse200 = {
1044767 data: EnvelopeMeResponse;
1045768 status: 200;
@@ -1423,69 +1146,6 @@
14231146 });
14241147};
14251148
1426export type oauthRevokeResponse200 = {
1427 data: void;
1428 status: 200;
1429};
1430
1431export type oauthRevokeResponse401 = {
1432 data: void;
1433 status: 401;
1434};
1435
1436export type oauthRevokeResponseSuccess = oauthRevokeResponse200 & {
1437 headers: Headers;
1438};
1439export type oauthRevokeResponseError = oauthRevokeResponse401 & {
1440 headers: Headers;
1441};
1442
1443export type oauthRevokeResponse =
1444 oauthRevokeResponseSuccess | oauthRevokeResponseError;
1445
1446export const getOauthRevokeUrl = () => {
1447 return `/api/v1/oauth/revoke`;
1448};
1449
1450/**
1451 * @summary 令牌撤销(RFC 7009)
1452 */
1453export const oauthRevoke = async (
1454 revokeRequest: RevokeRequest,
1455 options?: Parameters<typeof authClient>[1],
1456): Promise<oauthRevokeResponse> => {
1457 const getHeaders = (
1458 h?: NonNullable<RequestInit["headers"]>,
1459 ): Record<string, string | readonly string[]> => {
1460 if (!h) return {};
1461 if (h instanceof Headers) return Object.fromEntries(h.entries());
1462 if (Symbol.iterator in h) {
1463 return Object.fromEntries(
1464 Array.from(
1465 h as Iterable<Iterable<string>>,
1466 (entry) => Array.from(entry) as [string, string],
1467 ),
1468 );
1469 }
1470 const headers: Record<string, string | readonly string[]> = {};
1471 for (const [name, value] of Object.entries<
1472 string | readonly string[] | undefined
1473 >(h)) {
1474 if (value !== undefined) headers[name] = value;
1475 }
1476 return headers;
1477 };
1478 return authClient<oauthRevokeResponse>(getOauthRevokeUrl(), {
1479 ...options,
1480 method: "POST",
1481 headers: {
1482 "Content-Type": "application/json",
1483 ...getHeaders(options?.headers),
1484 },
1485 body: JSON.stringify(revokeRequest),
1486 });
1487};
1488
14891149export type authLogoutResponse200 = {
14901150 data: EnvelopeLogoutResponse;
14911151 status: 200;
@@ -1562,7 +1222,7 @@
15621222};
15631223
15641224/**
1565 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。任何服务只需 `/.well-known/jwks.json` 的公钥即可本地验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。
1225 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。
15661226 * @summary 用会话换取平台访问令牌
15671227 */
15681228export const authTokenIssue = async (
@@ -1791,121 +1451,6 @@
17911451 return authClient<authSshKeyDeleteResponse>(getAuthSshKeyDeleteUrl(keyId), {
17921452 ...options,
17931453 method: "DELETE",
1794 });
1795};
1796
1797export type oauthTokenResponse200 = {
1798 data: unknown;
1799 status: 200;
1800};
1801
1802export type oauthTokenResponse400 = {
1803 data: void;
1804 status: 400;
1805};
1806
1807export type oauthTokenResponse401 = {
1808 data: void;
1809 status: 401;
1810};
1811
1812export type oauthTokenResponseSuccess = oauthTokenResponse200 & {
1813 headers: Headers;
1814};
1815export type oauthTokenResponseError = (
1816 oauthTokenResponse400 | oauthTokenResponse401
1817) & {
1818 headers: Headers;
1819};
1820
1821export type oauthTokenResponse =
1822 oauthTokenResponseSuccess | oauthTokenResponseError;
1823
1824export const getOauthTokenUrl = () => {
1825 return `/api/v1/oauth/token`;
1826};
1827
1828/**
1829 * 支持 `authorization_code`(PKCE)、`refresh_token`(轮换 + 重放检测)、`client_credentials`(机密客户端)。带 `openid` scope 时响应额外包含 `id_token`。
1830 * @summary 令牌端点
1831 */
1832export const oauthToken = async (
1833 tokenRequest: TokenRequest,
1834 options?: Parameters<typeof authClient>[1],
1835): Promise<oauthTokenResponse> => {
1836 const getHeaders = (
1837 h?: NonNullable<RequestInit["headers"]>,
1838 ): Record<string, string | readonly string[]> => {
1839 if (!h) return {};
1840 if (h instanceof Headers) return Object.fromEntries(h.entries());
1841 if (Symbol.iterator in h) {
1842 return Object.fromEntries(
1843 Array.from(
1844 h as Iterable<Iterable<string>>,
1845 (entry) => Array.from(entry) as [string, string],
1846 ),
1847 );
1848 }
1849 const headers: Record<string, string | readonly string[]> = {};
1850 for (const [name, value] of Object.entries<
1851 string | readonly string[] | undefined
1852 >(h)) {
1853 if (value !== undefined) headers[name] = value;
1854 }
1855 return headers;
1856 };
1857 return authClient<oauthTokenResponse>(getOauthTokenUrl(), {
1858 ...options,
1859 method: "POST",
1860 headers: {
1861 "Content-Type": "application/json",
1862 ...getHeaders(options?.headers),
1863 },
1864 body: JSON.stringify(tokenRequest),
1865 });
1866};
1867
1868export type oauthUserinfoResponse200 = {
1869 data: unknown;
1870 status: 200;
1871};
1872
1873export type oauthUserinfoResponse401 = {
1874 data: void;
1875 status: 401;
1876};
1877
1878export type oauthUserinfoResponse403 = {
1879 data: void;
1880 status: 403;
1881};
1882
1883export type oauthUserinfoResponseSuccess = oauthUserinfoResponse200 & {
1884 headers: Headers;
1885};
1886export type oauthUserinfoResponseError = (
1887 oauthUserinfoResponse401 | oauthUserinfoResponse403
1888) & {
1889 headers: Headers;
1890};
1891
1892export type oauthUserinfoResponse =
1893 oauthUserinfoResponseSuccess | oauthUserinfoResponseError;
1894
1895export const getOauthUserinfoUrl = () => {
1896 return `/api/v1/oauth/userinfo`;
1897};
1898
1899/**
1900 * 需要 `openid` scope;`profile` 返回 `preferred_username` / `name`,`email` 返回 `email` / `email_verified`。令牌只接受 `Authorization: Bearer` 头(RFC 6750 §2.3),查询串携带一律 401。
1901 * @summary OIDC userinfo
1902 */
1903export const oauthUserinfo = async (
1904 options?: Parameters<typeof authClient>[1],
1905): Promise<oauthUserinfoResponse> => {
1906 return authClient<oauthUserinfoResponse>(getOauthUserinfoUrl(), {
1907 ...options,
1908 method: "GET",
19091454 });
19101455};
19111456
Modified view/src/shared/config/routes.ts View view/src/shared/config/routes.ts
@@ -10,5 +10,4 @@
1010 resetPassword: '/reset-password',
1111 resetComplete: '/reset-complete',
1212 account: '/success',
13 consent: '/consent',
1413} as const
Modified view/src/shared/i18n/messages.ts View view/src/shared/i18n/messages.ts
@@ -7,6 +7,7 @@
77 'common.backToLogin': '返回登录',
88 'common.close': '关闭',
99 'common.resend': '重新发送',
10 'common.retry': '重试',
1011 'common.sending': '正在发送…',
1112
1213 'error.invalid_credentials': '邮箱、用户名或密码有误,请核对后重试。',
@@ -205,19 +206,7 @@
205206 'account.descriptionDone': '你已安全登录 GitDataAI。',
206207 'account.unavailable': '暂时无法读取账户信息。',
207208
208 'consent.title': '授权 {client}',
209 'consent.description': '应用请求访问你的账户({user})。请确认下面这些权限。',
210 'consent.scopesTitle': '该应用将获得',
211 'consent.clientHint': '继续即表示你同意该应用按上述权限访问。你可以随时在账户设置中撤回授权。',
212 'consent.allow': '允许访问',
213 'consent.deny': '拒绝',
214 'consent.loading': '正在读取授权请求…',
215 'consent.retry': '重试',
216 'consent.error': '无法完成授权,请重试。',
217 'consent.errorTitle': '授权请求无效',
218 'consent.errorDescription': '该授权链接可能已过期。请回到应用重新发起登录。',
219209
220 'scope.openid': '使用你的身份登录(基础信息)',
221210 'scope.user_name': '读取你的用户名',
222211 'scope.profile': '读取你的个人资料',
223212 'scope.email': '读取你的邮箱地址',
@@ -244,6 +233,7 @@
244233 'common.backToLogin': 'Back to sign in',
245234 'common.close': 'Close',
246235 'common.resend': 'Resend',
236 'common.retry': 'Try again',
247237 'common.sending': 'Sending…',
248238
249239 'error.invalid_credentials': 'The email, username or password is incorrect. Check it and try again.',
@@ -443,19 +433,7 @@
443433 'account.descriptionDone': 'You are signed in to GitDataAI.',
444434 'account.unavailable': 'Your account details are unavailable right now.',
445435
446 'consent.title': 'Authorize {client}',
447 'consent.description': 'The app is requesting access to your account ({user}). Review the permissions below.',
448 'consent.scopesTitle': 'This app will be able to',
449 'consent.clientHint': 'Continuing grants these permissions. You can revoke access later in your account settings.',
450 'consent.allow': 'Allow access',
451 'consent.deny': 'Deny',
452 'consent.loading': 'Loading the authorization request…',
453 'consent.retry': 'Try again',
454 'consent.error': 'The authorization could not be completed. Try again.',
455 'consent.errorTitle': 'Invalid authorization request',
456 'consent.errorDescription': 'This link may have expired. Go back to the app and start sign-in again.',
457436
458 'scope.openid': 'Sign you in with basic identity',
459437 'scope.user_name': 'Read your username',
460438 'scope.profile': 'Read your profile',
461439 'scope.email': 'Read your email address',
Modified view/src/shared/lib/jwtmanager/index.ts View view/src/shared/lib/jwtmanager/index.ts
@@ -1,8 +1,9 @@
11export { decodeJwt } from './decode';
22export { createJwtManager, jwtManager } from './manager';
33export * from './types';
4export { beginOAuth, finishOAuth, connectSocket } from './oauth';
5export type { OAuthOptions, SocketOptions } from './oauth';
4export { connectSocket } from './socket';
5export type { SocketOptions } from './socket';
6export { acquireSessionToken } from './session';
67
78export { apiClient, authClient, gitClient, configureRequests, createRequestExecutor, serviceFetch, serviceJwtManagers } from './http';
89export { HttpError } from './http-types';
Deleted view/src/shared/lib/jwtmanager/oauth.ts
@@ -1,156 +0,0 @@
1import { serviceFetch } from './http'
2import { jwtManager } from './manager'
3import { type JwtManager, JwtManagerError } from './types'
4
5export interface OAuthOptions {
6 issuer: string
7 clientId: string
8 redirectUri: string
9 scopes: string[]
10}
11
12interface PendingAuthorization {
13 issuer: string
14 clientId: string
15 redirectUri: string
16 verifier: string
17 state: string
18 createdAt: number
19}
20
21const PENDING_KEY = 'gitdata.oauth.pending'
22
23function base64url(bytes: Uint8Array): string {
24 return btoa(String.fromCharCode(...bytes))
25 .replaceAll('+', '-')
26 .replaceAll('/', '_')
27 .replaceAll('=', '')
28}
29
30function random(): string {
31 return base64url(crypto.getRandomValues(new Uint8Array(32)))
32}
33
34function issuerUrl(raw: string): string {
35 const url = new URL(raw)
36 if (
37 url.origin !== raw ||
38 url.username ||
39 url.password ||
40 (url.protocol !== 'https:' &&
41 !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
42 ) {
43 throw new JwtManagerError('issuer_invalid', 'OAuth issuer must be an HTTPS origin or loopback HTTP origin')
44 }
45 return raw
46}
47
48export async function beginOAuth(options: OAuthOptions): Promise<void> {
49 const issuer = issuerUrl(options.issuer)
50 const redirect = new URL(options.redirectUri)
51 if (redirect.origin !== location.origin || redirect.hash)
52 throw new JwtManagerError('redirect_invalid', 'OAuth callback must belong to the current origin')
53 const verifier = random()
54 const state = random()
55 const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))))
56 const pending: PendingAuthorization = {
57 issuer,
58 clientId: options.clientId,
59 redirectUri: options.redirectUri,
60 verifier,
61 state,
62 createdAt: Date.now(),
63 }
64 sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
65 const target = new URL('/api/v1/oauth/authorize', issuer)
66 target.search = new URLSearchParams({
67 response_type: 'code',
68 client_id: options.clientId,
69 redirect_uri: options.redirectUri,
70 scope: options.scopes.join(' '),
71 state,
72 code_challenge: challenge,
73 code_challenge_method: 'S256',
74 }).toString()
75 location.assign(target.href)
76}
77
78export async function finishOAuth(manager: JwtManager = jwtManager): Promise<boolean> {
79 const params = new URLSearchParams(location.search)
80 if (!params.has('code') && !params.has('error')) return false
81 const raw = sessionStorage.getItem(PENDING_KEY)
82 sessionStorage.removeItem(PENDING_KEY)
83 if (!raw) throw new JwtManagerError('oauth_state_invalid', 'OAuth transaction is missing')
84 // sessionStorage 数据可能被用户/扩展污染:解析失败转类型化错误而非裸 SyntaxError。
85 let pending: PendingAuthorization
86 try {
87 pending = JSON.parse(raw) as PendingAuthorization
88 } catch {
89 throw new JwtManagerError('oauth_state_invalid', 'OAuth transaction is corrupted')
90 }
91 const issuer = issuerUrl(pending.issuer)
92 if (
93 params.get('state') !== pending.state ||
94 params.get('iss') !== issuer ||
95 Date.now() - pending.createdAt > 600_000
96 ) {
97 throw new JwtManagerError('oauth_state_invalid', 'OAuth state or issuer does not match')
98 }
99 const callback = new URL(pending.redirectUri)
100 if (callback.origin !== location.origin || callback.pathname !== location.pathname)
101 throw new JwtManagerError('redirect_invalid', 'OAuth callback does not match')
102 history.replaceState(null, '', pending.redirectUri)
103 if (params.has('error')) throw new JwtManagerError('oauth_error', params.get('error') ?? 'OAuth failed')
104 const response = await fetch(`${issuer}/api/v1/oauth/token`, {
105 method: 'POST',
106 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
107 body: new URLSearchParams({
108 grant_type: 'authorization_code',
109 client_id: pending.clientId,
110 redirect_uri: pending.redirectUri,
111 code: params.get('code') ?? '',
112 code_verifier: pending.verifier,
113 }),
114 signal: AbortSignal.timeout(10_000),
115 })
116 // token 端点异常时可能返回 HTML/空体:JSON 解析失败同样转类型化错误。
117 let token: { access_token?: string; token_type?: string; error?: string }
118 try {
119 token = (await response.json()) as { access_token?: string; token_type?: string; error?: string }
120 } catch {
121 throw new JwtManagerError('oauth_token_failed', 'OAuth token endpoint returned a non-JSON response')
122 }
123 if (!response.ok || !token.access_token || token.token_type !== 'Bearer')
124 throw new JwtManagerError('oauth_token_failed', token.error ?? 'OAuth token exchange failed')
125 await manager.set(token.access_token)
126 return true
127}
128
129export interface SocketOptions {
130 issuer: string
131 url: string
132 manager?: JwtManager
133}
134
135export async function connectSocket(options: SocketOptions): Promise<WebSocket> {
136 const issuer = issuerUrl(options.issuer)
137 const url = new URL(options.url, location.href)
138 if (
139 url.protocol !== 'wss:' &&
140 !(url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
141 ) {
142 throw new JwtManagerError('socket_url_invalid', 'Socket URL requires WSS or loopback WS')
143 }
144 const response = await serviceFetch('auth', `${issuer}/api/v1/oauth/socket-tickets`, {
145 baseUrl: issuer,
146 method: 'POST',
147 manager: options.manager ?? jwtManager,
148 headers: { 'Content-Type': 'application/json' },
149 body: JSON.stringify({ origin: location.origin }),
150 signal: AbortSignal.timeout(10_000),
151 })
152 const body = (await response.json()) as { ticket?: string }
153 if (!response.ok || !body.ticket) throw new JwtManagerError('socket_ticket_failed', 'Socket ticket issuance failed')
154 url.searchParams.set('ticket', body.ticket)
155 return new WebSocket(url)
156}
Added view/src/shared/lib/jwtmanager/session.ts View view/src/shared/lib/jwtmanager/session.ts
@@ -0,0 +1,26 @@
1import { serviceFetch } from './http'
2import { jwtManager } from './manager'
3import { type JwtManager, JwtManagerError } from './types'
4
5/** 用同域 identity session cookie 换取平台 JWT。 */
6export async function acquireSessionToken(manager: JwtManager = jwtManager): Promise<string | null> {
7 const response = await serviceFetch('auth', '/api/v1/oauth/session/token', {
8 method: 'POST',
9 auth: 'session',
10 handleUnauthorized: false,
11 headers: { 'Content-Type': 'application/json' },
12 body: '{}',
13 signal: AbortSignal.timeout(10_000),
14 })
15 if (response.status === 401) return null
16 let body: { data?: { token?: string }; message?: string }
17 try {
18 body = (await response.json()) as { data?: { token?: string }; message?: string }
19 } catch {
20 throw new JwtManagerError('session_token_failed', 'Session token endpoint returned a non-JSON response')
21 }
22 if (!response.ok || !body.data?.token)
23 throw new JwtManagerError('session_token_failed', body.message ?? 'Session token exchange failed')
24 await manager.set(body.data.token)
25 return body.data.token
26}
No newline at end of file
Added view/src/shared/lib/jwtmanager/socket.ts View view/src/shared/lib/jwtmanager/socket.ts
@@ -0,0 +1,47 @@
1import { serviceFetch } from './http'
2import { jwtManager } from './manager'
3import { type JwtManager, JwtManagerError } from './types'
4
5export interface SocketOptions {
6 issuer: string
7 url: string
8 manager?: JwtManager
9}
10
11function issuerUrl(raw: string): string {
12 const url = new URL(raw)
13 if (
14 url.origin !== raw ||
15 url.username ||
16 url.password ||
17 (url.protocol !== 'https:' &&
18 !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
19 ) {
20 throw new JwtManagerError('issuer_invalid', 'Issuer must be an HTTPS origin or loopback HTTP origin')
21 }
22 return raw
23}
24
25export async function connectSocket(options: SocketOptions): Promise<WebSocket> {
26 const issuer = issuerUrl(options.issuer)
27 const url = new URL(options.url, location.href)
28 if (
29 url.protocol !== 'wss:' &&
30 !(url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
31 ) {
32 throw new JwtManagerError('socket_url_invalid', 'Socket URL requires WSS or loopback WS')
33 }
34 const response = await serviceFetch('auth', `${issuer}/api/v1/oauth/socket-tickets`, {
35 baseUrl: issuer,
36 method: 'POST',
37 manager: options.manager ?? jwtManager,
38 credentials: 'include',
39 headers: { 'Content-Type': 'application/json' },
40 body: JSON.stringify({ origin: location.origin }),
41 signal: AbortSignal.timeout(10_000),
42 })
43 const body = (await response.json()) as { ticket?: string }
44 if (!response.ok || !body.ticket) throw new JwtManagerError('socket_ticket_failed', 'Socket ticket issuance failed')
45 url.searchParams.set('ticket', body.ticket)
46 return new WebSocket(url)
47}
No newline at end of file
Modified Cargo.lock View Cargo.lock
@@ -790,7 +790,6 @@
790790 "opentelemetry-appender-tracing",
791791 "opentelemetry-otlp",
792792 "opentelemetry_sdk",
793 "percent-encoding 2.3.2",
794793 "prost 0.14.4",
795794 "prost-types 0.14.4",
796795 "proto",
@@ -1492,7 +1491,6 @@
14921491 "chrono",
14931492 "dotenvy",
14941493 "figment",
1495 "models",
14961494 "sea-orm",
14971495 "serde",
14981496 "serde_json",
@@ -8835,7 +8833,6 @@
88358833 "models",
88368834 "pulldown-cmark",
88378835 "pulls",
8838 "rand 0.8.8",
88398836 "redis",
88408837 "sea-orm",
88418838 "serde",
Modified docker-compose.dev.yml View docker-compose.dev.yml
@@ -64,21 +64,6 @@
6464 timeout: 3s
6565 retries: 20
6666
67 # KenOAuth 认证中心。image 由外部构建;配置从 config/oauth.yaml 只读挂载。
68 # `just infra` 默认不启动,需要时:docker compose -f docker-compose.dev.yml --profile oauth up -d oauth
69 oauth:
70 profiles: ["oauth"]
71 image: harbor.gitdata.me/app/kenoauth:latest
72 container_name: gitdata-dev-oauth
73 restart: unless-stopped
74 network_mode: host
75 volumes:
76 - ./config/oauth.yaml:/app/config/config.yaml:ro
77 depends_on:
78 postgres:
79 condition: service_healthy
80 redis:
81 condition: service_healthy
8267
8368 # 本地 S3 bucket 初始化(建出 config/local/s3.json 里声明的 bucket)
8469 minio-init:
Modified Dockerfile View Dockerfile
@@ -99,7 +99,7 @@
9999COPY --from=builder /out/kenapi /usr/local/bin/
100100
101101LABEL org.opencontainers.image.title="KenApi" \
102 org.opencontainers.image.description="GitDataAI monolith: business API, OAuth, gRPC and email" \
102 org.opencontainers.image.description="GitDataAI monolith: business API, identity, gRPC and email" \
103103 org.opencontainers.image.version="1.0.0"
104104
105105ENV RUST_BACKTRACE=0
Modified openapi.json View openapi.json

Diff too large to display.

Modified orval.config.ts View orval.config.ts
@@ -4,7 +4,7 @@
44// `cargo run -p kenapi -- identity-openapi`。
55export default defineConfig({
66 auth: {
7 input: './lib/identity/OpenApi.Auth.json',
7 input: './lib/auth/OpenApi.Auth.json',
88 output: {
99 target: './view/src/shared/api/auth',
1010 mode: 'split',