@eduwork/dsh-oidc 0.3.0-dev.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.en.md +10 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +9 -1
  4. package/README_EN.md +9 -1
  5. package/THIRD_PARTY_NOTICES.en.md +5 -4
  6. package/THIRD_PARTY_NOTICES.md +5 -5
  7. package/docs/account-extensions.en.md +9 -1
  8. package/docs/account-extensions.md +9 -1
  9. package/docs/compatibility.en.md +3 -1
  10. package/docs/compatibility.md +3 -1
  11. package/docs/development.en.md +1 -1
  12. package/docs/development.md +1 -1
  13. package/docs/dsh-integration.en.md +3 -3
  14. package/docs/dsh-integration.md +3 -3
  15. package/docs/enterprise-profile.en.md +47 -0
  16. package/docs/enterprise-profile.md +47 -0
  17. package/docs/gateway-auth/README.md +6 -5
  18. package/docs/gateway-auth/README_EN.md +6 -5
  19. package/docs/gateway-auth/experimental-oidc-llm.en.md +9 -7
  20. package/docs/gateway-auth/experimental-oidc-llm.md +8 -6
  21. package/docs/gateway-auth/litellm-setup.en.md +9 -3
  22. package/docs/gateway-auth/litellm-setup.md +9 -3
  23. package/docs/gateway-auth/oidc-llm-draft.en.md +89 -63
  24. package/docs/gateway-auth/oidc-llm-draft.md +86 -60
  25. package/docs/getting-started.en.md +3 -1
  26. package/docs/getting-started.md +3 -1
  27. package/docs/key-binding-protocol.en.md +1 -1
  28. package/docs/key-binding-protocol.md +1 -1
  29. package/docs/oidc-interoperability.en.md +1 -1
  30. package/docs/oidc-interoperability.md +1 -1
  31. package/docs/open-integration.en.md +3 -1
  32. package/docs/open-integration.md +3 -1
  33. package/examples/ecnu.enterprise-profile.example.json +2 -0
  34. package/examples/enterprise-profile.example.json +2 -0
  35. package/examples/litellm.enterprise-profile.example.json +9 -1
  36. package/examples/oidc-llm.enterprise-profile.example.json +33 -1
  37. package/examples/resources-discovery.example.json +7 -1
  38. package/lib/callback-page.js +9 -4
  39. package/lib/client.js +460 -360
  40. package/lib/desktop-oidc.js +2 -2
  41. package/lib/gateway-backend.js +38 -5
  42. package/lib/index.js +13 -6
  43. package/lib/litellm-protocol.js +2 -1
  44. package/lib/model-types.js +7 -0
  45. package/lib/oidc-llm-protocol.js +6 -2
  46. package/lib/oidc.js +24 -13
  47. package/lib/profile.js +24 -9
  48. package/lib/provider/authorization-scope.js +4 -0
  49. package/lib/provider/core.js +3 -3
  50. package/lib/provider/index.js +18 -2
  51. package/lib/provider/transform-adapter.js +4 -0
  52. package/lib/resources.js +12 -4
  53. package/lib/token-lifetime.js +15 -0
  54. package/lib/typert-schemas.js +14 -12
  55. package/package.json +104 -97
  56. package/protocol/resources.openapi.yaml +11 -3
  57. package/schema/enterprise-profile.v1alpha1.schema.json +36 -0
@@ -2,101 +2,104 @@
2
2
 
3
3
  **简体中文** | [English](oidc-llm-draft.en.md)
4
4
 
5
- **状态:待评审,不作为已发布部署契约。** 本分支已有默认关闭的[实验适配器](experimental-oidc-llm.md),实现范围与限制以其文档为准;服务端行为仍需单独实测。名称、扩展字段、scope、寿命和撤销要求尚未定稿;不是已发布的 OpenID 标准。现有 OIDC 接入继续使用[当前契约](../server-integration-contract.md)。[网关接入入口](README.md) · [English overview](README_EN.md#existing-oidc-and-the-future-draft)
5
+ **状态:已有实验实现的开放草案,尚未定稿,也不是 OpenID 官方标准。** 本文以 ChatECNU 已采用的接入方式和 EduWork 当前适配器为基础,定义可供其他模型平台实现的通用约定。公共客户端仍需显式启用,配置见[实验接入](experimental-oidc-llm.md)。[网关接入入口](README.md) · [服务端接入契约](../server-integration-contract.md)
6
6
 
7
- ## 1. 范围
7
+ ## 1. 范围与接入流程
8
8
 
9
- 浏览器登录后,桌面公共客户端用 Access Token 获取本人的资料、可用模型目录并调用模型。协议复用 OAuth Authorization Code、PKCE S256、loopback 回调和标准 OIDC 字段。基础 OAuth 模式不要求 ID Token;需要严格身份验证的完整 OIDC 模式仍必须验证 ID Token。
9
+ 用户在系统浏览器登录组织账号,桌面公共客户端通过 Authorization Code + PKCE S256 取得 Token,直接用 Access Token 获取用户资料、模型目录并调用模型;无需再申请模型 API Key。其他符合本草案的客户端也可以接入,不依赖 EduWork 品牌或学校专属接口。
10
10
 
11
- 不定义 Key Binding、配额标准、团队管理、计费或远程插件执行。客户端和服务端不能把任意普通 OIDC Token 自动当作模型凭据。
11
+ 面向组织登录,建议采用完整 `oidc` 身份模式,这是 EduWork@ECNU 使用的方式;同时保留 `oauth` 基础模式。二者属于同一个模型接入协议,客户端明确选择,不能自动降级。仅验证身份的普通 OIDC 不会自动获得模型调用权限。
12
+
13
+ 本草案不定义 Key Binding、配额、计费、团队管理或远程插件执行。机构能力通过独立插件扩展,不能把某个学校的模型名称、并发限制或配额字段写成公共协议要求。
12
14
 
13
15
  ## 2. 服务发现
14
16
 
15
- 配置提供完整发现地址和可选的 `expectedIssuer`,预注册机构另提供 public `clientId`。客户端不固定账号 API 路径。无凭据 GET 成功返回 200 `application/json`:
17
+ 客户端配置完整发现地址、可选的 `expectedIssuer` 和预注册的公共 `clientId`。发现请求不携带凭据,成功返回 200 JSON。下面的通用示例与 ChatECNU 当前声明的能力对应;域名和路径不是固定要求:
16
18
 
17
19
  ```json
18
20
  {
19
- "issuer": "https://login.example.org",
20
- "authorization_endpoint": "https://login.example.org/oauth/authorize",
21
- "token_endpoint": "https://login.example.org/oauth/token",
22
- "revocation_endpoint": "https://login.example.org/oauth/revoke",
23
- "userinfo_endpoint": "https://models.example.org/userinfo",
21
+ "issuer": "https://models.example.org",
22
+ "authorization_endpoint": "https://models.example.org/oauth/authorize",
23
+ "token_endpoint": "https://models.example.org/oauth/token",
24
+ "userinfo_endpoint": "https://models.example.org/oauth/userinfo",
25
+ "revocation_endpoint": "https://models.example.org/oauth/revoke",
26
+ "jwks_uri": "https://models.example.org/oauth/jwks.json",
24
27
  "response_types_supported": ["code"],
28
+ "response_modes_supported": ["query"],
25
29
  "grant_types_supported": ["authorization_code", "refresh_token"],
26
- "code_challenge_methods_supported": ["S256"],
30
+ "scopes_supported": ["openid", "profile", "offline_access", "llm:profile", "llm:models:read", "llm:invoke"],
31
+ "subject_types_supported": ["public"],
32
+ "id_token_signing_alg_values_supported": ["RS256"],
27
33
  "token_endpoint_auth_methods_supported": ["none"],
28
34
  "revocation_endpoint_auth_methods_supported": ["none"],
29
- "scopes_supported": ["llm:profile", "llm:models:read", "llm:invoke"],
35
+ "code_challenge_methods_supported": ["S256"],
36
+ "authorization_response_iss_parameter_supported": true,
30
37
  "oidc_llm": {
31
38
  "version": "0.1",
32
39
  "resource": "https://models.example.org",
33
- "api_base": "https://models.example.org/v1",
34
- "identity_modes_supported": ["oauth"],
40
+ "api_base": "https://models.example.org/open/api/v1",
41
+ "identity_modes_supported": ["oauth", "oidc"],
35
42
  "client_registration_methods_supported": ["static"]
36
43
  }
37
44
  }
38
45
  ```
39
46
 
40
- 以上字段拟为基础模式必填。`version` 精确匹配支持版本;0.x 不承诺跨版本兼容。`resource` 只有一个,授权和 Token 请求必须原样发送。`api_base` 是可配置 API 前缀,追加 `/models` 和 `/chat/completions`,不是强制 `/v1`。本草案限定 api_base 与 UserInfo 同 resource 源;issuer 可以是独立登录域名。该同源约束属于本草案,不是标准 OIDC 的要求。
47
+ `oidc_llm.version` 精确匹配 `0.1`;0.x 不承诺跨版本兼容。`resource` 只有一个,授权、换码与刷新请求均发送其原值。`api_base` 是模型 API 前缀,追加 `/models`、`/chat/completions`,不固定为 `/v1`。UserInfo 和 `api_base` 必须与 resource 同源;issuer 可以是独立登录域。这是本草案的资源约束,不是普通 OIDC 的通用要求。
41
48
 
42
- `userinfo_endpoint` 在标准 OIDC Discovery 中是推荐项,在本接入草案中提升为必填。发现元数据还须声明每一种实际支持模式的 scopes。标准发现地址按对应规范校验 issuer;配置 `expectedIssuer` 时必须完整字符串相等。未配置时,首次发现的 issuer 与发现地址同源;异源托管需要显式固定 issuer。
49
+ 当前适配器要求声明 `oauth` 基础模式;完整 OIDC 部署再声明 `oidc`、JWKS、支持的主体类型和 RS256。只部署 OAuth 模式时不要求 OIDC 签名元数据,但仍须提供 UserInfo、撤销、刷新、PKCE 和对应 scopes。两种模式都使用 `static` 注册与 `none` 客户端认证。
43
50
 
44
- 所有生产端点使用 HTTPS。发现和携带凭据的请求不自动跟随重定向;浏览器导航除外。认证端点来自可信发现,仅接收其角色对应的凭据;模型 Token 不被任意转发到发现提到的其他 URL。保存的授权绑定协议、issuer、resource、client、token/revoke/model 端点;配置或绑定改变要求重新授权,不能默默迁移旧 Token。
51
+ 发现地址以 `/.well-known/openid-configuration` 结尾时,必须匹配 issuer 对应的标准位置。配置 `expectedIssuer` 时要求完全一致;未配置时,issuer 须与发现地址同源。生产端点使用 HTTPS;发现和携带凭据的请求不自动跟随重定向,浏览器导航除外。认证端点仅接收其角色对应的凭据。
45
52
 
46
- 发现同时包含 LiteLLM 与 oidc-llm 标志、未知版本、HTML 或缺关键能力时停止,不通过关闭验签或换协议来降级。
53
+ 保存的授权绑定协议、issuer、resource、client 和相关端点;配置或绑定改变要求重新授权。未知版本、同时声明 LiteLLM 与 oidc-llm、HTML 或缺失必要能力均应报错,不能换协议、关闭验签或转发旧 Token 绕过检查。
47
54
 
48
55
  ## 3. 客户端注册
49
56
 
50
- 默认支持机构预注册的一个公共客户端,所有用户共用 client ID,但各自有独立授权。客户端不保存共享 client secret。允许的 loopback host/path 必须预注册;按照 RFC 8252 允许实际端口变化,不能接受任意回调地址。
57
+ 当前接入方式是**静态预注册公共客户端**。组织为应用分配 client ID,用户各自授权;client ID 不是用户账号,也不是秘密。桌面端不保存共享 client secret。
51
58
 
52
- 可选动态注册需在 `client_registration_methods_supported` 中声明 `dynamic` 并提供 `registration_endpoint`。按 RFC 7591 接受 JSON POST:
59
+ 服务端登记允许的回调 host/path。EduWork 使用 `http://127.0.0.1:<随机端口>/oauth/callback`,按 RFC 8252 允许 loopback 的实际端口变化;不能因此允许任意 host/path。换码必须使用本次授权的相同 redirect URI,刷新复用原 client ID。
53
60
 
54
- ```json
55
- {
56
- "client_name": "EduWork",
57
- "redirect_uris": ["http://127.0.0.1:53187/oauth/callback"],
58
- "token_endpoint_auth_method": "none",
59
- "grant_types": ["authorization_code", "refresh_token"],
60
- "response_types": ["code"]
61
- }
62
- ```
61
+ 动态客户端注册不属于当前 oidc-llm 接入要求,ChatECNU 的公开发现仅声明 `static`。未来如扩展动态注册,需要另行明确发现、注册权限和回调约束;不能照搬 LiteLLM 的动态注册流程并宣称已经兼容。
63
62
 
64
- 成功 201,返回已登记的 `client_id`、回调、认证方法、grants/response types 和可选签发时间。0.1 的自动动态注册只覆盖无 bootstrap secret 的 public 注册;需要注册凭据的机构先用预注册模式。注册可以有状态或无状态,不强制“每人一个 client”。刷新复用原注册。
63
+ ## 4. 授权、换码与身份
65
64
 
66
- ## 4. 授权、换码与刷新
65
+ | 身份模式 | 本连接申请的 scopes | 身份验证 |
66
+ | --- | --- | --- |
67
+ | `oidc` | `openid profile offline_access llm:models:read llm:invoke` | 初次授权必须返回 ID Token,校验后核对 UserInfo |
68
+ | `oauth` | `llm:profile llm:models:read llm:invoke` | 读取 UserInfo 格式资料,不宣称完成 OIDC 身份验证 |
67
69
 
68
- 基础 OAuth 模式拟申请 `llm:profile`、`llm:models:read`、`llm:invoke`。分别授权本人资料、获准模型目录、模型调用,不包括管理 Key、用户、团队或账单。具体模型集合仍由授权决定;服务端必须实际约束权限,不能仅靠客户端自律。
70
+ `llm:models:read` 授权读取本人可用模型,`llm:invoke` 授权调用;OAuth 模式的 `llm:profile` 用于本人资料。完整 OIDC 模式使用标准身份 scopes,不再额外请求 `llm:profile`。发现中即便存在 worker、会话、知识库或管理 scopes,客户端也不一并申请。服务端仍须执行实际用户权限。
69
71
 
70
- 浏览器 GET authorization endpoint,必须带 `response_type=code`、client ID、精确 redirect URI、至少 128 bit 随机 state、S256 challenge、resource 和 scopes。verifier 为 43–128 字符。用户看到应用、账户和申请权限,拒绝以 `error=access_denied&state=...` 回调。无效 client/回调直接返回错误,不重定向到未信任地址。
72
+ 浏览器授权携带 `response_type=code`、`client_id`、`redirect_uri`、随机 `state`、PKCE `code_challenge` / `S256`、`resource` 和 `scope`。OIDC 模式还发送随机 `nonce` 和 `prompt=consent`。用户拒绝时返回 `error=access_denied` 和原 state;无效 client/回调不得重定向到不可信地址。
71
73
 
72
- 成功回调 `code` 和原 state。授权码不透明、有效期拟不超过 120 秒、单次成功兑换,绑定主体、client、redirect、PKCE、resource 和批准 scopes。
74
+ 成功回调包含 `code` 和原 state。发现声明 `authorization_response_iss_parameter_supported: true` 时,成功和错误回调都必须提供完全匹配的 `iss`。授权码须短期、单次使用,并绑定主体、client、redirect、PKCE、resource 和批准 scopes;具体有效期由服务端策略决定,本草案不固定 120 秒。
73
75
 
74
- token endpoint 接受 form POST:
76
+ Token endpoint 接受 `application/x-www-form-urlencoded` POST:
75
77
 
76
78
  | 操作 | 必须字段 |
77
79
  | --- | --- |
78
80
  | 换码 | `grant_type=authorization_code`、`client_id`、`code`、`redirect_uri`、`code_verifier`、`resource` |
79
81
  | 刷新 | `grant_type=refresh_token`、`client_id`、`refresh_token`、`resource` |
80
82
 
81
- 成功响应示例:
83
+ 完整 OIDC 模式的初次成功响应示例(Token 均为占位符,3600 秒仅为示例):
82
84
 
83
85
  ```json
84
86
  {
85
87
  "access_token": "OPAQUE_ACCESS_TOKEN",
86
88
  "token_type": "Bearer",
87
- "expires_in": 900,
89
+ "expires_in": 3600,
88
90
  "refresh_token": "OPAQUE_REFRESH_TOKEN",
89
- "scope": "llm:profile llm:models:read llm:invoke"
91
+ "scope": "openid profile offline_access llm:models:read llm:invoke",
92
+ "id_token": "SIGNED_ID_TOKEN"
90
93
  }
91
94
  ```
92
95
 
93
- 响应应 `Cache-Control: no-store`。客户端不假定 Access Token 是 JWT,以 `expires_in` 判断有效期,核对实际批准 scopes。拟要求 Access Token 不超过 15 分钟;具体上限和部署兼容性尚待确认。
96
+ 响应应禁止缓存。Access Token 按不透明 Bearer 处理,不要求其为 JWT。`expires_in` 为正整数秒,以服务端响应为准,**不规定 15 分钟上限**;当前客户端另外拒绝超过 365 天的异常值,这不是推荐寿命。`scope` 必须明确返回本连接申请的权限集合,不能缺失、重复或额外扩大。OAuth 模式返回该模式的 scope,无需 ID Token。
94
97
 
95
- Refresh Token 轮换、单次使用;成功时新 Token 对整体返回,授权主体/resource 不变,不扩大 scopes。服务端拟支持刷新家族重用检测和授权级撤销;客户端对同一授权合并并发刷新,保存新 Token 对后再使用。网络中断后的提交状态不确定,需要明确错误恢复策略,不能无边界重复提交旧 refresh。Token 生命周期、并发协调和持久化是服务端验收项。
98
+ OIDC 模式复用现有验证器,校验 RS256 签名、issuer、audience/azp、nonce、时间和存在时的 `at_hash`。初次缺少 ID Token 或验证失败即停止,不能退回 OAuth。刷新响应可省略 ID Token;返回时验证原主体、audience 及相关 claims,不能因刷新而更换身份。
96
99
 
97
- ## 5. UserInfo
100
+ ## 5. UserInfo 与模型资源
98
101
 
99
- GET 发现的 `userinfo_endpoint`,`Authorization: Bearer <access_token>`,成功 200 JSON:
102
+ 客户端用同一 Access Token 请求发现中的 `userinfo_endpoint`,成功返回 200 JSON,例如:
100
103
 
101
104
  ```json
102
105
  {
@@ -106,36 +109,59 @@ GET 发现的 `userinfo_endpoint`,`Authorization: Bearer <access_token>`,成
106
109
  }
107
110
  ```
108
111
 
109
- 普通 JSON UserInfo **只要求非空字符串 `sub`**。`name`、`preferred_username`、`picture`、`email`、`email_verified` 使用 OIDC 标准含义、按权限可选;缺少姓名/头像不使登录失败。主体键为 issuer + sub,不能用邮箱或显示名替代。不同授权的模型和资料缓存隔离,不因同名账户而合并。
112
+ 主体键为 issuer + sub,不能用姓名或邮箱代替。当前适配器要求 `sub` 为 1–255 个非空白可见 ASCII 字符;`name`、`preferred_username`、`picture`、`email` 为可选字符串,`email_verified` 为可选布尔值,缺少姓名或头像不使登录失败。完整 OIDC 的 UserInfo sub 必须与已验证 ID Token 一致,刷新后的 sub 也须保持不变。OAuth 模式不把这种资料读取称为 OIDC 身份验证。
113
+
114
+ | 资源 | 请求与响应 |
115
+ | --- | --- |
116
+ | `GET api_base + /models` | Bearer;200 OpenAI 风格 `{"object":"list","data":[{"id":"example-chat","object":"model"}]}`,只返回当前授权可用模型 |
117
+ | `POST api_base + /chat/completions` | 相同 Bearer;OpenAI-compatible 请求与普通 JSON 或 SSE 响应(`data:`,最终 `[DONE]`) |
118
+
119
+ 服务端决定可用模型及额度,客户端不能用配置扩权。空目录保持为空;不同授权的资料、目录和请求隔离。可选的 data[].type 将用途分为 llm、embedding、rerank、image、tts 或 unknown,仅 LLM 进入 EduWork 对话目录。这是扩展字段,不属于 OpenAI 标准发现字段;服务端类型优先,缺失时使用已审核的本地模型类型。无法确定或不支持的类型不进入对话。图像输入、思考与上下文能力另行显式配置,不从名称猜测;能读图的 LLM 仍是 LLM。
120
+
121
+ 图像、音频和其他 API 由服务端与对应插件另行约定,不因“OpenAI-compatible”就假定全部支持。配额及学校服务仍属于机构扩展,不进入公共协议必选接口。
122
+
123
+ ## 6. 刷新与长流式请求
110
124
 
111
- OAuth-only 模式提供 UserInfo 格式兼容扩展,不宣称完成 OIDC 身份验证。资料暂时不可用不等于模型授权一定失效;客户端不能自行解析未经验证的 Token 来填充身份。完整 OIDC 模式必须校验返回 sub 与已验证 ID Token 的 sub 相同。
125
+ 刷新成功返回完整的新 Access Token / Refresh Token 对,主体和 resource 不变,scope 不扩大。客户端合并同一授权的并发刷新,并整体保存新凭据后再使用。Refresh Token 的有效期、轮换后的旧值处理、重用检测及家族撤销范围由服务端说明,不能从发现元数据推断。
112
126
 
113
- ## 6. 模型资源
127
+ EduWork 在使用授权前检查有效期,提前量为 `min(30 分钟, 本次 Token 有效期的一半)`。例如 2 小时 Token 剩余 30 分钟时触发,20 分钟 Token 剩余 10 分钟时触发。这是客户端请求前的刷新策略,不是服务端 Token 寿命,也不意味着应用关闭后仍后台续期。
114
128
 
115
- - GET `api_base + /models`:Bearer,200 OpenAI 风格 `{"object":"list","data":[{"id":"example-chat","object":"model"}]}`,仅列当前授权可见模型。
116
- - POST `api_base + /chat/completions`:相同 Bearer,使用 OpenAI-compatible 请求与普通 JSON/SSE(`data:`,最终 `[DONE]`)响应。
117
- - 其他图像、音频、Responses 等能力需以后显式声明;不因“OpenAI-compatible”就假定全部支持。
129
+ 临时网络故障、429 或 5xx 不应一概清空登录;客户端可继续使用尚未过期的 Token,过期则停止。明确的 `invalid_grant` / `invalid_client`、身份改变或收到无法安全接受的新凭据时要求重新登录。轮换响应丢失后的恢复策略仍需服务端明确,不能假设旧 Refresh Token 可以无限重试。
118
130
 
119
- 上下文、输出上限、输入模态、思考能力等可选元数据的字段格式尚待确认。只有模型 ID 时不能推断图像或 reasoning 支持。本轮不定义配额接口。
131
+ **新 Token 不能替换已经发出的 SSE 请求中的旧 Token。** 客户端刷新同一授权不会主动中断该请求;服务端是否允许已受理的长流跨越 Token 到期时刻,需要单独声明和验证。提前 30 分钟不构成无限时长保证。模型生成不由认证层自动重放,尤其不能在已输出内容后刷新重放,避免重复生成与计费。
120
132
 
121
- ## 7. 撤销与错误
133
+ ## 7. 退出、撤销与错误
122
134
 
123
- revocation endpoint 按 RFC 7009 接受 form `token`、public `client_id` 和可选 `token_type_hint=refresh_token`。成功 200,未知或已失效 Token 保持幂等。拟要求撤销该授权的 Refresh Token 家族;Access Token 最迟随其短寿命失效,服务端可额外主动失效。不能宣传无条件、即时的全服务下线。
135
+ 客户端退出立即停止本机授权使用,清除凭据及相关模型缓存,取消关联请求,再尽力撤销 Refresh Token。迟到的刷新、目录或流式结果不能恢复已退出的授权。该操作不等同于退出系统浏览器中的组织单点登录会话。
124
136
 
125
- 退出立即停止本地授权使用和相关缓存写回;撤销网络失败与本地退出分开报告。迟到的刷新/目录结果不能恢复已退出或被新账户替换的授权。
137
+ revocation endpoint 按 RFC 7009 接受 form `token`、公共 `client_id` 和 `token_type_hint=refresh_token`,成功 200,可为空响应或 JSON。撤销须阻止被撤销 Refresh Token 后续刷新;未知或已失效 Token 保持幂等。客户端接受已签发 Access Token 在服务端自然到期,不要求立即全局失效。撤销是否覆盖整个刷新家族,应由服务端明确,而非本草案假定。
126
138
 
127
- OAuth 错误沿用 `invalid_request`、`invalid_client`、`invalid_grant`、`invalid_scope`、`unsupported_grant_type`,resource 不匹配使用 `invalid_target`。401 表示凭据无效,403 表示无权限,429 表示限流,5xx 表示服务异常;不要用清空账户处理所有失败。模型请求开始输出后不得自动刷新并重放整个生成。错误响应不能泄露令牌、验证码或账户秘密。
139
+ 撤销网络失败不阻止本地退出,客户端记录脱敏警告;目前没有跨重启的持久化撤销重试队列。不能把本机已退出等同于远端已经确认撤销。
128
140
 
129
- ## 8. 可选完整 OIDC 模式
141
+ OAuth 错误沿用 `invalid_request`、`invalid_client`、`invalid_grant`、`invalid_scope`、`unsupported_grant_type`,resource 不匹配使用 `invalid_target`。401 为认证失败,403 为权限不足,429 为限流,5xx 为服务异常;不能统一提示重新登录。安全读取可在 401 后刷新并重试一次,模型生成不自动重放。错误与日志不得泄露凭据。
142
+
143
+ ## 8. 示例部署
144
+
145
+ 以下使用 `models.example.org` 演示采用本草案的部署配置,与第 2 节的发现文档示例一致;这些是示例值,不代表真实服务的元数据或验收结果。
146
+
147
+ | 项目 | 示例元数据 |
148
+ | --- | --- |
149
+ | issuer / resource | `https://models.example.org` |
150
+ | 模型 API 基址 | `https://models.example.org/open/api/v1` |
151
+ | 扩展版本 | `oidc_llm.version: "0.1"` |
152
+ | 身份模式 | `oauth`、`oidc`;客户端通过配置显式选择 `oidc` |
153
+ | 客户端注册 | `static`,公共客户端认证方法 `none` |
154
+ | 授权与刷新 | Code、PKCE S256、Refresh Token |
155
+ | 身份验证 | RS256、公开 JWKS、UserInfo、授权回调 `iss` |
130
156
 
131
- 服务端在 `identity_modes_supported` 中额外声明 `oidc`,提供标准 `jwks_uri`、签名算法与 OIDC 必需元数据。客户端显式选择该模式,请求 `openid profile`(按需 `email`)和模型 scopes,使用 nonce;不额外要求基础 OAuth 模式的 `llm:profile`。
157
+ 服务端可以声明其他业务 scopes;本连接仅请求第 4 节所列权限。部署地址和公共 client ID 由发行版配置提供,不硬编码到公共插件。
132
158
 
133
- 客户端按 OIDC 规范验证签名、算法、issuer、audience/azp、nonce、时间及相关 Token 绑定,UserInfo sub 必须一致。缺少 ID Token 时失败,不能临时切回 OAuth-only。当前旧 OIDC 的安全校验应继续保留,未来实现可以复用,不能为另一 adapter 全局关闭。
159
+ 发现文档能确认服务端声明的能力,不能证明 Token 的具体寿命、刷新家族重用检测、即时撤销效果、所有账号的模型过滤或长流到期行为。这些需要真实服务验收;不能把某次联调或客户端实现等同于服务端的完整保证。
134
160
 
135
- ## 9. 与 LiteLLM 的关系及待定项
161
+ ## 9. 兼容与后续扩展
136
162
 
137
- LiteLLM native 是客户端支持的另一兼容协议,不是本草案逐接口的子集:它使用动态注册、自己的发现与账户响应,未提供相同应用 scopes,且只撤销提交的 refresh、默认 Access Token 更长。这些差异由 adapter 保留,不把期望写成上游事实。
163
+ LiteLLM native OAuth 是另一条已支持的模型接入路线,使用自己的发现、动态注册及账户响应,不能视为本草案的逐接口子集。两套适配器共用凭据保存、刷新协调和模型请求隔离,各自保留协议差异。
138
164
 
139
- 定稿前需确认:scope 名及强制范围;Token 有效期与刷新家族保证;模型能力元数据;掉线、撤销失败和并发刷新恢复契约。实现顺序是先确认文档与服务端,再在独立 PR 中增加客户端新 adapter,最后由机构配置显式迁移。旧 OIDC 用户不会自动切协议。
165
+ 后续讨论集中在动态注册、统一模型能力元数据,以及刷新响应丢失、Token 家族与长流式请求的服务端契约。当前接入按本文和[实验接入指南](experimental-oidc-llm.md)实现并验收,无需等待这些扩展才接入。已有机构迁移仍须显式修改配置并重新授权。
140
166
 
141
- 规范依据:[OIDC Core / UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)、[OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)、[RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html)、[PKCE](https://www.rfc-editor.org/rfc/rfc7636.html)、[原生客户端](https://www.rfc-editor.org/rfc/rfc8252.html)、[动态注册](https://www.rfc-editor.org/rfc/rfc7591.html)、[Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html)、[撤销](https://www.rfc-editor.org/rfc/rfc7009.html)。
167
+ 规范依据:[OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html)、[OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)、[PKCE](https://www.rfc-editor.org/rfc/rfc7636.html)、[原生客户端](https://www.rfc-editor.org/rfc/rfc8252.html)、[Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html)、[授权响应 issuer](https://www.rfc-editor.org/rfc/rfc9207.html)、[撤销](https://www.rfc-editor.org/rfc/rfc7009.html)。
@@ -16,7 +16,9 @@ OIDC mode requires browser sign-in, Code + PKCE, a valid ID Token and UserInfo.
16
16
 
17
17
  Load trusted JSON through config.profile, config.profiles or EDUWORK_OIDC_PROFILE. Desktop uses backend: desktop, Host credential storage and desktopServices.openExternal, with a temporary 127.0.0.1 callback. Local Web uses backend: web with WebServer bound to 127.0.0.1 and its actual port.
18
18
 
19
- These changes are for development assemblies containing this branch, not yet published. Follow [development](development.en.md) for validation. Released assemblies must pin reviewed published packages; old packages cannot consume these new profiles.
19
+ This Host integration is published in `@eduwork/dsh-oidc@0.3.0-dev.2` and assembled in EduWork `0.3.6-dev.20260921.1`. Desktop users edit `eduwork.jsonc` using the [LiteLLM setup guide](gateway-auth/litellm-setup.en.md). Other Hosts must pin reviewed package versions; old packages cannot consume these profiles. See [development](development.en.md) for source builds and validation.
20
+
21
+ The current source classifies discovered models by `type`; only `llm` enters chat. If the server returns only IDs, supply `provider.models[].type` using actual model IDs before upgrading. Server type metadata takes precedence; specialist and unresolved types remain outside chat. This requires an updated plugin/client and coordinated configuration, and is not part of the published version above. See the [model reference](enterprise-profile.en.md).
20
22
 
21
23
  ## Sign-in and use
22
24
 
@@ -16,7 +16,9 @@ OIDC 模式需要浏览器登录、Code + PKCE、有效 ID Token 和 UserInfo。
16
16
 
17
17
  插件通过 config.profile、config.profiles 或 EDUWORK_OIDC_PROFILE 指向的可信 JSON 文件加载配置。桌面使用 backend: desktop,Host 提供凭据存储和 desktopServices.openExternal;插件监听临时 127.0.0.1 回调。本机 Web 使用 backend: web,WebServer 必须监听 127.0.0.1,回调端口取实际服务端口。
18
18
 
19
- 仅用于包含本分支源码的开发装配,尚未发布。按[开发说明](development.md)构建和验证。正式装配应固定通过审查的已发布包,不能把新配置直接交给尚不支持它的旧版本。
19
+ 上述 Host 接入已发布于 `@eduwork/dsh-oidc@0.3.0-dev.2`;EduWork `0.3.6-dev.20260921.1` 已完成装配,桌面用户按 [LiteLLM 接入指南](gateway-auth/litellm-setup.md)编辑 `eduwork.jsonc` 即可。其他宿主应固定通过审查的包版本,不能把新配置直接交给不支持它的旧版本。从源码开发和验证见[开发说明](development.md)。
20
+
21
+ 当前源码按模型 `type` 注册,仅 `llm` 进入对话。服务器只返回 ID 时,升级前须按实际模型 ID 补充 `provider.models[].type`;服务端类型优先,专用及类型未知的模型不进入对话。这项改动需要新插件/客户端与配置同步,尚不属于上述已发布版本。详见[模型说明](enterprise-profile.md)。
20
22
 
21
23
  ## 登录与使用
22
24
 
@@ -14,4 +14,4 @@ Servers may keep legacy endpoints for already released clients. New clients use
14
14
  4. Sign in again. The client neither guesses a new discovery URL nor treats saved model keys as Access Tokens, and never silently falls back. It does not read old model keys or bulk-delete keys potentially used elsewhere.
15
15
  5. Identity-only oidc profiles may remain but cannot include a model provider.
16
16
 
17
- This is a breaking configuration change in unpublished source. Consult historical versions for the retired protocol. Personal API-key settings remain available.
17
+ This configuration change is included in `@eduwork/dsh-oidc@0.3.0-dev.2` and EduWork `0.3.6-dev.20260921.1`. Consult historical versions for the retired protocol. Personal API-key settings remain available.
@@ -14,4 +14,4 @@
14
14
  4. 重新登录。客户端不猜测旧服务的新发现地址,不把旧模型 Key 当作 Access Token,也不自动回退。旧本地凭据不会被新模型路径读取;不批量删除可能仍由其他配置使用的 Key。
15
15
  5. 仅身份登录可继续使用 oidc 配置,但不得同时配置模型 provider。
16
16
 
17
- 这次删除属于尚未发布源码的破坏性配置变更;旧协议细节以历史版本为准。个人 API Key 入口保持可用。
17
+ 这项配置变更已包含在 `@eduwork/dsh-oidc@0.3.0-dev.2` 和 EduWork `0.3.6-dev.20260921.1` 中;旧协议细节以历史版本为准。个人 API Key 入口保持可用。
@@ -93,7 +93,7 @@ The plugin intentionally has no JSON path mapping or private profile endpoint. O
93
93
 
94
94
  ## Refresh and logout
95
95
 
96
- If the access token is within 90 seconds of expiry and a refresh token exists, the plugin refreshes it. On `invalid_grant`, the stored OIDC session and its still-owned local model credential are removed and login is required. A rotated refresh token replaces the previous one. An expired session without a refresh token is handled the same way when an active session is next required.
96
+ Before requests, the plugin refreshes up to 30 minutes early, capped at half the server-advertised token lifetime. Older sessions with only an expiry gain a refresh timestamp on their first refresh. The actual server-advertised lifetime is never extended. On `invalid_grant`, the stored OIDC session and its still-owned local model credential are removed and login is required. A rotated refresh token replaces the previous one. Sessions without a refresh token remain usable until actual expiry and are then cleared when an active session is next required; entering the early-refresh window does not sign them out prematurely.
97
97
 
98
98
  Logout attempts RFC 7009 revocation when Discovery publishes `revocation_endpoint`, then removes the OIDC session record and only a model credential still owned by that session. A key written by another profile into the shared reference is preserved. Remote revocation failure is logged without blocking local cleanup.
99
99
 
@@ -93,7 +93,7 @@ TLS 信任和 DNS 解析仍由 Node.js 宿主和操作系统负责。
93
93
 
94
94
  ## 刷新与退出
95
95
 
96
- 当 access token 距离过期不足 90 秒且存在 refresh token 时,插件会刷新会话。若收到 `invalid_grant`,会删除已存 OIDC 会话和本地模型凭据,并要求重新登录。轮换后的 refresh token 会替换旧值。没有 refresh token 的过期会话在下次需要活动会话时按同样方式处理。
96
+ 请求前检查 access token,默认提前 30 分钟刷新,提前量不超过服务端返回有效期的一半;只有到期时间的旧会话在首次刷新后自动补齐刷新时间。不会延长服务端给出的实际有效期。若收到 `invalid_grant`,会删除已存 OIDC 会话和本地模型凭据,并要求重新登录。轮换后的 refresh token 会替换旧值。没有 refresh token 的会话仍可用到实际到期,到期后在下次需要活动会话时清理,而非在提前刷新窗口内提前退出。
97
97
 
98
98
  退出登录时,如果 Discovery 发布 `revocation_endpoint`,插件会尝试按 RFC 7009 撤销 token;随后在本地删除该 Profile 的 OIDC 会话与归属于它的托管模型凭据;共享引用已被其他登录改写时不会删除另一方的 Key。远程撤销失败会被记录,但不阻止本地清理。
99
99
 
@@ -4,7 +4,9 @@
4
4
 
5
5
  A user can authorize a general-purpose client in the browser and use their model permissions. OIDC verifies identity; the model service explicitly grants Access Token model access. Clients reuse discovery, PKCE, refresh and the model Provider.
6
6
 
7
- Current implementations support [LiteLLM native OAuth](gateway-auth/README_EN.md) and the [experimental oidc-llm draft](gateway-auth/experimental-oidc-llm.en.md). An implementation does not make the draft a finalized standard. Personal models and identity-only sign-in remain independent.
7
+ There are two model-access routes: the [experimental oidc-llm draft](gateway-auth/experimental-oidc-llm.en.md) and [native LiteLLM OAuth](gateway-auth/litellm-setup.en.md). We are advancing oidc-llm as an open protocol for institutions and interoperability across clients; ChatECNU uses this route. The LiteLLM adapter directly supports the existing gateway's native authorization contract.
8
+
9
+ oidc-llm 0.1 remains under review. An experimental implementation does not make it a finalized standard or an official OpenID standard. See the [protocol draft](gateway-auth/oidc-llm-draft.en.md) for the full proposal and the experimental guide for the implemented scope. The draft's `oidc` and `oauth` options are identity modes. Separately retained identity-only OIDC sign-in provides no model access and is not a third model-access route. Personal models remain independently configurable.
8
10
 
9
11
  New clients no longer consume Key Binding. Servers may support token model access alongside old client endpoints without retaining old logic in new clients. Institution quota, team policy, web pages and operations remain extensions using bounded Host transport.
10
12
 
@@ -4,7 +4,9 @@
4
4
 
5
5
  机构用户可以通过一次浏览器授权,在通用客户端使用自己的模型访问权限。OIDC 负责身份验证;模型服务明确授予 Access Token 模型权限,客户端复用发现、PKCE、刷新和模型 Provider。
6
6
 
7
- 当前实现支持 [LiteLLM native OAuth](gateway-auth/README.md) 与 [oidc-llm 实验草案](gateway-auth/experimental-oidc-llm.md)。草案仍需评审,已实现不等于标准定稿。个人模型和纯身份登录保留独立入口。
7
+ 模型接入有两条路线:[oidc-llm 实验草案](gateway-auth/experimental-oidc-llm.md)与 [LiteLLM native OAuth](gateway-auth/litellm-setup.md)。oidc-llm 是我们面向机构与多客户端互通推进的开放协议方向,ChatECNU 已采用这条路线;LiteLLM 适配则直接兼容现有网关的原生授权契约。
8
+
9
+ oidc-llm 0.1 仍需评审,实验实现不等于标准定稿,也不是 OpenID 官方标准。完整提议见[协议草案](gateway-auth/oidc-llm-draft.md),已实现范围以实验接入说明为准。草案中的 `oidc` / `oauth` 是身份模式;另行保留的纯身份 OIDC 登录不提供模型访问,不是第三条模型接入路线。个人模型仍可独立配置。
8
10
 
9
11
  新客户端不再消费 Key Binding。服务端可同时支持新 Token 模型接入和老客户端接口;新客户端无需保留旧实现。机构配额、团队策略、网页和运维功能由机构独立扩展,公共包提供受控 Host 传输。
10
12
 
@@ -45,6 +45,7 @@
45
45
  "models": [
46
46
  {
47
47
  "id": "ecnu-max",
48
+ "type": "llm",
48
49
  "name": "ecnu-max",
49
50
  "contextWindow": 1000000,
50
51
  "maxTokens": 393216,
@@ -59,6 +60,7 @@
59
60
  },
60
61
  {
61
62
  "id": "ecnu-plus",
63
+ "type": "llm",
62
64
  "name": "ecnu-plus",
63
65
  "contextWindow": 262144,
64
66
  "maxTokens": 65536,
@@ -45,6 +45,7 @@
45
45
  "models": [
46
46
  {
47
47
  "id": "example-max",
48
+ "type": "llm",
48
49
  "name": "Example Max",
49
50
  "input": [
50
51
  "text"
@@ -58,6 +59,7 @@
58
59
  },
59
60
  {
60
61
  "id": "example-plus",
62
+ "type": "llm",
61
63
  "name": "Example Plus",
62
64
  "input": [
63
65
  "text",
@@ -6,5 +6,13 @@
6
6
  "discoveryUrl": "https://gateway.example.org/.well-known/litellm-cli-auth",
7
7
  "expectedIssuer": "https://gateway.example.org"
8
8
  },
9
- "allowInsecureDevelopment": false
9
+ "allowInsecureDevelopment": false,
10
+ "provider": {
11
+ "models": [
12
+ {
13
+ "id": "example-chat",
14
+ "type": "llm"
15
+ }
16
+ ]
17
+ }
10
18
  }
@@ -9,5 +9,37 @@
9
9
  "clientId": "replace-with-public-client-id",
10
10
  "identityMode": "oidc"
11
11
  },
12
- "allowInsecureDevelopment": false
12
+ "allowInsecureDevelopment": false,
13
+ "provider": {
14
+ "models": [
15
+ {
16
+ "id": "example-chat",
17
+ "type": "llm"
18
+ },
19
+ {
20
+ "id": "example-vision-chat",
21
+ "type": "llm",
22
+ "input": [
23
+ "text",
24
+ "image"
25
+ ]
26
+ },
27
+ {
28
+ "id": "example-embedding",
29
+ "type": "embedding"
30
+ },
31
+ {
32
+ "id": "example-rerank",
33
+ "type": "rerank"
34
+ },
35
+ {
36
+ "id": "example-image",
37
+ "type": "image"
38
+ },
39
+ {
40
+ "id": "example-tts",
41
+ "type": "tts"
42
+ }
43
+ ]
44
+ }
13
45
  }
@@ -12,7 +12,13 @@
12
12
  "provider": {
13
13
  "id": "example-ai",
14
14
  "adapter": "openai-compatible",
15
- "modelSource": "discovery"
15
+ "modelSource": "discovery",
16
+ "models": [
17
+ {
18
+ "id": "example-chat",
19
+ "type": "llm"
20
+ }
21
+ ]
16
22
  },
17
23
  "allowInsecureDevelopment": false
18
24
  }
@@ -12,16 +12,21 @@ export function callbackLanguage(header = '') {
12
12
 
13
13
  /** Only trusted profile presentation and a fixed outcome enter the page. */
14
14
  export function callbackPage(profile = {}, outcome = 'failed', language = 'en') {
15
+ // brand.language fixes the page language; auto (default) follows the browser preference.
16
+ if (profile.brand?.language && profile.brand.language !== 'auto') language = profile.brand.language
15
17
  const zh = language === 'zh-CN'
16
18
  const brand = profile.brand ?? {}, product = brand.productName || profile.displayName || 'EduWork'
17
19
  const organization = brand.organizationName || profile.organization || ''
18
20
  const success = outcome === 'completed'
19
- const title = success ? (zh ? '身份认证已完成' : 'You’re signed in')
21
+ const who = organization ? (zh ? `${organization}统一身份认证` : `${organization} sign-in`) : (zh ? '组织统一身份认证' : 'your organization sign-in')
22
+ const title = success ? (zh ? '登录成功' : 'You’re signed in')
20
23
  : outcome === 'expired' ? (zh ? '此次登录已过期' : 'This sign-in has expired') : (zh ? '此次登录未完成' : 'Sign-in did not complete')
21
- const description = success ? (zh ? '请返回应用继续工作。' : 'Return to the app to continue your work.')
24
+ const description = success ? (zh ? `你已通过${who},请返回「${product}」继续使用。` : `You signed in with ${who}. Return to ${product} to continue.`)
25
+ : outcome === 'time-invalid'
26
+ ? (zh ? '登录凭据的时间校验未通过,可能是电脑时间不准确或凭据已过期。请在系统设置中开启自动设置日期和时间并同步,然后返回应用重新登录。若仍失败,请联系管理员。' : 'The sign-in token failed its time check. Your computer clock may be inaccurate, or the token may have expired. Enable automatic date and time in system settings, synchronize your clock, then sign in again. If this continues, contact your administrator.')
22
27
  : outcome === 'issuer-invalid'
23
28
  ? (zh ? '认证服务返回的信息与登录配置不一致。请联系管理员检查后重试。' : 'The authentication response does not match the sign-in configuration. Contact your administrator before trying again.')
24
- : (zh ? '请返回应用重新发起登录。' : 'Return to the app and start sign-in again.')
29
+ : (zh ? `请返回「${product}」重新点击登录。` : `Return to ${product} and start sign-in again.`)
25
30
  const accent = /^#[a-f0-9]{6}$/i.test(brand.primaryColor ?? '') ? brand.primaryColor : '#4f5fd7'
26
31
  let logo = '', imageSource = "'none'"
27
32
  if (/^data:image\/(png|webp);base64,[A-Za-z0-9+/]+=*$/.test(brand.logoURL ?? '')) { logo = brand.logoURL; imageSource = 'data:' }
@@ -37,6 +42,6 @@ export function callbackPage(profile = {}, outcome = 'failed', language = 'en')
37
42
  'x-content-type-options': 'nosniff', connection: 'close',
38
43
  },
39
44
  html: `<!doctype html><html lang="${language === 'zh-CN' ? 'zh-CN' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · ${escape(product)}</title>
40
- <style nonce="${nonce}">*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#f5f6f8;color:#20232c;font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}.card{width:min(100%,480px);padding:36px;border:1px solid #e1e4eb;border-radius:20px;background:#fff;box-shadow:0 14px 48px #17264d0a}.brand{display:flex;align-items:center;gap:12px;margin-bottom:36px}.mark{width:42px;height:42px;display:grid;place-items:center;flex:none;border-radius:11px;background:${accent};color:white;font-size:22px;font-weight:700}.mark img{width:100%;height:100%;object-fit:contain;border-radius:inherit}.name{font-weight:650;font-size:16px}.organization{font-size:12px;color:#747987;margin-top:3px}.status{font-size:12px;color:${success ? '#397958' : '#a14d3b'};margin-bottom:12px}h1{margin:0 0 14px;font-size:26px;line-height:1.35;letter-spacing:-.4px}p{margin:0;color:#5c6270;font-size:15px;line-height:1.8}.hint{margin-top:28px;padding-top:20px;border-top:1px solid #edf0f4;font-size:13px;color:#818694}@media(max-width:480px){.card{padding:28px}h1{font-size:23px}}</style></head><body><main class="card"><div class="brand"><div class="mark">${mark}</div><div><div class="name">${escape(product)}</div>${organization && organization !== product ? `<div class="organization">${escape(organization)}</div>` : ''}</div></div><div class="status">${success ? (zh ? '已验证身份' : 'Identity verified') : (zh ? '需要重新登录' : 'Please try again')}</div><h1>${title}</h1><p>${description}</p><p class="hint">${zh ? '现在可以关闭此标签页。' : 'You can close this browser tab now.'}</p></main></body></html>`,
45
+ <style nonce="${nonce}">*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#f5f6f8;color:#20232c;font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}.card{width:min(100%,480px);padding:36px;border:1px solid #e1e4eb;border-radius:20px;background:#fff;box-shadow:0 14px 48px #17264d0a}.brand{display:flex;align-items:center;gap:12px;margin-bottom:36px}.mark{width:42px;height:42px;display:grid;place-items:center;flex:none;border-radius:11px;background:${accent};color:white;font-size:22px;font-weight:700}.mark img{width:100%;height:100%;object-fit:contain;border-radius:inherit}.name{font-weight:650;font-size:16px}.organization{font-size:12px;color:#747987;margin-top:3px}.status{font-size:12px;color:${success ? '#397958' : '#a14d3b'};margin-bottom:12px}h1{margin:0 0 14px;font-size:26px;line-height:1.35;letter-spacing:-.4px}p{margin:0;color:#5c6270;font-size:15px;line-height:1.8}.hint{margin-top:28px;padding-top:20px;border-top:1px solid #edf0f4;font-size:13px;color:#818694}@media(max-width:480px){.card{padding:28px}h1{font-size:23px}}</style></head><body><main class="card"><div class="brand"><div class="mark">${mark}</div><div><div class="name">${escape(product)}</div>${organization && organization !== product ? `<div class="organization">${escape(organization)}</div>` : ''}</div></div><div class="status">${success ? (zh ? '已验证身份' : 'Identity verified') : (zh ? '需要重新登录' : 'Please try again')}</div><h1>${escape(title)}</h1><p>${escape(description)}</p><p class="hint">${zh ? '此页面可以关闭。' : 'You can close this browser tab now.'}</p></main></body></html>`,
41
46
  }
42
47
  }