refactor: remove OAuth-related modules and update references for identity service integration
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified app/src/main.rs app/src/main.rs +13 −16 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 charts/core/templates/NOTES.txt +1 −1 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 charts/core/templates/oauth.yaml +0 −75
@@ -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 charts/core/values.yaml +4 −21 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 charts/prod/values/prod.yaml +1 −1 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 config/default/auth.json +4 −48 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 config/default/jwt.json +7 −1 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 config/default/oauth.json +0 −32
@@ -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 deploy/compose.yaml +4 −6 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 docs/oauth.md +0 −1256
@@ -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 lib/auth/src/runtime/mod.rs +3 −3 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 lib/auth/src/server/grpc/access_key.rs +1 −1 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 lib/auth/src/server/grpc/agreement.rs +1 −1 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 lib/auth/src/server/grpc/audit.rs +1 −1 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 lib/auth/src/server/grpc/authority.rs +1 −1 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 lib/auth/src/server/grpc/authorization.rs +1 −1 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 lib/auth/src/server/grpc/cache_maintenance.rs +1 −1 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 lib/auth/src/server/grpc/client.rs +0 −256
@@ -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 lib/auth/src/server/grpc/context.rs +1 −1 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 lib/auth/src/server/grpc/email.rs +1 −1 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 lib/auth/src/server/grpc/identity.rs +1 −1 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 lib/auth/src/server/grpc/key_rotation.rs +1 −1 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 lib/auth/src/server/grpc/mfa.rs +1 −1 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 lib/auth/src/server/grpc/mod.rs +0 −4 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 lib/auth/src/server/grpc/organization.rs +2 −2 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 lib/auth/src/server/grpc/session.rs +1 −1 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 lib/auth/src/server/grpc/ssh_key.rs +1 −1 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 lib/auth/src/server/grpc/token.rs +1 −1 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 lib/auth/src/server/http/access_token/service.rs +4 −6 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 lib/auth/src/server/http/app_token/handler.rs +1 −1 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 lib/auth/src/server/http/token/handler.rs +1 −1 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 lib/auth/src/server/http/cors.rs +86 −0 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 lib/auth/src/server/http/mod.rs +1 −0 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 lib/auth/src/server/http/openapi.rs +0 −14 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 lib/auth/src/server/http/router.rs +1 −58 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 lib/auth/src/server/http/socket_ticket.rs +2 −3 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 lib/auth/src/server/oauth/client/mod.rs +0 −8
@@ -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 lib/auth/src/server/oauth/client/registry.rs +0 −529
@@ -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 lib/auth/src/server/oauth/consent.rs +0 −349
@@ -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 lib/auth/src/server/oauth/id_token.rs +0 −153
@@ -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 lib/auth/src/server/oauth/logout.rs +0 −341
@@ -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&state=xyz"));
340 }
341}
Deleted lib/auth/src/server/oauth/mod.rs lib/auth/src/server/oauth/mod.rs +0 −11
@@ -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 lib/auth/src/server/oauth/protocol.rs +0 −1528
@@ -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 lib/auth/src/server/oauth/refresh.rs +0 −345
@@ -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 lib/auth/src/server/oauth/userinfo.rs +0 −153
@@ -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 lib/auth/src/server/auth_svc.rs +11 −9 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 lib/auth/src/server/keys.rs +0 −10 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 lib/auth/src/server/migrate.rs +5 −0 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 lib/auth/src/server/mod.rs +0 −2 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 lib/auth/src/lib.rs +0 −2 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 lib/auth/Cargo.toml +0 −1 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 lib/auth/OpenApi.Auth.json +3 −423 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 lib/config/src/auth.rs +4 −133 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 lib/config/src/jwt.rs +57 −0 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 lib/config/src/lib.rs +4 −4 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 lib/config/src/oauth.rs +0 −158
@@ -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 lib/config/src/scoped.rs +6 −4 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 lib/config/src/session.rs +15 −0 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 lib/config/Cargo.toml +0 −1 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 lib/export/src/identity_client.rs +0 −0 View lib/export/src/identity_client.rs
No content changes.
Modified lib/export/src/lib.rs lib/export/src/lib.rs +2 −7 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 lib/models/src/users/mod.rs +0 −3 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 lib/models/src/users/oauth_clients.rs +0 −118
@@ -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 lib/models/src/users/oauth_consents.rs +0 −39
@@ -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 lib/models/src/users/oauth_refresh_tokens.rs +0 −50
@@ -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 lib/oltp/src/metrics.rs +1 −1 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 lib/proto/proto/auth/internal/v1/client.proto +0 −104
@@ -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 lib/space/src/services/join_rules.rs +1 −1 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 lib/token/src/session/claims.rs +0 −21 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 lib/token/src/session/cookie.rs +4 −19 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 lib/token/src/session/mod.rs +2 −4 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 lib/token/src/session/verifier.rs +27 −170 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 lib/token/src/codec.rs +2 −9 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 lib/token/src/lib.rs +4 −12 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 lib/webapi/src/auth/oauth/callback.rs +0 −100
@@ -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 lib/webapi/src/auth/oauth/flow.rs +0 −177
@@ -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 lib/webapi/src/auth/oauth/login.rs +0 −157
@@ -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 lib/webapi/src/auth/oauth/logout.rs +0 −30
@@ -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 lib/webapi/src/auth/oauth/refresh.rs +0 −75
@@ -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 lib/webapi/src/auth/oauth/state.rs +0 −26
@@ -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 lib/webapi/src/auth/session/callback.rs +85 −0 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 lib/webapi/src/auth/session/cookie.rs +24 −0 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 lib/webapi/src/auth/session/login.rs +71 −0 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 lib/webapi/src/auth/session/logout.rs +22 −0 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 lib/webapi/src/auth/session/mod.rs +1 −5 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 lib/webapi/src/auth/session/project.rs +0 −0 View lib/webapi/src/auth/session/project.rs
No content changes.
Added lib/webapi/src/auth/session/refresh.rs lib/webapi/src/auth/session/refresh.rs +18 −0 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 lib/webapi/src/auth/session/revalidate.rs +6 −6 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 lib/webapi/src/auth/mod.rs +1 −1 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 lib/webapi/src/auth/routes.rs +6 −6 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 lib/webapi/src/lib.rs +3 −11 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 lib/webapi/src/openapi.rs +7 −6 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 lib/webapi/Cargo.toml +0 −1 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/src/app/routing/router.tsx +6 −12 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/src/entities/auth-callback/model/callback-url.ts +8 −32 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/src/entities/auth-callback/index.ts +1 −1 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/src/pages/account/ui/account-page.tsx +1 −1 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 view/src/pages/consent/ui/consent-page.tsx +0 −129
@@ -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 view/src/pages/consent/index.ts +0 −1
@@ -1 +0,0 @@
1export { ConsentPage } from './ui/consent-page'
Modified view/src/shared/api/auth/auth.schemas.ts view/src/shared/api/auth/auth.schemas.ts +14 −89 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/src/shared/api/auth/auth.ts +2 −457 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/src/shared/config/routes.ts +0 −1 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/src/shared/i18n/messages.ts +2 −24 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/src/shared/lib/jwtmanager/index.ts +3 −2 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 view/src/shared/lib/jwtmanager/oauth.ts +0 −156
@@ -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/src/shared/lib/jwtmanager/session.ts +26 −0 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/src/shared/lib/jwtmanager/socket.ts +47 −0 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 Cargo.lock +0 −3 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 docker-compose.dev.yml +0 −15 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 Dockerfile +1 −1 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 openapi.json View openapi.json
Diff too large to display.
Modified orval.config.ts orval.config.ts +1 −1 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',