已合并
feat(service): 新增可扩展 Handler 与联合认证示例 #412
Wal1et创建于 8月7日
feat(service): 新增可扩展 Handler 与联合认证示例 #412
已合并
共 25 个文件变更+4621-232
| @@ -0,0 +1,667 @@ | |||
| 1 | +# Service Framework examples | ||
| 2 | + | ||
| 3 | +本目录提供一个可以直接运行、调试和扩展的 `openjiuwen_runtime.service` | ||
| 4 | +示例。它的目标不是展示某个特定业务,而是回答开发者在使用 Service | ||
| 5 | +Framework 时最常见的几个问题: | ||
| 6 | + | ||
| 7 | +- 如何定义普通 Handler 和流式 Handler; | ||
| 8 | +- 如何为 Handler 声明输入、输出和 OpenAPI 元数据; | ||
| 9 | +- 如何通过装饰器、对象注册、批量注册和模块组合组织不同规模的服务; | ||
| 10 | +- HTTP 请求如何经过 FastAPI、OAuth2、统一 Envelope、消息路由和 Handler; | ||
| 11 | +- 流式 Handler 如何通过 HTTP Server-Sent Events(SSE)返回数据; | ||
| 12 | +- 如何在不修改框架代码的情况下增加自定义 Handler 模块; | ||
| 13 | +- 如何把本地登录和企业联合登录接入同一个 OAuth2 Authorization Code 流程。 | ||
| 14 | + | ||
| 15 | +> 本目录是开发者示例,不是可直接部署到生产环境的认证中心或用户服务。 | ||
| 16 | +> 文档中会明确标注演示实现及其生产替换点。 | ||
| 17 | + | ||
| 18 | +## 1. 目录结构 | ||
| 19 | + | ||
| 20 | +```text | ||
| 21 | +examples/ | ||
| 22 | +├── README.md | ||
| 23 | +├── multi_handler_app.py | ||
| 24 | +├── custom_handlers.py | ||
| 25 | +└── federated_auth/ | ||
| 26 | + ├── README.md | ||
| 27 | + ├── __init__.py | ||
| 28 | + ├── domain.py | ||
| 29 | + ├── identity_store.py | ||
| 30 | + ├── database_identity_store.py | ||
| 31 | + ├── provider.py | ||
| 32 | + ├── module.py | ||
| 33 | + ├── oauth2_server.py | ||
| 34 | + └── demo_idp.py | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +各文件职责如下: | ||
| 38 | + | ||
| 39 | +| 文件 | 职责 | | ||
| 40 | +| --- | --- | | ||
| 41 | +| `multi_handler_app.py` | 组装 `App`、OAuth2、联合认证模块和全部示例 Handler,并提供可运行入口 | | ||
| 42 | +| `custom_handlers.py` | 展示独立功能模块如何通过 `HandlerRegistry` 向宿主应用贡献 Handler | | ||
| 43 | +| `federated_auth/` | 联合身份标准化、本地虚拟身份映射、SQLite 存储和示例 OAuth2 服务 | | ||
| 44 | +| `federated_auth/README.md` | 联合认证模块的详细设计、通信时序、安全边界与扩展方法 | | ||
| 45 | + | ||
| 46 | +## 2. 整体架构 | ||
| 47 | + | ||
| 48 | +示例刻意把“通用框架”“应用组装”和“可插拔业务模块”分开: | ||
| 49 | + | ||
| 50 | +```mermaid | ||
| 51 | +flowchart LR | ||
| 52 | + Client["Swagger UI / HTTP client"] | ||
| 53 | + FastAPI["FastAPI / REST adapter"] | ||
| 54 | + Auth["OAuth2AccessControl"] | ||
| 55 | + Router["MessageRouter"] | ||
| 56 | + Context["SystemContext / RequestContext"] | ||
| 57 | + Handler["MessageHandler"] | ||
| 58 | + Stream["StreamMessageHandler"] | ||
| 59 | + Module["HandlerRegistry module"] | ||
| 60 | + | ||
| 61 | + Client --> FastAPI | ||
| 62 | + FastAPI --> Auth | ||
| 63 | + Auth --> Context | ||
| 64 | + Context --> Router | ||
| 65 | + Module -->|"App.include"| Router | ||
| 66 | + Router --> Handler | ||
| 67 | + Router --> Stream | ||
| 68 | + Handler -->|"ResponseEnvelope"| FastAPI | ||
| 69 | + Stream -->|"StreamChunk"| FastAPI | ||
| 70 | + FastAPI -->|"JSON or SSE"| Client | ||
| 71 | +``` | ||
| 72 | + | ||
| 73 | +关键原则: | ||
| 74 | + | ||
| 75 | +1. **Handler 与传输协议解耦。** Handler 只接收 `RequestContext` 和 | ||
| 76 | + `Envelope`,不直接依赖 FastAPI 的 `Request`、HTTP 或 WebSocket。 | ||
| 77 | +2. **所有注册方式共享同一份契约。** 装饰器、对象和模块最终都会转换成 | ||
| 78 | + `MessageHandler` 或 `StreamMessageHandler`,并进入同一个 | ||
| 79 | + `MessageRouter`。 | ||
| 80 | +3. **异步是强约束。** 普通 Handler 必须是 `async def`;流式 Handler | ||
| 81 | + 必须是异步生成器。同步实现会在注册阶段被拒绝,而不是等到请求期间阻塞事件循环。 | ||
| 82 | +4. **消息类型是唯一的路由键。** `Envelope.type` 同时决定 Handler 和默认 | ||
| 83 | + REST 路径,一个应用中不允许重复注册同一个消息类型。 | ||
| 84 | + | ||
| 85 | +## 3. 环境准备与运行 | ||
| 86 | + | ||
| 87 | +### 3.1 前置条件 | ||
| 88 | + | ||
| 89 | +- Python `>= 3.11.4` | ||
| 90 | +- `uv` | ||
| 91 | + | ||
| 92 | +从仓库的 `service` 目录执行: | ||
| 93 | + | ||
| 94 | +```bash | ||
| 95 | +uv sync | ||
| 96 | +uv run python examples/multi_handler_app.py | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +默认地址: | ||
| 100 | + | ||
| 101 | +- OpenAPI/Swagger UI:<http://127.0.0.1:8090/docs> | ||
| 102 | +- OpenAPI JSON:<http://127.0.0.1:8090/openapi.json> | ||
| 103 | +- 健康检查:<http://127.0.0.1:8090/health> | ||
| 104 | + | ||
| 105 | +监听地址和端口由 Service Framework 的通用配置控制: | ||
| 106 | + | ||
| 107 | +| 环境变量 | 默认值 | 说明 | | ||
| 108 | +| --- | --- | --- | | ||
| 109 | +| `OPENJIUWEN_SERVICE_HOST` | `0.0.0.0` | 服务监听地址 | | ||
| 110 | +| `OPENJIUWEN_SERVICE_PORT` | `8090` | 服务监听端口 | | ||
| 111 | +| `FEDERATED_AUTH_DATABASE_PATH` | `examples/federated_auth/.data/federated_auth.db` | 示例联合身份 SQLite 文件路径 | | ||
| 112 | + | ||
| 113 | +例如: | ||
| 114 | + | ||
| 115 | +```bash | ||
| 116 | +OPENJIUWEN_SERVICE_PORT=18090 \ | ||
| 117 | +FEDERATED_AUTH_DATABASE_PATH=/tmp/openjiuwen-federated-auth.db \ | ||
| 118 | +uv run python examples/multi_handler_app.py | ||
| 119 | +``` | ||
| 120 | + | ||
| 121 | +`.data/` 是本地运行数据目录,已经被忽略,不应提交数据库文件。 | ||
| 122 | + | ||
| 123 | +### 3.2 在 Swagger UI 中完成认证 | ||
| 124 | + | ||
| 125 | +1. 打开 `/docs`; | ||
| 126 | +2. 点击右上角 **Authorize**; | ||
| 127 | +3. 保持 `client_id` 为 `swagger-docs`,无需填写 `client_secret`; | ||
| 128 | +4. 再次点击 **Authorize**,浏览器会打开统一登录页; | ||
| 129 | +5. 选择本地登录或 Enterprise Demo SSO; | ||
| 130 | +6. 登录完成后,Swagger 使用 Authorization Code 和 PKCE 换取 Bearer Token; | ||
| 131 | +7. Swagger 随后会自动为受保护的 `/api/*` 请求增加 | ||
| 132 | + `Authorization: Bearer <token>`。 | ||
| 133 | + | ||
| 134 | +本地演示账号: | ||
| 135 | + | ||
| 136 | +```text | ||
| 137 | +username: demo | ||
| 138 | +password: demo | ||
| 139 | +``` | ||
| 140 | + | ||
| 141 | +Enterprise Demo SSO 页面预置的演示身份: | ||
| 142 | + | ||
| 143 | +```text | ||
| 144 | +employee ID: employee-10086 | ||
| 145 | +display name: Enterprise Alice | ||
| 146 | +email: alice@enterprise.example | ||
| 147 | +``` | ||
| 148 | + | ||
| 149 | +Enterprise Demo SSO 只是本地交互模拟器,不解析或验证 SAML XML。完整边界见 | ||
| 150 | +[`federated_auth/README.md`](federated_auth/README.md)。 | ||
| 151 | + | ||
| 152 | +## 4. 请求和响应协议 | ||
| 153 | + | ||
| 154 | +### 4.1 统一请求 Envelope | ||
| 155 | + | ||
| 156 | +所有通过 Service Framework 注册的 REST Handler 都使用完整的 v1 | ||
| 157 | +`Envelope`: | ||
| 158 | + | ||
| 159 | +```json | ||
| 160 | +{ | ||
| 161 | + "type": "users.create", | ||
| 162 | + "metadata": { | ||
| 163 | + "request_id": "request-1", | ||
| 164 | + "user_id": "optional-user-id", | ||
| 165 | + "chat_id": null, | ||
| 166 | + "session_id": null, | ||
| 167 | + "bot_id": null, | ||
| 168 | + "channel": "web", | ||
| 169 | + "timestamp": null, | ||
| 170 | + "trace_id": "optional-trace-id", | ||
| 171 | + "extra": {} | ||
| 172 | + }, | ||
| 173 | + "rawdata": { | ||
| 174 | + "name": "alice" | ||
| 175 | + }, | ||
| 176 | + "version": "1" | ||
| 177 | +} | ||
| 178 | +``` | ||
| 179 | + | ||
| 180 | +字段说明: | ||
| 181 | + | ||
| 182 | +| 字段 | 是否必填 | 说明 | | ||
| 183 | +| --- | --- | --- | | ||
| 184 | +| `type` | 是 | 消息路由键;默认 REST 路径为 `/api/{type}` | | ||
| 185 | +| `metadata.request_id` | 是 | 请求标识,也是后续幂等与链路追踪能力的基础 | | ||
| 186 | +| `metadata.*` | 否 | 用户、会话、Bot、渠道及追踪上下文;未知顶层 metadata 字段会被忽略,扩展字段应放入 `extra` | | ||
| 187 | +| `rawdata` | 是 | Handler 的业务输入;若声明了 `request_model`,进入 Handler 前会转换为对应的 Pydantic 对象 | | ||
| 188 | +| `version` | 否 | 协议版本,默认 `1` | | ||
| 189 | + | ||
| 190 | +路径和 `type` 必须一致。例如 `/api/users.create` 的请求体中 `type` 必须是 | ||
| 191 | +`users.create`。REST adapter 为每个 Handler 生成独立的 Envelope OpenAPI | ||
| 192 | +模型,因此路径与 `type` 不一致时,FastAPI 会在进入框架路由前返回 `422`。 | ||
| 193 | +`rawdata` 的业务模型校验则统一在 Router 内执行,使 REST、WebSocket 和直接 | ||
| 194 | +`app.dispatch()` 具有一致的 `validation` 错误信封。 | ||
| 195 | + | ||
| 196 | +### 4.2 普通响应 | ||
| 197 | + | ||
| 198 | +普通 Handler 返回 `dict` 或 `ResponseEnvelope`。返回 `dict` 时,框架会包装为: | ||
| 199 | + | ||
| 200 | +```json | ||
| 201 | +{ | ||
| 202 | + "type": "users.create", | ||
| 203 | + "metadata": { | ||
| 204 | + "request_id": "request-1" | ||
| 205 | + }, | ||
| 206 | + "rawdata": { | ||
| 207 | + "id": 1, | ||
| 208 | + "name": "alice", | ||
| 209 | + "created_by": "demo" | ||
| 210 | + }, | ||
| 211 | + "ok": true, | ||
| 212 | + "error_code": null, | ||
| 213 | + "error_message": null, | ||
| 214 | + "version": "1" | ||
| 215 | +} | ||
| 216 | +``` | ||
| 217 | + | ||
| 218 | +若 Handler 声明了 `response_model`,框架会在返回客户端前校验 | ||
| 219 | +`rawdata`。响应不满足模型属于服务端实现错误,会被归一化为 `internal` 错误响应。 | ||
| 220 | + | ||
| 221 | +### 4.3 SSE 流式响应 | ||
| 222 | + | ||
| 223 | +`StreamMessageHandler` 产生的每个 `dict` 或 `StreamChunk` 都会被框架包装为 | ||
| 224 | +SSE 事件: | ||
| 225 | + | ||
| 226 | +```text | ||
| 227 | +data: {"sequence":1,"is_final":false,"metadata":{"request_id":"request-1"},"rawdata":{"chunk":"h"},"error_code":null,"error_message":null} | ||
| 228 | + | ||
| 229 | +data: {"sequence":2,"is_final":true,"metadata":{"request_id":"request-1"},"rawdata":{"chunk":"i"},"error_code":null,"error_message":null} | ||
| 230 | + | ||
| 231 | +``` | ||
| 232 | + | ||
| 233 | +- `sequence` 从 `1` 递增; | ||
| 234 | +- 正常情况下最后一个分片的 `is_final` 为 `true`; | ||
| 235 | +- 流处理中发生框架异常时,会发送一个带 `error_code`、`error_message` 且 | ||
| 236 | + `is_final=true` 的终止分片; | ||
| 237 | +- 空的异步生成器当前不会额外产生终止分片。 | ||
| 238 | + | ||
| 239 | +## 5. Handler 开发方式 | ||
| 240 | + | ||
| 241 | +### 5.1 装饰器:适合宿主应用内的小型 Handler | ||
| 242 | + | ||
| 243 | +```python | ||
| 244 | +from openjiuwen_runtime.service import Envelope | ||
| 245 | + | ||
| 246 | + | ||
| 247 | +@app.handle( | ||
| 248 | + "ping", | ||
| 249 | + summary="Ping", | ||
| 250 | + tags=["system"], | ||
| 251 | +) | ||
| 252 | +async def ping(ctx, env: Envelope): | ||
| 253 | + return { | ||
| 254 | + "pong": True, | ||
| 255 | + "request_id": ctx.request_id, | ||
| 256 | + "authenticated_user": ctx.principal["username"], | ||
| 257 | + } | ||
| 258 | +``` | ||
| 259 | + | ||
| 260 | +这种方式代码最短,适合逻辑简单、不会跨应用复用的 Handler。 | ||
| 261 | + | ||
| 262 | +### 5.2 对象式普通 Handler:适合依赖注入和复用 | ||
| 263 | + | ||
| 264 | +```python | ||
| 265 | +from pydantic import BaseModel, Field | ||
| 266 | + | ||
| 267 | +from openjiuwen_runtime.service import ( | ||
| 268 | + Envelope, | ||
| 269 | + HandlerSpec, | ||
| 270 | + MessageHandler, | ||
| 271 | +) | ||
| 272 | + | ||
| 273 | + | ||
| 274 | +class CreateUserInput(BaseModel): | ||
| 275 | + name: str = Field(min_length=1) | ||
| 276 | + | ||
| 277 | + | ||
| 278 | +class CreatedUserOutput(BaseModel): | ||
| 279 | + id: int | ||
| 280 | + name: str | ||
| 281 | + created_by: str | ||
| 282 | + | ||
| 283 | + | ||
| 284 | +class CreateUserHandler(MessageHandler): | ||
| 285 | + spec = HandlerSpec( | ||
| 286 | + msg_type="users.create", | ||
| 287 | + request_model=CreateUserInput, | ||
| 288 | + response_model=CreatedUserOutput, | ||
| 289 | + summary="Create user", | ||
| 290 | + tags=("users",), | ||
| 291 | + ) | ||
| 292 | + | ||
| 293 | + def __init__(self, store): | ||
| 294 | + self._store = store | ||
| 295 | + | ||
| 296 | + async def handle(self, ctx, env: Envelope): | ||
| 297 | + user = await self._store.create(ctx.request.name) | ||
| 298 | + return { | ||
| 299 | + **user, | ||
| 300 | + "created_by": ctx.principal["username"], | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + | ||
| 304 | +app.register(CreateUserHandler(store)) | ||
| 305 | +``` | ||
| 306 | + | ||
| 307 | +`HandlerSpec` 是传输无关的 Handler 描述: | ||
| 308 | + | ||
| 309 | +| 属性 | 作用 | | ||
| 310 | +| --- | --- | | ||
| 311 | +| `msg_type` | 唯一消息类型及默认 REST 路径后缀 | | ||
| 312 | +| `request_model` | `rawdata` 的 Pydantic 输入模型 | | ||
| 313 | +| `response_model` | 普通响应 `rawdata` 的 Pydantic 输出模型 | | ||
| 314 | +| `summary` | OpenAPI 操作摘要 | | ||
| 315 | +| `description` | OpenAPI 操作说明 | | ||
| 316 | +| `tags` | OpenAPI 分组标签 | | ||
| 317 | + | ||
| 318 | +构造函数可接收 Repository、客户端、配置或领域服务。不要依赖模块级可变状态来保存 | ||
| 319 | +需要跨副本共享的数据;生产服务应使用 `SystemContext` 中的数据库、Redis 或明确注入的 | ||
| 320 | +持久化组件。 | ||
| 321 | + | ||
| 322 | +### 5.3 对象式流式 Handler | ||
| 323 | + | ||
| 324 | +```python | ||
| 325 | +from openjiuwen_runtime.service import ( | ||
| 326 | + HandlerSpec, | ||
| 327 | + StreamMessageHandler, | ||
| 328 | +) | ||
| 329 | + | ||
| 330 | + | ||
| 331 | +class ChatHandler(StreamMessageHandler): | ||
| 332 | + spec = HandlerSpec( | ||
| 333 | + msg_type="chat", | ||
| 334 | + request_model=ChatInput, | ||
| 335 | + summary="Stream chat characters", | ||
| 336 | + tags=("chat",), | ||
| 337 | + ) | ||
| 338 | + | ||
| 339 | + async def handle_stream(self, ctx, env): | ||
| 340 | + for character in ctx.request.text: | ||
| 341 | + yield { | ||
| 342 | + "chunk": character, | ||
| 343 | + "user": ctx.principal["username"], | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + | ||
| 347 | +app.register(ChatHandler()) | ||
| 348 | +``` | ||
| 349 | + | ||
| 350 | +`handle_stream()` 必须是包含 `yield` 的异步生成器函数。仅仅写成返回列表的 | ||
| 351 | +`async def` 不满足流式契约,并会在注册时抛出 `TypeError`。 | ||
| 352 | + | ||
| 353 | +### 5.4 批量注册 | ||
| 354 | + | ||
| 355 | +同一功能域存在多个对象式 Handler 时,可以按顺序注册: | ||
| 356 | + | ||
| 357 | +```python | ||
| 358 | +app.register_all( | ||
| 359 | + [ | ||
| 360 | + ListUsersHandler(store), | ||
| 361 | + GetUserHandler(store), | ||
| 362 | + RemoveUserHandler(store), | ||
| 363 | + ChatHandler(), | ||
| 364 | + ] | ||
| 365 | +) | ||
| 366 | +``` | ||
| 367 | + | ||
| 368 | +如果批次中出现重复的 `msg_type`,注册会立即失败。 | ||
| 369 | + | ||
| 370 | +### 5.5 独立 Handler 模块 | ||
| 371 | + | ||
| 372 | +当一个功能由其他开发者或独立目录维护时,使用 `HandlerRegistry`: | ||
| 373 | + | ||
| 374 | +```python | ||
| 375 | +# feature_handlers.py | ||
| 376 | +from openjiuwen_runtime.service import HandlerRegistry | ||
| 377 | + | ||
| 378 | +feature_handlers = HandlerRegistry() | ||
| 379 | + | ||
| 380 | + | ||
| 381 | +@feature_handlers.handle( | ||
| 382 | + "feature.execute", | ||
| 383 | + request_model=FeatureInput, | ||
| 384 | + response_model=FeatureOutput, | ||
| 385 | + tags=["feature"], | ||
| 386 | +) | ||
| 387 | +async def execute(ctx, env): | ||
| 388 | + return {"result": ctx.request.value} | ||
| 389 | +``` | ||
| 390 | + | ||
| 391 | +宿主应用只负责组合: | ||
| 392 | + | ||
| 393 | +```python | ||
| 394 | +from feature_handlers import feature_handlers | ||
| 395 | + | ||
| 396 | +app.include(feature_handlers) | ||
| 397 | +``` | ||
| 398 | + | ||
| 399 | +`App.include()` 接收任何实现 `handlers()` 方法的模块,不要求模块继承某个具体基类。 | ||
| 400 | +这使功能模块能够独立开发和测试,同时保持 Handler 契约一致。 | ||
| 401 | + | ||
| 402 | +### 5.6 传输无关中间件 | ||
| 403 | + | ||
| 404 | +需要为多个 Handler 统一增加日志、计时、审计或策略检查时,可以使用 | ||
| 405 | +`app.use()` 注册异步中间件: | ||
| 406 | + | ||
| 407 | +```python | ||
| 408 | +import time | ||
| 409 | + | ||
| 410 | + | ||
| 411 | +async def timing_middleware(ctx, env, nxt): | ||
| 412 | + started_at = time.monotonic() | ||
| 413 | + try: | ||
| 414 | + return await nxt(ctx, env) | ||
| 415 | + finally: | ||
| 416 | + ctx.logger.info( | ||
| 417 | + "handler completed: type=%s elapsed_ms=%.2f", | ||
| 418 | + env.type, | ||
| 419 | + (time.monotonic() - started_at) * 1000, | ||
| 420 | + ) | ||
| 421 | + | ||
| 422 | + | ||
| 423 | +app.use(timing_middleware) | ||
| 424 | +``` | ||
| 425 | + | ||
| 426 | +中间件采用洋葱模型:先注册的中间件位于外层,`await nxt(ctx, env)` 调用下一个 | ||
| 427 | +中间件或最终 Handler。中间件位于 Router 层,因此直接 `app.dispatch()` 和所有传输 | ||
| 428 | +adapter 共用同一条链路。中间件本身也必须异步,且不要执行阻塞 I/O。 | ||
| 429 | + | ||
| 430 | +## 6. 注册、启动和请求通信链路 | ||
| 431 | + | ||
| 432 | +### 6.1 应用启动 | ||
| 433 | + | ||
| 434 | +```mermaid | ||
| 435 | +sequenceDiagram | ||
| 436 | + participant Module as Application module | ||
| 437 | + participant App | ||
| 438 | + participant Router as MessageRouter | ||
| 439 | + participant REST as RestAdapter | ||
| 440 | + participant API as FastAPI | ||
| 441 | + | ||
| 442 | + Module->>App: register / register_all / include | ||
| 443 | + App->>Router: register(handler) | ||
| 444 | + Router->>Router: validate spec and async contract | ||
| 445 | + App->>REST: register(handler) | ||
| 446 | + REST->>API: add POST /api/{msg_type} | ||
| 447 | + REST->>API: invalidate cached OpenAPI schema | ||
| 448 | +``` | ||
| 449 | + | ||
| 450 | +注册时会完成以下校验: | ||
| 451 | + | ||
| 452 | +- `handler.spec` 必须是 `HandlerSpec`; | ||
| 453 | +- `msg_type` 必须是非空字符串且不能重复; | ||
| 454 | +- `request_model`、`response_model` 必须是 Pydantic 模型; | ||
| 455 | +- 普通 Handler 必须实现异步 `handle()`; | ||
| 456 | +- 流式 Handler 必须实现异步生成器 `handle_stream()`。 | ||
| 457 | + | ||
| 458 | +### 6.2 普通 HTTP 请求 | ||
| 459 | + | ||
| 460 | +```mermaid | ||
| 461 | +sequenceDiagram | ||
| 462 | + participant Client | ||
| 463 | + participant FastAPI | ||
| 464 | + participant OAuth as OAuth2AccessControl | ||
| 465 | + participant REST as RestAdapter | ||
| 466 | + participant Context as SystemContext | ||
| 467 | + participant Router as MessageRouter | ||
| 468 | + participant Handler | ||
| 469 | + | ||
| 470 | + Client->>FastAPI: POST /api/users.create + Bearer token + Envelope | ||
| 471 | + FastAPI->>OAuth: extract and validate access token | ||
| 472 | + OAuth-->>FastAPI: principal | ||
| 473 | + FastAPI->>REST: validated endpoint body | ||
| 474 | + REST->>Context: for_request(metadata) | ||
| 475 | + Context-->>REST: RequestContext | ||
| 476 | + REST->>REST: attach principal to RequestContext | ||
| 477 | + REST->>Router: dispatch(envelope, request_context) | ||
| 478 | + Router->>Router: validate rawdata as Pydantic model and run middleware | ||
| 479 | + Router->>Handler: await handle(ctx, env) | ||
| 480 | + Handler-->>Router: dict or ResponseEnvelope | ||
| 481 | + Router-->>REST: UnaryResult | ||
| 482 | + REST-->>Client: HTTP status + ResponseEnvelope JSON | ||
| 483 | +``` | ||
| 484 | + | ||
| 485 | +认证只发生在 REST adapter 的边界;业务 Handler 从 `ctx.principal` 使用已经验证的 | ||
| 486 | +本地身份,不解析 Authorization Header,也不自行验证 Token。 | ||
| 487 | + | ||
| 488 | +### 6.3 流式 HTTP/SSE 请求 | ||
| 489 | + | ||
| 490 | +流式请求的前半段与普通请求相同。Router 返回 `StreamResult` 后,REST adapter | ||
| 491 | +创建 `StreamingResponse(media_type="text/event-stream")`,异步读取 Handler | ||
| 492 | +分片并按 SSE `data:` 帧发送给客户端。整个过程不会把完整结果缓存在内存后再响应。 | ||
| 493 | + | ||
| 494 | +## 7. RequestContext 和依赖边界 | ||
| 495 | + | ||
| 496 | +`SystemContext` 是进程级能力容器,在 FastAPI lifespan 中启动和停止。每次请求根据 | ||
| 497 | +`Envelope.metadata` 派生一个 `RequestContext`。Handler 常用字段包括: | ||
| 498 | + | ||
| 499 | +- `ctx.request_id` | ||
| 500 | +- `ctx.request`(声明 `request_model` 后的 Pydantic 请求对象) | ||
| 501 | +- `ctx.user_id` | ||
| 502 | +- `ctx.chat_id` | ||
| 503 | +- `ctx.session_id` | ||
| 504 | +- `ctx.bot_id` | ||
| 505 | +- `ctx.trace_id` | ||
| 506 | +- `ctx.principal` | ||
| 507 | +- `ctx.db` | ||
| 508 | +- `ctx.redis` | ||
| 509 | +- `ctx.settings` | ||
| 510 | +- `ctx.logger` | ||
| 511 | + | ||
| 512 | +如果需要事务,应使用框架提供的请求上下文事务入口,而不是在 Handler 内创建无法统一 | ||
| 513 | +管理的连接。示例中的 `DemoUserStore` 为了便于运行,使用进程内字典和 | ||
| 514 | +`asyncio.Lock`;它的数据会在进程退出后丢失,也不会在多个服务副本之间共享。 | ||
| 515 | + | ||
| 516 | +## 8. OAuth2 和联合身份如何接入 | ||
| 517 | + | ||
| 518 | +`multi_handler_app.py` 创建一个 `ExampleOAuth2AuthorizationServer`,然后把它的异步 | ||
| 519 | +Token 校验函数交给框架的 `OAuth2AccessControl`: | ||
| 520 | + | ||
| 521 | +```python | ||
| 522 | +oauth2_server = ExampleOAuth2AuthorizationServer() | ||
| 523 | + | ||
| 524 | +oauth2 = OAuth2AccessControl( | ||
| 525 | + token_url="/oauth/token", | ||
| 526 | + authorization_url="/oauth/authorize", | ||
| 527 | + token_validator=oauth2_server.validate_access_token, | ||
| 528 | + scheme_name="OAuth2AuthorizationCode", | ||
| 529 | +) | ||
| 530 | + | ||
| 531 | +app = App( | ||
| 532 | + lambda: SystemContext(), | ||
| 533 | + title="OpenJiuwen Multi Handler Example", | ||
| 534 | + enable_ws=False, | ||
| 535 | + oauth2=oauth2, | ||
| 536 | +) | ||
| 537 | + | ||
| 538 | +app.asgi.swagger_ui_init_oauth = { | ||
| 539 | + "clientId": oauth2_server.client_id, | ||
| 540 | + "usePkceWithAuthorizationCodeGrant": True, | ||
| 541 | +} | ||
| 542 | +``` | ||
| 543 | + | ||
| 544 | +框架只负责: | ||
| 545 | + | ||
| 546 | +- 在 OpenAPI 中声明 OAuth2 scheme; | ||
| 547 | +- 从请求读取 Bearer Token; | ||
| 548 | +- 异步调用应用提供的 `token_validator`; | ||
| 549 | +- 把验证结果作为 `ctx.principal` 传入 Handler; | ||
| 550 | +- Token 无效时返回 `401` 和 `WWW-Authenticate: Bearer`。 | ||
| 551 | + | ||
| 552 | +用户存储、登录页面、Token 签发和联合身份协议都属于应用层。本示例把这些能力放在 | ||
| 553 | +`federated_auth/` 中,生产应用可以保持 `OAuth2AccessControl` 不变,只替换为现有认证 | ||
| 554 | +中心的异步 Token 校验方法。 | ||
| 555 | + | ||
| 556 | +## 9. 示例接口 | ||
| 557 | + | ||
| 558 | +所有 `/api/*` 接口均需要 Bearer Token。 | ||
| 559 | + | ||
| 560 | +| 方法与路径 | 类型 | 说明 | 注册方式 | | ||
| 561 | +| --- | --- | --- | --- | | ||
| 562 | +| `POST /api/users.create` | 普通 | 创建演示用户 | `app.register()` | | ||
| 563 | +| `POST /api/users.list` | 普通 | 查询演示用户列表 | `app.register_all()` | | ||
| 564 | +| `POST /api/users.get` | 普通 | 按 ID 查询演示用户 | `app.register_all()` | | ||
| 565 | +| `POST /api/users.remove` | 普通 | 删除演示用户 | `app.register_all()` | | ||
| 566 | +| `POST /api/chat` | SSE | 按字符输出输入文本 | `app.register_all()` | | ||
| 567 | +| `POST /api/custom.uppercase` | 普通 | 独立模块提供的大写转换 | `app.include()` | | ||
| 568 | +| `POST /api/ping` | 普通 | 宿主应用内装饰器示例 | `@app.handle()` | | ||
| 569 | +| `POST /api/identity.me` | 普通 | 返回当前本地 Principal | `@app.handle()` | | ||
| 570 | +| `POST /api/demo.error` | 普通错误 | 展示框架错误信封 | `@app.handle()` | | ||
| 571 | +| `GET /health` | 普通 FastAPI | 健康检查,不经过消息路由 | `@app.asgi.get()` | | ||
| 572 | + | ||
| 573 | +认证相关接口见联合认证文档。 | ||
| 574 | + | ||
| 575 | +## 10. 校验和错误行为 | ||
| 576 | + | ||
| 577 | +需要区分两层校验: | ||
| 578 | + | ||
| 579 | +1. **FastAPI/OpenAPI 边界校验。** 完整 Envelope 的结构或路径对应的 `type` | ||
| 580 | + 不符合要求时,FastAPI 返回标准 `422`。 | ||
| 581 | +2. **框架派发校验。** `rawdata` 不满足 Handler 的 `request_model`、直接调用 | ||
| 582 | + `app.dispatch()`,或进入 Router 后发生领域校验、资源不存在等问题时,框架返回统一 | ||
| 583 | + `ResponseEnvelope(ok=false)`。`rawdata` 校验失败对应 HTTP `400`。 | ||
| 584 | + | ||
| 585 | +框架错误码与 HTTP 状态映射: | ||
| 586 | + | ||
| 587 | +| `error_code` | HTTP 状态 | | ||
| 588 | +| --- | --- | | ||
| 589 | +| `validation` | `400` | | ||
| 590 | +| `not_found` | `404` | | ||
| 591 | +| `conflict` / `idempotent` | `409` | | ||
| 592 | +| `locked` | `423` | | ||
| 593 | +| `timeout` | `504` | | ||
| 594 | +| `internal` | `500` | | ||
| 595 | + | ||
| 596 | +未知 REST 路径由 FastAPI 返回标准 `404`。已注册路径中的业务错误由 Service | ||
| 597 | +Framework 统一包装。 | ||
| 598 | + | ||
| 599 | +## 11. 如何增加一个新的功能模块 | ||
| 600 | + | ||
| 601 | +推荐流程: | ||
| 602 | + | ||
| 603 | +1. 为每种消息定义稳定且唯一的 `msg_type`; | ||
| 604 | +2. 使用 Pydantic 定义 `rawdata` 输入模型和输出模型; | ||
| 605 | +3. 小型功能使用 `HandlerRegistry` 装饰器,复杂功能实现 Handler 类; | ||
| 606 | +4. 所有 I/O 都使用异步客户端,避免在 Handler 中执行阻塞调用; | ||
| 607 | +5. 为模块暴露一个实现 `handlers()` 的 Registry 或 Module; | ||
| 608 | +6. 在宿主应用调用 `app.include(module)`; | ||
| 609 | +7. 为注册、输入校验、输出校验、错误和流式终止行为补充测试; | ||
| 610 | +8. 启动示例并检查 `/docs` 中路径、schema、tag、summary 和认证声明。 | ||
| 611 | + | ||
| 612 | +一个模块不应该: | ||
| 613 | + | ||
| 614 | +- 修改 `MessageRouter` 或 REST adapter 来注册自己的业务; | ||
| 615 | +- 在 import 阶段启动网络连接或后台线程; | ||
| 616 | +- 绕过 `RequestContext` 自行解析认证信息; | ||
| 617 | +- 注册同步 Handler; | ||
| 618 | +- 依赖进程内状态实现需要多副本一致性的业务。 | ||
| 619 | + | ||
| 620 | +## 12. 测试 | ||
| 621 | + | ||
| 622 | +从 `service` 目录运行全量测试: | ||
| 623 | + | ||
| 624 | +```bash | ||
| 625 | +uv run pytest -q | ||
| 626 | +``` | ||
| 627 | + | ||
| 628 | +仅运行与本示例相关的测试: | ||
| 629 | + | ||
| 630 | +```bash | ||
| 631 | +uv run pytest -q \ | ||
| 632 | + tests/unit_tests/test_handler_registration.py \ | ||
| 633 | + tests/unit_tests/test_federated_identity_store.py \ | ||
| 634 | + tests/unit_tests/test_federated_oauth2.py \ | ||
| 635 | + tests/system_tests/test_federated_identity_sqlite.py \ | ||
| 636 | + tests/system_tests/test_multi_handler_example.py | ||
| 637 | +``` | ||
| 638 | + | ||
| 639 | +测试覆盖: | ||
| 640 | + | ||
| 641 | +- 三种 Handler 组合方式; | ||
| 642 | +- 同步 Handler、重复类型和非法模型的注册失败; | ||
| 643 | +- 请求、响应 Pydantic 校验; | ||
| 644 | +- REST、SSE 和统一错误信封; | ||
| 645 | +- OAuth2 未登录、非法 Token、本地登录与联合登录; | ||
| 646 | +- Authorization Code 的一次性消费和 PKCE 校验; | ||
| 647 | +- SQLite 身份持久化、首次并发登录和连接绑定保护; | ||
| 648 | +- 同一外部身份多次登录得到同一个本地 `user_id`。 | ||
| 649 | + | ||
| 650 | +## 13. 生产化边界 | ||
| 651 | + | ||
| 652 | +本示例中以下实现必须在生产环境替换或增强: | ||
| 653 | + | ||
| 654 | +| 示例实现 | 当前用途 | 生产要求 | | ||
| 655 | +| --- | --- | --- | | ||
| 656 | +| `DemoUserStore` | 展示依赖注入和 CRUD Handler | 使用共享数据库或 `ctx.db`,保证多副本一致性 | | ||
| 657 | +| `ExampleOAuth2AuthorizationServer` | 展示 Authorization Code + PKCE 通信 | 接入正式认证中心;持久化或分布式保存授权状态、Token 和撤销状态 | | ||
| 658 | +| `DemoEnterpriseIdentityProvider` | 展示企业登录页面跳转 | 替换为真实企业 IdP | | ||
| 659 | +| `DemoFederationProvider` | 标准化本地演示表单 | 替换为严格验证 SAML/OIDC 回调的 Provider | | ||
| 660 | +| SQLite 联合身份库 | 本地运行和单机演示 | 根据部署规模使用正式数据库、迁移机制、备份、审计和密钥管理 | | ||
| 661 | +| 固定本地账号 `demo/demo` | 快速体验 | 删除;使用正式用户目录和凭据策略 | | ||
| 662 | + | ||
| 663 | +此外,示例将 WebSocket 关闭,仅展示 REST 与 HTTP SSE。Service Framework 的 | ||
| 664 | +传输适配能力与 Handler 契约是分离的;是否启用其他传输应由具体应用根据部署需求决定。 | ||
| 665 | +当前流式 Handler 的 OpenAPI 操作会声明 `text/event-stream`,但不会根据 Pydantic | ||
| 666 | +模型生成逐分片 schema;需要稳定流式协议时,应在应用文档中额外声明每个 | ||
| 667 | +`StreamChunk.rawdata` 的结构和版本策略。 | ||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""A separately maintained handler module included by multi_handler_app.py.""" | ||
| 5 | + | ||
| 6 | +from pydantic import BaseModel, Field | ||
| 7 | + | ||
| 8 | +from openjiuwen_runtime.service import HandlerRegistry | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +class UppercaseInput(BaseModel): | ||
| 12 | + """Input owned by this extension module.""" | ||
| 13 | + | ||
| 14 | + text: str = Field(min_length=1, examples=["hello"]) | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class UppercaseOutput(BaseModel): | ||
| 18 | + """Output owned by this extension module.""" | ||
| 19 | + | ||
| 20 | + text: str | ||
| 21 | + authenticated_user: str | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +custom_handlers = HandlerRegistry() | ||
| 25 | + | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + "custom.uppercase", | ||
| 29 | + request_model=UppercaseInput, | ||
| 30 | + response_model=UppercaseOutput, | ||
| 31 | + summary="Uppercase text", | ||
| 32 | + description="A handler loaded from a separate, reusable handler module.", | ||
| 33 | + tags=["extension"], | ||
| 34 | +) | ||
| 35 | +async def uppercase(ctx, env): | ||
| 36 | + return { | ||
| 37 | + "text": env.rawdata.text.upper(), | ||
| 38 | + "authenticated_user": ctx.principal["username"], | ||
| 39 | + } | ||
| @@ -0,0 +1 @@ | |||
| 1 | +.data/ | ||
| @@ -0,0 +1,649 @@ | |||
| 1 | +# Federated authentication example | ||
| 2 | + | ||
| 3 | +本模块展示如何把企业外部身份接入应用自己的 OAuth2 Authorization Code 流程, | ||
| 4 | +并将外部身份稳定映射为本地虚拟组织和虚拟用户。它与 | ||
| 5 | +`multi_handler_app.py` 组合后,可以直接从 Swagger UI 体验完整链路。 | ||
| 6 | + | ||
| 7 | +本模块解决的是两个相互独立但需要衔接的问题: | ||
| 8 | + | ||
| 9 | +1. **联合认证:** 用户在企业身份提供方(Identity Provider,IdP)完成认证,应用获得 | ||
| 10 | + 一个已经验证并标准化的外部身份; | ||
| 11 | +2. **本地授权身份:** 应用将该外部身份映射到稳定的本地 `user_id`、 | ||
| 12 | + `organization_id` 和角色,然后通过内部 OAuth2 Token 向业务 Handler 传递身份。 | ||
| 13 | + | ||
| 14 | +联合认证不会替换应用内部的 OAuth2。SAML 或其他企业协议负责证明“外部用户是谁”, | ||
| 15 | +OAuth2 Bearer Token 负责应用内部“本次请求以哪个本地身份访问 API”。 | ||
| 16 | + | ||
| 17 | +> `DemoEnterpriseIdentityProvider` 和 `DemoFederationProvider` 仅用于本地演示。 | ||
| 18 | +> 它们不接收、不解析、不验证 SAML XML,不能用于生产身份认证。 | ||
| 19 | + | ||
| 20 | +## 1. 设计目标和非目标 | ||
| 21 | + | ||
| 22 | +### 1.1 设计目标 | ||
| 23 | + | ||
| 24 | +- 隔离外部身份协议与业务 Handler; | ||
| 25 | +- 让本地账号和企业联合账号进入同一个 OAuth2 Authorization Code 流程; | ||
| 26 | +- 为外部身份生成稳定的本地虚拟用户和组织成员关系; | ||
| 27 | +- 同一个外部主体重复登录时复用同一个本地 `user_id`; | ||
| 28 | +- 使用异步接口,避免身份查询或数据库操作阻塞事件循环; | ||
| 29 | +- 允许单元测试使用内存 Store,本地运行使用 SQLite; | ||
| 30 | +- 让真实 SAML Provider 能够替换演示 Provider,而不改变身份映射和业务 Handler。 | ||
| 31 | + | ||
| 32 | +### 1.2 非目标 | ||
| 33 | + | ||
| 34 | +当前模块不负责: | ||
| 35 | + | ||
| 36 | +- 实现生产级 SAML Service Provider; | ||
| 37 | +- 同步企业完整组织树、部门或用户目录; | ||
| 38 | +- 把企业部门自动映射成 Runtime 的 `group_id`; | ||
| 39 | +- 管理 Bot、Agent、会话或业务资源权限; | ||
| 40 | +- 实现 OAuth2 Refresh Token、Scope、Token 撤销、Introspection 或 Logout; | ||
| 41 | +- 提供 MySQL、PostgreSQL 或通用数据库 URL 适配器; | ||
| 42 | +- 提供多副本 OAuth2 授权状态存储。 | ||
| 43 | + | ||
| 44 | +企业组织与 Runtime `group_id` 不是同一概念。本示例中的 | ||
| 45 | +`organization_id` 表示本地身份和租户边界;企业部门、项目组、Runtime Group、Bot | ||
| 46 | +及 Agent 的映射属于更上层的授权和资源模型,不应在身份认证模块中隐式完成。 | ||
| 47 | + | ||
| 48 | +## 2. 模块结构和职责 | ||
| 49 | + | ||
| 50 | +```text | ||
| 51 | +federated_auth/ | ||
| 52 | +├── README.md | ||
| 53 | +├── __init__.py | ||
| 54 | +├── domain.py | ||
| 55 | +├── provider.py | ||
| 56 | +├── identity_store.py | ||
| 57 | +├── database_identity_store.py | ||
| 58 | +├── module.py | ||
| 59 | +├── oauth2_server.py | ||
| 60 | +└── demo_idp.py | ||
| 61 | +``` | ||
| 62 | + | ||
| 63 | +| 文件 | 主要类型 | 职责 | | ||
| 64 | +| --- | --- | --- | | ||
| 65 | +| `domain.py` | `FederationConnection`, `ExternalIdentity`, `LocalPrincipal` | 定义 Provider、Store 和 OAuth2 之间共享的稳定领域对象 | | ||
| 66 | +| `provider.py` | `FederationProvider`, `DemoFederationProvider` | 抽象企业身份协议的开始登录和回调消费边界 | | ||
| 67 | +| `identity_store.py` | `FederatedIdentityStore`, `InMemoryFederatedIdentityStore` | 定义外部身份到本地 Principal 的映射接口,并提供单元测试实现 | | ||
| 68 | +| `database_identity_store.py` | `DatabaseFederatedIdentityStore` | 使用一个 SQLite 文件持久化虚拟组织、用户、外部身份和成员关系 | | ||
| 69 | +| `module.py` | `FederatedAuthModule` | 编排 Provider、Store 和 OAuth2 Server,并挂载联合登录路由 | | ||
| 70 | +| `oauth2_server.py` | `ExampleOAuth2AuthorizationServer` | 示例 Authorization Code、PKCE、访问令牌签发与校验 | | ||
| 71 | +| `demo_idp.py` | `DemoEnterpriseIdentityProvider` | 提供明确标注的本地企业 IdP 表单模拟器 | | ||
| 72 | + | ||
| 73 | +依赖方向如下: | ||
| 74 | + | ||
| 75 | +```mermaid | ||
| 76 | +flowchart TD | ||
| 77 | + App["multi_handler_app.py"] | ||
| 78 | + Module["FederatedAuthModule"] | ||
| 79 | + Provider["FederationProvider"] | ||
| 80 | + Store["FederatedIdentityStore"] | ||
| 81 | + OAuth["ExampleOAuth2AuthorizationServer"] | ||
| 82 | + IdP["DemoEnterpriseIdentityProvider"] | ||
| 83 | + SQLite["SQLite"] | ||
| 84 | + Handler["Protected Service Handler"] | ||
| 85 | + | ||
| 86 | + App --> Module | ||
| 87 | + App --> OAuth | ||
| 88 | + App --> IdP | ||
| 89 | + Module --> Provider | ||
| 90 | + Module --> Store | ||
| 91 | + Module --> OAuth | ||
| 92 | + Provider --> IdP | ||
| 93 | + Store --> SQLite | ||
| 94 | + OAuth --> Handler | ||
| 95 | +``` | ||
| 96 | + | ||
| 97 | +`FederatedAuthModule` 只依赖 `FederationProvider` 和 | ||
| 98 | +`FederatedIdentityStore` 抽象,不依赖演示类。生产应用可以替换 Provider 和 Store | ||
| 99 | +实现,同时保留编排方式。 | ||
| 100 | + | ||
| 101 | +## 3. 核心领域对象 | ||
| 102 | + | ||
| 103 | +### 3.1 FederationConnection | ||
| 104 | + | ||
| 105 | +`FederationConnection` 描述一个受信任的企业身份连接,并将它绑定到一个本地虚拟组织: | ||
| 106 | + | ||
| 107 | +```python | ||
| 108 | +connection = FederationConnection( | ||
| 109 | + connection_id="enterprise-demo", | ||
| 110 | + issuer="https://idp.enterprise-demo.example", | ||
| 111 | + organization_id="virtual-org-enterprise-demo", | ||
| 112 | + organization_name="Enterprise Demo SSO", | ||
| 113 | + default_role="member", | ||
| 114 | +) | ||
| 115 | +``` | ||
| 116 | + | ||
| 117 | +| 字段 | 含义 | 稳定性要求 | | ||
| 118 | +| --- | --- | --- | | ||
| 119 | +| `connection_id` | 应用内部引用该企业连接的唯一 ID | 必须稳定且唯一 | | ||
| 120 | +| `issuer` | 外部 IdP 的受信任签发者标识 | 必须来自可信配置,不能相信回调自行声明的值 | | ||
| 121 | +| `organization_id` | 该连接对应的本地虚拟组织 | 是本地身份边界,不是 Runtime `group_id` | | ||
| 122 | +| `organization_name` | 本地展示名称 | 可以更新 | | ||
| 123 | +| `default_role` | 首次创建组织成员关系时赋予的角色 | 默认 `member` | | ||
| 124 | + | ||
| 125 | +SQLite 和内存 Store 都禁止把已经使用的 `connection_id` 重新绑定到另一个 | ||
| 126 | +`issuer`、`organization_id` 或 `default_role`,以防同一个连接 ID 的身份语义发生漂移。 | ||
| 127 | + | ||
| 128 | +### 3.2 ExternalIdentity | ||
| 129 | + | ||
| 130 | +`ExternalIdentity` 是 Provider 完成协议验证后输出的标准化身份: | ||
| 131 | + | ||
| 132 | +```python | ||
| 133 | +ExternalIdentity( | ||
| 134 | + connection_id="enterprise-demo", | ||
| 135 | + issuer="https://idp.enterprise-demo.example", | ||
| 136 | + external_subject="employee-10086", | ||
| 137 | + display_name="Enterprise Alice", | ||
| 138 | + email="alice@enterprise.example", | ||
| 139 | + attributes={"employee_id": "employee-10086"}, | ||
| 140 | +) | ||
| 141 | +``` | ||
| 142 | + | ||
| 143 | +其中 `(connection_id, issuer, external_subject)` 是外部身份的稳定复合键。 | ||
| 144 | + | ||
| 145 | +- `external_subject` 应使用 IdP 提供的稳定、不可由终端用户修改的 Subject; | ||
| 146 | +- 不应使用展示名作为唯一键; | ||
| 147 | +- 是否可使用邮箱取决于企业 IdP 是否保证邮箱稳定且唯一;默认不应这样假设; | ||
| 148 | +- `attributes` 可保存经过验证的附加声明,但不应存放原始凭据或不必要的敏感信息。 | ||
| 149 | + | ||
| 150 | +### 3.3 LocalPrincipal | ||
| 151 | + | ||
| 152 | +`LocalPrincipal` 是应用内部认证和 Handler 使用的身份: | ||
| 153 | + | ||
| 154 | +```python | ||
| 155 | +LocalPrincipal( | ||
| 156 | + user_id="user_...", | ||
| 157 | + organization_id="virtual-org-enterprise-demo", | ||
| 158 | + display_name="Enterprise Alice", | ||
| 159 | + email="alice@enterprise.example", | ||
| 160 | + roles=("member",), | ||
| 161 | + auth_source="saml", | ||
| 162 | +) | ||
| 163 | +``` | ||
| 164 | + | ||
| 165 | +业务 Handler 不应该读取 SAML Response 或企业回调表单,而应该只使用经过映射的 | ||
| 166 | +`ctx.principal`。 | ||
| 167 | + | ||
| 168 | +## 4. 完整通信链路 | ||
| 169 | + | ||
| 170 | +### 4.1 企业联合登录 | ||
| 171 | + | ||
| 172 | +```mermaid | ||
| 173 | +sequenceDiagram | ||
| 174 | + participant Browser as Swagger UI / Browser | ||
| 175 | + participant OAuth as Example OAuth2 Server | ||
| 176 | + participant Federation as FederatedAuthModule | ||
| 177 | + participant Provider as FederationProvider | ||
| 178 | + participant IdP as Enterprise IdP | ||
| 179 | + participant Store as FederatedIdentityStore | ||
| 180 | + participant API as Protected Service API | ||
| 181 | + | ||
| 182 | + Browser->>OAuth: GET /oauth/authorize<br/>client_id, redirect_uri, state, PKCE challenge | ||
| 183 | + OAuth->>OAuth: validate request and create authorization_request_id | ||
| 184 | + OAuth-->>Browser: unified sign-in page | ||
| 185 | + Browser->>Federation: GET /auth/federation/{connection_id}/login<br/>authorization_request_id | ||
| 186 | + Federation->>OAuth: verify authorization request exists and is not expired | ||
| 187 | + Federation->>Provider: begin_login(connection, authorization_request_id) | ||
| 188 | + Provider-->>Federation: IdP login URL | ||
| 189 | + Federation-->>Browser: 303 redirect to IdP | ||
| 190 | + Browser->>IdP: authenticate user | ||
| 191 | + IdP-->>Browser: callback payload | ||
| 192 | + Browser->>Federation: POST /auth/federation/{connection_id}/callback | ||
| 193 | + Federation->>Provider: consume_callback(connection, form) | ||
| 194 | + Provider->>Provider: validate protocol and normalize ExternalIdentity | ||
| 195 | + Provider-->>Federation: authorization_request_id + ExternalIdentity | ||
| 196 | + Federation->>OAuth: verify authorization request again | ||
| 197 | + Federation->>Store: resolve_or_create(connection, identity) | ||
| 198 | + Store-->>Federation: LocalPrincipal | ||
| 199 | + Federation->>OAuth: complete_authorization(request_id, principal) | ||
| 200 | + OAuth-->>Browser: 303 redirect to /docs/oauth2-redirect?code=...&state=... | ||
| 201 | + Browser->>OAuth: POST /oauth/token<br/>code + redirect_uri + PKCE verifier | ||
| 202 | + OAuth-->>Browser: Bearer access token | ||
| 203 | + Browser->>API: Authorization: Bearer token | ||
| 204 | + API->>OAuth: validate_access_token(token) | ||
| 205 | + OAuth-->>API: LocalPrincipal payload | ||
| 206 | + API-->>Browser: protected API response | ||
| 207 | +``` | ||
| 208 | + | ||
| 209 | +`authorization_request_id` 是本地 OAuth2 请求与外部身份回调之间的关联键。真实 SAML | ||
| 210 | +实现还应维护 SAML `AuthnRequest ID` 与 `InResponseTo` 的一次性关联,不能只依赖浏览器 | ||
| 211 | +提交的本地字段。 | ||
| 212 | + | ||
| 213 | +### 4.2 本地登录 | ||
| 214 | + | ||
| 215 | +本地账号不经过 `FederatedAuthModule`: | ||
| 216 | + | ||
| 217 | +```text | ||
| 218 | +/oauth/authorize | ||
| 219 | + -> /auth/local/login | ||
| 220 | + -> LocalPrincipal(local-demo-user) | ||
| 221 | + -> authorization code | ||
| 222 | + -> /oauth/token | ||
| 223 | + -> bearer token | ||
| 224 | +``` | ||
| 225 | + | ||
| 226 | +本地和联合登录的差异只发生在“如何得到 LocalPrincipal”这一段。Principal 进入 | ||
| 227 | +OAuth2 之后,换码、Token 校验和受保护 API 的行为完全一致。 | ||
| 228 | + | ||
| 229 | +## 5. HTTP 接口 | ||
| 230 | + | ||
| 231 | +| 方法与路径 | 是否面向浏览器 | 说明 | | ||
| 232 | +| --- | --- | --- | | ||
| 233 | +| `GET /oauth/authorize` | 是 | 创建 OAuth2 授权请求并显示统一登录选择页 | | ||
| 234 | +| `POST /auth/local/login` | 是 | 校验示例本地账号并完成授权 | | ||
| 235 | +| `POST /oauth/token` | OAuth2 客户端 | 使用一次性 Authorization Code 和 PKCE verifier 换取 Token | | ||
| 236 | +| `GET /auth/federation/{connection_id}/login` | 是 | 校验连接和授权请求,然后跳转至对应企业 IdP | | ||
| 237 | +| `POST /auth/federation/{connection_id}/callback` | IdP/浏览器 | 交给 Provider 验证回调,映射本地身份并完成 OAuth2 授权 | | ||
| 238 | +| `GET /demo-enterprise-idp/login` | 仅本地演示 | 显示模拟企业用户表单;不验证 SAML | | ||
| 239 | + | ||
| 240 | +受保护的业务接口不是本模块直接注册的。`OAuth2AccessControl` 会调用 | ||
| 241 | +`ExampleOAuth2AuthorizationServer.validate_access_token()`,再把 Principal 放入 | ||
| 242 | +Service Framework 的 `RequestContext`。 | ||
| 243 | + | ||
| 244 | +## 6. 身份解析与 Just-In-Time Provisioning | ||
| 245 | + | ||
| 246 | +`resolve_or_create()` 实现按首次登录创建本地虚拟身份(Just-In-Time | ||
| 247 | +Provisioning,JIT): | ||
| 248 | + | ||
| 249 | +1. 校验 `ExternalIdentity.connection_id` 与当前连接一致; | ||
| 250 | +2. 校验 `ExternalIdentity.issuer` 与可信连接配置一致; | ||
| 251 | +3. 读取 `(connection_id, issuer, external_subject)`; | ||
| 252 | +4. 若映射不存在: | ||
| 253 | + - 创建或更新虚拟组织的展示信息; | ||
| 254 | + - 创建新的本地 `user_id`; | ||
| 255 | + - 建立外部身份到本地用户的映射; | ||
| 256 | + - 建立本地组织成员关系并赋予 `default_role`; | ||
| 257 | +5. 若映射已存在: | ||
| 258 | + - 保持原本的本地 `user_id`; | ||
| 259 | + - 更新展示名、邮箱、外部属性和最近登录时间; | ||
| 260 | +6. 返回 `LocalPrincipal`。 | ||
| 261 | + | ||
| 262 | +这保证同一个外部主体重复登录时,本地 `user_id` 稳定。以下改变会产生不同的外部 | ||
| 263 | +身份键: | ||
| 264 | + | ||
| 265 | +- 使用另一个 `connection_id`; | ||
| 266 | +- 使用另一个 `issuer`; | ||
| 267 | +- IdP 返回另一个 `external_subject`。 | ||
| 268 | + | ||
| 269 | +如果企业身份发生合并、拆分或 Subject 迁移,需要显式的管理流程和审计记录,不能通过 | ||
| 270 | +修改展示名自动合并。 | ||
| 271 | + | ||
| 272 | +## 7. Store 接口与实现 | ||
| 273 | + | ||
| 274 | +### 7.1 抽象接口 | ||
| 275 | + | ||
| 276 | +```python | ||
| 277 | +class FederatedIdentityStore(ABC): | ||
| 278 | + async def resolve_or_create( | ||
| 279 | + self, | ||
| 280 | + connection: FederationConnection, | ||
| 281 | + identity: ExternalIdentity, | ||
| 282 | + ) -> LocalPrincipal: | ||
| 283 | + ... | ||
| 284 | + | ||
| 285 | + async def find( | ||
| 286 | + self, | ||
| 287 | + *, | ||
| 288 | + connection_id: str, | ||
| 289 | + issuer: str, | ||
| 290 | + external_subject: str, | ||
| 291 | + ) -> LocalPrincipal | None: | ||
| 292 | + ... | ||
| 293 | + | ||
| 294 | + async def close(self) -> None: | ||
| 295 | + ... | ||
| 296 | +``` | ||
| 297 | + | ||
| 298 | +所有方法均为异步方法。生产实现执行数据库 I/O 时必须使用真正的异步驱动,或在受控 | ||
| 299 | +线程池中隔离阻塞驱动;不能在事件循环中直接执行同步数据库操作。 | ||
| 300 | + | ||
| 301 | +### 7.2 当前两种实现 | ||
| 302 | + | ||
| 303 | +| 实现 | 存储 | 使用场景 | 生命周期 | | ||
| 304 | +| --- | --- | --- | --- | | ||
| 305 | +| `InMemoryFederatedIdentityStore` | Python 字典 | 单元测试和纯逻辑验证 | 进程退出即丢失;`close()` 无操作 | | ||
| 306 | +| `DatabaseFederatedIdentityStore` | 单个 SQLite 文件 | 本地可运行示例和 SQLite 集成测试 | 每次操作打开连接并关闭;`close()` 无操作 | | ||
| 307 | + | ||
| 308 | +SQLite 实现接收**文件路径**,不是数据库 URL: | ||
| 309 | + | ||
| 310 | +```python | ||
| 311 | +from pathlib import Path | ||
| 312 | + | ||
| 313 | +from federated_auth import DatabaseFederatedIdentityStore | ||
| 314 | + | ||
| 315 | +store = DatabaseFederatedIdentityStore( | ||
| 316 | + Path("examples/federated_auth/.data/federated_auth.db") | ||
| 317 | +) | ||
| 318 | +``` | ||
| 319 | + | ||
| 320 | +应用也支持通过 `FEDERATED_AUTH_DATABASE_PATH` 指定路径: | ||
| 321 | + | ||
| 322 | +```bash | ||
| 323 | +FEDERATED_AUTH_DATABASE_PATH=/var/lib/example/federated-auth.db \ | ||
| 324 | +uv run python examples/multi_handler_app.py | ||
| 325 | +``` | ||
| 326 | + | ||
| 327 | +## 8. SQLite 数据模型 | ||
| 328 | + | ||
| 329 | +```mermaid | ||
| 330 | +erDiagram | ||
| 331 | + VIRTUAL_ORGANIZATIONS ||--o{ FEDERATION_CONNECTIONS : "binds" | ||
| 332 | + VIRTUAL_ORGANIZATIONS ||--o{ ORGANIZATION_MEMBERSHIPS : "contains" | ||
| 333 | + VIRTUAL_USERS ||--o{ ORGANIZATION_MEMBERSHIPS : "joins" | ||
| 334 | + FEDERATION_CONNECTIONS ||--o{ FEDERATED_IDENTITIES : "issues identities" | ||
| 335 | + VIRTUAL_USERS ||--o{ FEDERATED_IDENTITIES : "is mapped from" | ||
| 336 | + | ||
| 337 | + VIRTUAL_ORGANIZATIONS { | ||
| 338 | + text organization_id PK | ||
| 339 | + text organization_name | ||
| 340 | + text created_at | ||
| 341 | + } | ||
| 342 | + FEDERATION_CONNECTIONS { | ||
| 343 | + text connection_id PK | ||
| 344 | + text issuer | ||
| 345 | + text organization_id FK | ||
| 346 | + text default_role | ||
| 347 | + text created_at | ||
| 348 | + } | ||
| 349 | + VIRTUAL_USERS { | ||
| 350 | + text user_id PK | ||
| 351 | + text display_name | ||
| 352 | + text email | ||
| 353 | + text created_at | ||
| 354 | + text updated_at | ||
| 355 | + } | ||
| 356 | + FEDERATED_IDENTITIES { | ||
| 357 | + text connection_id PK,FK | ||
| 358 | + text issuer PK | ||
| 359 | + text external_subject PK | ||
| 360 | + text local_user_id FK | ||
| 361 | + text attributes_json | ||
| 362 | + text first_login_at | ||
| 363 | + text last_login_at | ||
| 364 | + } | ||
| 365 | + ORGANIZATION_MEMBERSHIPS { | ||
| 366 | + text organization_id PK,FK | ||
| 367 | + text local_user_id PK,FK | ||
| 368 | + text role | ||
| 369 | + text created_at | ||
| 370 | + } | ||
| 371 | +``` | ||
| 372 | + | ||
| 373 | +表的职责: | ||
| 374 | + | ||
| 375 | +| 表 | 职责 | | ||
| 376 | +| --- | --- | | ||
| 377 | +| `virtual_organizations` | 保存本地虚拟组织和展示名称 | | ||
| 378 | +| `federation_connections` | 保存连接到 issuer、本地组织和默认角色的固定绑定 | | ||
| 379 | +| `virtual_users` | 保存应用内部稳定用户 ID 和可更新的基础资料 | | ||
| 380 | +| `federated_identities` | 保存外部身份复合键、本地用户映射、属性快照及登录时间 | | ||
| 381 | +| `organization_memberships` | 保存本地用户在虚拟组织中的角色 | | ||
| 382 | + | ||
| 383 | +实现细节: | ||
| 384 | + | ||
| 385 | +- 启用 `PRAGMA foreign_keys = ON`; | ||
| 386 | +- 使用 WAL journal mode; | ||
| 387 | +- 首次写入使用 `BEGIN IMMEDIATE`; | ||
| 388 | +- 进程内使用 `asyncio.Lock` 串行化首次创建; | ||
| 389 | +- 外部身份复合主键和事务共同保证同一身份不会创建多个本地用户; | ||
| 390 | +- SQLite schema 在第一次 Store 操作时自动创建; | ||
| 391 | +- 每次读写使用独立 `aiosqlite` 连接,操作完成后关闭。 | ||
| 392 | + | ||
| 393 | +该自动建表方式适合示例。生产环境应使用正式 schema migration、数据库权限、备份、 | ||
| 394 | +审计、容量规划和多副本并发策略。 | ||
| 395 | + | ||
| 396 | +## 9. 在应用中组装 | ||
| 397 | + | ||
| 398 | +`multi_handler_app.py` 的组装顺序如下: | ||
| 399 | + | ||
| 400 | +```python | ||
| 401 | +from pathlib import Path | ||
| 402 | + | ||
| 403 | +from federated_auth import ( | ||
| 404 | + DatabaseFederatedIdentityStore, | ||
| 405 | + DemoEnterpriseIdentityProvider, | ||
| 406 | + DemoFederationProvider, | ||
| 407 | + ExampleOAuth2AuthorizationServer, | ||
| 408 | + FederatedAuthModule, | ||
| 409 | + FederationConnection, | ||
| 410 | +) | ||
| 411 | +from openjiuwen_runtime.service import App, OAuth2AccessControl, SystemContext | ||
| 412 | + | ||
| 413 | + | ||
| 414 | +connection = FederationConnection( | ||
| 415 | + connection_id="enterprise-demo", | ||
| 416 | + issuer="https://idp.enterprise-demo.example", | ||
| 417 | + organization_id="virtual-org-enterprise-demo", | ||
| 418 | + organization_name="Enterprise Demo SSO", | ||
| 419 | +) | ||
| 420 | +connections = {connection.connection_id: connection} | ||
| 421 | + | ||
| 422 | +identity_store = DatabaseFederatedIdentityStore( | ||
| 423 | + Path("examples/federated_auth/.data/federated_auth.db") | ||
| 424 | +) | ||
| 425 | +oauth2_server = ExampleOAuth2AuthorizationServer() | ||
| 426 | + | ||
| 427 | +oauth2 = OAuth2AccessControl( | ||
| 428 | + token_url="/oauth/token", | ||
| 429 | + authorization_url="/oauth/authorize", | ||
| 430 | + token_validator=oauth2_server.validate_access_token, | ||
| 431 | + scheme_name="OAuth2AuthorizationCode", | ||
| 432 | +) | ||
| 433 | + | ||
| 434 | +app = App( | ||
| 435 | + lambda: SystemContext(), | ||
| 436 | + enable_ws=False, | ||
| 437 | + oauth2=oauth2, | ||
| 438 | +) | ||
| 439 | + | ||
| 440 | +app.asgi.swagger_ui_init_oauth = { | ||
| 441 | + "clientId": oauth2_server.client_id, | ||
| 442 | + "usePkceWithAuthorizationCodeGrant": True, | ||
| 443 | +} | ||
| 444 | + | ||
| 445 | +oauth2_server.mount(app.asgi, connections.values()) | ||
| 446 | + | ||
| 447 | +FederatedAuthModule( | ||
| 448 | + provider=DemoFederationProvider(), | ||
| 449 | + identity_store=identity_store, | ||
| 450 | + oauth2_server=oauth2_server, | ||
| 451 | + connections=connections, | ||
| 452 | +).mount(app.asgi) | ||
| 453 | + | ||
| 454 | +DemoEnterpriseIdentityProvider().mount(app.asgi) | ||
| 455 | +``` | ||
| 456 | + | ||
| 457 | +组装中只有 `OAuth2AccessControl` 属于通用 Service Framework。Token 签发、用户映射 | ||
| 458 | +和企业协议实现都属于应用层示例。 | ||
| 459 | + | ||
| 460 | +## 10. OAuth2 示例实现的行为 | ||
| 461 | + | ||
| 462 | +`ExampleOAuth2AuthorizationServer` 当前实现: | ||
| 463 | + | ||
| 464 | +- 单一公开客户端 `swagger-docs`; | ||
| 465 | +- Authorization Code grant; | ||
| 466 | +- Swagger 配置为 PKCE S256; | ||
| 467 | +- 收到 PKCE challenge 时,在换码阶段校验 verifier; | ||
| 468 | +- 授权请求默认有效期 300 秒; | ||
| 469 | +- Authorization Code 默认有效期 300 秒且只能使用一次; | ||
| 470 | +- Access Token 默认有效期 3600 秒; | ||
| 471 | +- Token 验证为异步方法; | ||
| 472 | +- 保留并原样返回 OAuth2 `state`; | ||
| 473 | +- 仅接受 path 为 `/docs/oauth2-redirect` 的 HTTP/HTTPS redirect URI。 | ||
| 474 | + | ||
| 475 | +必须理解的限制: | ||
| 476 | + | ||
| 477 | +- 授权请求、Authorization Code 和 Access Token 都保存在当前 Python 进程内; | ||
| 478 | +- 服务重启后所有已签发 Token 失效; | ||
| 479 | +- 多副本之间不共享授权状态; | ||
| 480 | +- 当前服务端允许未携带 PKCE challenge 的请求,只有 Swagger 客户端明确启用了 PKCE; | ||
| 481 | +- redirect URI 当前只校验协议、非空 host 和固定 path,不是生产级客户端 URI 白名单; | ||
| 482 | +- 没有 Refresh Token、Scope、客户端密钥、Token 撤销和注销协议。 | ||
| 483 | + | ||
| 484 | +因此该 OAuth2 Server 只能用于解释和测试通信链路。生产环境应接入正式认证中心,并让 | ||
| 485 | +`OAuth2AccessControl` 使用认证中心提供的异步 Token 校验实现。 | ||
| 486 | + | ||
| 487 | +## 11. 实现真实 SAML Provider | ||
| 488 | + | ||
| 489 | +真实 SAML 支持应通过新增 `FederationProvider` 实现完成,不应把 SAML 解析逻辑写进 | ||
| 490 | +`FederatedAuthModule`、Identity Store 或业务 Handler。 | ||
| 491 | + | ||
| 492 | +基本结构: | ||
| 493 | + | ||
| 494 | +```python | ||
| 495 | +from collections.abc import Mapping | ||
| 496 | + | ||
| 497 | +from federated_auth.domain import ExternalIdentity, FederationConnection | ||
| 498 | +from federated_auth.provider import ( | ||
| 499 | + FederationAuthenticationResult, | ||
| 500 | + FederationProvider, | ||
| 501 | +) | ||
| 502 | + | ||
| 503 | + | ||
| 504 | +class SamlFederationProvider(FederationProvider): | ||
| 505 | + async def begin_login( | ||
| 506 | + self, | ||
| 507 | + connection: FederationConnection, | ||
| 508 | + authorization_request_id: str, | ||
| 509 | + ) -> str: | ||
| 510 | + # 1. 创建 AuthnRequest 和不可预测的 request ID | ||
| 511 | + # 2. 保存 request ID 与 authorization_request_id 的短期关联 | ||
| 512 | + # 3. 根据配置签名并构造 Redirect 或 POST Binding | ||
| 513 | + # 4. 返回企业 IdP SSO URL | ||
| 514 | + ... | ||
| 515 | + | ||
| 516 | + async def consume_callback( | ||
| 517 | + self, | ||
| 518 | + connection: FederationConnection, | ||
| 519 | + form: Mapping[str, str], | ||
| 520 | + ) -> FederationAuthenticationResult: | ||
| 521 | + # 1. 读取并安全解析 SAMLResponse | ||
| 522 | + # 2. 验证签名、证书和 XML 安全边界 | ||
| 523 | + # 3. 验证 issuer、audience、destination 和 recipient | ||
| 524 | + # 4. 验证 InResponseTo、时间窗口和一次性消费 | ||
| 525 | + # 5. 提取稳定 Subject 和已验证属性 | ||
| 526 | + # 6. 返回标准化 ExternalIdentity | ||
| 527 | + ... | ||
| 528 | +``` | ||
| 529 | + | ||
| 530 | +生产 SAML Provider 至少必须处理: | ||
| 531 | + | ||
| 532 | +- IdP metadata 与签名证书的可信配置、更新和轮换; | ||
| 533 | +- XML Signature 验证以及 XML Signature Wrapping 防护; | ||
| 534 | +- `Issuer` 与 `FederationConnection.issuer` 精确匹配; | ||
| 535 | +- `AudienceRestriction`; | ||
| 536 | +- `Destination`、Subject Confirmation `Recipient`; | ||
| 537 | +- `InResponseTo` 与原始 AuthnRequest 的关联; | ||
| 538 | +- `NotBefore`、`NotOnOrAfter` 和有限时钟偏差; | ||
| 539 | +- Response/Assertion ID 防重放; | ||
| 540 | +- 成功状态码、已认证 Assertion 和 NameID/Subject 提取; | ||
| 541 | +- RelayState 或本地关联状态的完整性; | ||
| 542 | +- 属性白名单、必填属性和数据最小化; | ||
| 543 | +- 异步或非阻塞的 metadata、密钥和关联状态访问。 | ||
| 544 | + | ||
| 545 | +只有在以上验证全部成功后,Provider 才能创建 `ExternalIdentity`。生产实现绝不能像 | ||
| 546 | +`DemoFederationProvider` 一样直接相信浏览器提交的 `employee_id`、展示名或邮箱。 | ||
| 547 | + | ||
| 548 | +Provider 返回标准化身份后,现有 `FederatedAuthModule`、Identity Store、OAuth2 完成 | ||
| 549 | +流程和业务 Handler 无需感知 SAML XML。 | ||
| 550 | + | ||
| 551 | +## 12. 安全边界 | ||
| 552 | + | ||
| 553 | +### 12.1 当前已经提供的保护 | ||
| 554 | + | ||
| 555 | +- Provider 输出的 `connection_id` 和 `issuer` 必须匹配可信连接; | ||
| 556 | +- 已使用的 `connection_id` 不能静默绑定到不同 issuer 或组织; | ||
| 557 | +- OAuth2 Authorization Code 一次性使用并有过期时间; | ||
| 558 | +- Swagger 使用 PKCE S256; | ||
| 559 | +- Token 无效或过期时受保护 API 返回 `401`; | ||
| 560 | +- SQLite 使用外键和事务维护映射完整性; | ||
| 561 | +- 演示 IdP 页面明确说明没有执行 SAML 验证。 | ||
| 562 | + | ||
| 563 | +### 12.2 生产环境需要补充的保护 | ||
| 564 | + | ||
| 565 | +- 使用真实、严格验证的 SAML/OIDC Provider; | ||
| 566 | +- 强制 PKCE,并按客户端注册精确校验 redirect URI; | ||
| 567 | +- 使用正式认证中心管理客户端、Token、Scope、撤销和注销; | ||
| 568 | +- 将授权请求、重放记录和必要状态放入具备 TTL 和原子操作的共享存储; | ||
| 569 | +- 使用 TLS、安全 Cookie、CSRF 防护及适当的 CSP; | ||
| 570 | +- 对登录、连接配置、身份绑定和角色变更写审计日志; | ||
| 571 | +- 对数据库文件、备份和个人信息实施访问控制和保留策略; | ||
| 572 | +- 设计外部身份禁用、离职、组织解绑和本地权限回收流程; | ||
| 573 | +- 避免把敏感 SAML Assertion、密码或 Token 写入日志。 | ||
| 574 | + | ||
| 575 | +## 13. 测试策略 | ||
| 576 | + | ||
| 577 | +### 13.1 单元测试 | ||
| 578 | + | ||
| 579 | +单元测试使用 `InMemoryFederatedIdentityStore`,不创建本地数据库: | ||
| 580 | + | ||
| 581 | +```bash | ||
| 582 | +uv run pytest -q \ | ||
| 583 | + tests/unit_tests/test_federated_identity_store.py \ | ||
| 584 | + tests/unit_tests/test_federated_oauth2.py | ||
| 585 | +``` | ||
| 586 | + | ||
| 587 | +覆盖内容包括: | ||
| 588 | + | ||
| 589 | +- 外部身份首次创建和稳定复用; | ||
| 590 | +- issuer、connection 绑定校验; | ||
| 591 | +- Provider 到 LocalPrincipal 的标准化; | ||
| 592 | +- Authorization Code、PKCE 和一次性消费; | ||
| 593 | +- 非法 redirect URI 拒绝。 | ||
| 594 | + | ||
| 595 | +### 13.2 SQLite 集成测试 | ||
| 596 | + | ||
| 597 | +```bash | ||
| 598 | +uv run pytest -q tests/system_tests/test_federated_identity_sqlite.py | ||
| 599 | +``` | ||
| 600 | + | ||
| 601 | +覆盖内容包括: | ||
| 602 | + | ||
| 603 | +- Store 关闭并重新创建后身份仍可读取; | ||
| 604 | +- 同一身份并发首次登录只生成一个本地用户; | ||
| 605 | +- 已有 `connection_id` 不能重新绑定到另一组织。 | ||
| 606 | + | ||
| 607 | +### 13.3 完整系统测试 | ||
| 608 | + | ||
| 609 | +```bash | ||
| 610 | +uv run pytest -q tests/system_tests/test_multi_handler_example.py | ||
| 611 | +``` | ||
| 612 | + | ||
| 613 | +该测试从 OAuth2 授权请求开始,覆盖本地登录、Enterprise Demo SSO、Token 交换、受保护 | ||
| 614 | +Handler、SSE、OpenAPI schema、错误响应和 SQLite 本地身份稳定性。 | ||
| 615 | + | ||
| 616 | +## 14. 常见问题 | ||
| 617 | + | ||
| 618 | +### 为什么企业登录后还要签发 OAuth2 Token? | ||
| 619 | + | ||
| 620 | +企业协议证明用户在外部 IdP 的身份,OAuth2 Token 则是应用内部 API 的访问凭据。这样 | ||
| 621 | +业务 Handler 无需理解 SAML,并且本地账号与联合账号可以使用同一套访问控制。 | ||
| 622 | + | ||
| 623 | +### 虚拟用户是否等于复制企业用户? | ||
| 624 | + | ||
| 625 | +不是。虚拟用户是外部身份在本系统中的最小本地映射,用于获得稳定 `user_id`、组织成员 | ||
| 626 | +关系和本地授权。企业凭据仍由企业 IdP 管理。 | ||
| 627 | + | ||
| 628 | +### 企业组织是否等于 Runtime group? | ||
| 629 | + | ||
| 630 | +不是。`organization_id` 是身份/租户边界;Runtime `group_id` 是业务或运行时资源概念。 | ||
| 631 | +二者若需要关联,应由独立、显式、可审计的授权配置完成。 | ||
| 632 | + | ||
| 633 | +### 为什么单元测试不用 SQLite? | ||
| 634 | + | ||
| 635 | +Store 接口的领域行为可以通过内存实现快速、确定地验证;SQLite 持久化、事务和并发行为 | ||
| 636 | +由独立集成测试覆盖。这样测试职责清晰,也不把数据库细节泄漏到 Provider 或 OAuth2 | ||
| 637 | +逻辑中。 | ||
| 638 | + | ||
| 639 | +### 能否把 SQLite 路径改成数据库 URL? | ||
| 640 | + | ||
| 641 | +当前实现明确只支持 SQLite 文件路径,没有通用数据库 URL 抽象。若未来增加 MySQL 或 | ||
| 642 | +PostgreSQL,应新增明确的 Store 实现及迁移方案,而不是让 SQLite 示例类根据字符串 | ||
| 643 | +隐式切换不同数据库。 | ||
| 644 | + | ||
| 645 | +### 真实 SAML 接入后哪些模块保持不变? | ||
| 646 | + | ||
| 647 | +通常可以保持 `FederatedAuthModule`、`FederatedIdentityStore`、内部 OAuth2 接入以及 | ||
| 648 | +业务 Handler 不变,替换 `DemoFederationProvider` 和演示 IdP,并根据生产部署替换 | ||
| 649 | +Identity Store 与 OAuth2 Server。 | ||
| @@ -0,0 +1,26 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Reusable identity components for the federated-auth example.""" | ||
| 5 | + | ||
| 6 | +from .database_identity_store import DatabaseFederatedIdentityStore | ||
| 7 | +from .demo_idp import DemoEnterpriseIdentityProvider | ||
| 8 | +from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | ||
| 9 | +from .identity_store import FederatedIdentityStore, InMemoryFederatedIdentityStore | ||
| 10 | +from .module import FederatedAuthModule | ||
| 11 | +from .oauth2_server import ExampleOAuth2AuthorizationServer | ||
| 12 | +from .provider import DemoFederationProvider, FederationProvider | ||
| 13 | + | ||
| 14 | +__all__ = [ | ||
| 15 | + "DatabaseFederatedIdentityStore", | ||
| 16 | + "DemoEnterpriseIdentityProvider", | ||
| 17 | + "DemoFederationProvider", | ||
| 18 | + "ExampleOAuth2AuthorizationServer", | ||
| 19 | + "ExternalIdentity", | ||
| 20 | + "FederatedIdentityStore", | ||
| 21 | + "FederatedAuthModule", | ||
| 22 | + "FederationProvider", | ||
| 23 | + "FederationConnection", | ||
| 24 | + "InMemoryFederatedIdentityStore", | ||
| 25 | + "LocalPrincipal", | ||
| 26 | +] | ||
| @@ -0,0 +1,384 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""SQLite-backed identity store for the runnable federated-auth example.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import json | ||
| 10 | +from datetime import UTC, datetime | ||
| 11 | +from pathlib import Path | ||
| 12 | +from uuid import uuid4 | ||
| 13 | + | ||
| 14 | +import aiosqlite | ||
| 15 | + | ||
| 16 | +from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | ||
| 17 | +from .identity_store import FederatedIdentityStore | ||
| 18 | + | ||
| 19 | +_SCHEMA = """ | ||
| 20 | +CREATE TABLE IF NOT EXISTS virtual_organizations ( | ||
| 21 | + organization_id TEXT PRIMARY KEY, | ||
| 22 | + organization_name TEXT NOT NULL, | ||
| 23 | + created_at TEXT NOT NULL | ||
| 24 | +); | ||
| 25 | + | ||
| 26 | +CREATE TABLE IF NOT EXISTS federation_connections ( | ||
| 27 | + connection_id TEXT PRIMARY KEY, | ||
| 28 | + issuer TEXT NOT NULL, | ||
| 29 | + organization_id TEXT NOT NULL, | ||
| 30 | + default_role TEXT NOT NULL, | ||
| 31 | + created_at TEXT NOT NULL, | ||
| 32 | + FOREIGN KEY (organization_id) | ||
| 33 | + REFERENCES virtual_organizations(organization_id) | ||
| 34 | +); | ||
| 35 | + | ||
| 36 | +CREATE TABLE IF NOT EXISTS virtual_users ( | ||
| 37 | + user_id TEXT PRIMARY KEY, | ||
| 38 | + display_name TEXT NOT NULL, | ||
| 39 | + email TEXT, | ||
| 40 | + created_at TEXT NOT NULL, | ||
| 41 | + updated_at TEXT NOT NULL | ||
| 42 | +); | ||
| 43 | + | ||
| 44 | +CREATE TABLE IF NOT EXISTS federated_identities ( | ||
| 45 | + connection_id TEXT NOT NULL, | ||
| 46 | + issuer TEXT NOT NULL, | ||
| 47 | + external_subject TEXT NOT NULL, | ||
| 48 | + local_user_id TEXT NOT NULL, | ||
| 49 | + attributes_json TEXT NOT NULL, | ||
| 50 | + first_login_at TEXT NOT NULL, | ||
| 51 | + last_login_at TEXT NOT NULL, | ||
| 52 | + PRIMARY KEY (connection_id, issuer, external_subject), | ||
| 53 | + FOREIGN KEY (connection_id) | ||
| 54 | + REFERENCES federation_connections(connection_id), | ||
| 55 | + FOREIGN KEY (local_user_id) | ||
| 56 | + REFERENCES virtual_users(user_id) | ||
| 57 | +); | ||
| 58 | + | ||
| 59 | +CREATE TABLE IF NOT EXISTS organization_memberships ( | ||
| 60 | + organization_id TEXT NOT NULL, | ||
| 61 | + local_user_id TEXT NOT NULL, | ||
| 62 | + role TEXT NOT NULL, | ||
| 63 | + created_at TEXT NOT NULL, | ||
| 64 | + PRIMARY KEY (organization_id, local_user_id), | ||
| 65 | + FOREIGN KEY (organization_id) | ||
| 66 | + REFERENCES virtual_organizations(organization_id), | ||
| 67 | + FOREIGN KEY (local_user_id) | ||
| 68 | + REFERENCES virtual_users(user_id) | ||
| 69 | +); | ||
| 70 | +""" | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +class DatabaseFederatedIdentityStore(FederatedIdentityStore): | ||
| 74 | + """Persist shadow organizations and users in one SQLite database file.""" | ||
| 75 | + | ||
| 76 | + def __init__(self, database_path: str | Path) -> None: | ||
| 77 | + self._database_path = Path(database_path) | ||
| 78 | + self._initialized = False | ||
| 79 | + self._initialize_lock = asyncio.Lock() | ||
| 80 | + self._write_lock = asyncio.Lock() | ||
| 81 | + | ||
| 82 | + async def initialize(self) -> None: | ||
| 83 | + """Open the SQLite file and initialize its example schema once.""" | ||
| 84 | + if self._initialized: | ||
| 85 | + return | ||
| 86 | + async with self._initialize_lock: | ||
| 87 | + if self._initialized: | ||
| 88 | + return | ||
| 89 | + self._database_path.parent.mkdir(parents=True, exist_ok=True) | ||
| 90 | + database = await aiosqlite.connect(self._database_path) | ||
| 91 | + try: | ||
| 92 | + await _configure_database(database) | ||
| 93 | + await database.executescript(_SCHEMA) | ||
| 94 | + await database.commit() | ||
| 95 | + self._initialized = True | ||
| 96 | + finally: | ||
| 97 | + await database.close() | ||
| 98 | + | ||
| 99 | + async def resolve_or_create( | ||
| 100 | + self, | ||
| 101 | + connection: FederationConnection, | ||
| 102 | + identity: ExternalIdentity, | ||
| 103 | + ) -> LocalPrincipal: | ||
| 104 | + self.validate_binding(connection, identity) | ||
| 105 | + database = await self._open_database() | ||
| 106 | + | ||
| 107 | + try: | ||
| 108 | + async with self._write_lock: | ||
| 109 | + await database.execute("BEGIN IMMEDIATE") | ||
| 110 | + await self._ensure_connection(database, connection) | ||
| 111 | + principal = await self._resolve_or_create_user( | ||
| 112 | + database, | ||
| 113 | + connection, | ||
| 114 | + identity, | ||
| 115 | + ) | ||
| 116 | + await database.commit() | ||
| 117 | + return principal | ||
| 118 | + except Exception: | ||
| 119 | + if database.in_transaction: | ||
| 120 | + await database.rollback() | ||
| 121 | + raise | ||
| 122 | + finally: | ||
| 123 | + await database.close() | ||
| 124 | + | ||
| 125 | + async def find( | ||
| 126 | + self, | ||
| 127 | + *, | ||
| 128 | + connection_id: str, | ||
| 129 | + issuer: str, | ||
| 130 | + external_subject: str, | ||
| 131 | + ) -> LocalPrincipal | None: | ||
| 132 | + database = await self._open_database() | ||
| 133 | + try: | ||
| 134 | + cursor = await database.execute( | ||
| 135 | + """ | ||
| 136 | + SELECT | ||
| 137 | + u.user_id, | ||
| 138 | + c.organization_id, | ||
| 139 | + u.display_name, | ||
| 140 | + u.email, | ||
| 141 | + m.role | ||
| 142 | + FROM federated_identities AS i | ||
| 143 | + JOIN virtual_users AS u | ||
| 144 | + ON u.user_id = i.local_user_id | ||
| 145 | + JOIN federation_connections AS c | ||
| 146 | + ON c.connection_id = i.connection_id | ||
| 147 | + JOIN organization_memberships AS m | ||
| 148 | + ON m.organization_id = c.organization_id | ||
| 149 | + AND m.local_user_id = u.user_id | ||
| 150 | + WHERE i.connection_id = ? | ||
| 151 | + AND i.issuer = ? | ||
| 152 | + AND i.external_subject = ? | ||
| 153 | + """, | ||
| 154 | + (connection_id, issuer, external_subject), | ||
| 155 | + ) | ||
| 156 | + row = await cursor.fetchone() | ||
| 157 | + await cursor.close() | ||
| 158 | + return _principal_from_row(row) if row is not None else None | ||
| 159 | + finally: | ||
| 160 | + await database.close() | ||
| 161 | + | ||
| 162 | + async def close(self) -> None: | ||
| 163 | + """Connections are scoped to operations, so there is nothing to close.""" | ||
| 164 | + | ||
| 165 | + async def _open_database(self) -> aiosqlite.Connection: | ||
| 166 | + await self.initialize() | ||
| 167 | + database = await aiosqlite.connect(self._database_path) | ||
| 168 | + await _configure_database(database) | ||
| 169 | + return database | ||
| 170 | + | ||
| 171 | + async def _ensure_connection( | ||
| 172 | + self, | ||
| 173 | + database: aiosqlite.Connection, | ||
| 174 | + connection: FederationConnection, | ||
| 175 | + ) -> None: | ||
| 176 | + now = _utc_now() | ||
| 177 | + await database.execute( | ||
| 178 | + """ | ||
| 179 | + INSERT INTO virtual_organizations ( | ||
| 180 | + organization_id, | ||
| 181 | + organization_name, | ||
| 182 | + created_at | ||
| 183 | + ) VALUES (?, ?, ?) | ||
| 184 | + ON CONFLICT(organization_id) DO UPDATE SET | ||
| 185 | + organization_name = excluded.organization_name | ||
| 186 | + """, | ||
| 187 | + ( | ||
| 188 | + connection.organization_id, | ||
| 189 | + connection.organization_name, | ||
| 190 | + now, | ||
| 191 | + ), | ||
| 192 | + ) | ||
| 193 | + | ||
| 194 | + cursor = await database.execute( | ||
| 195 | + """ | ||
| 196 | + SELECT issuer, organization_id, default_role | ||
| 197 | + FROM federation_connections | ||
| 198 | + WHERE connection_id = ? | ||
| 199 | + """, | ||
| 200 | + (connection.connection_id,), | ||
| 201 | + ) | ||
| 202 | + existing = await cursor.fetchone() | ||
| 203 | + await cursor.close() | ||
| 204 | + if existing is None: | ||
| 205 | + await database.execute( | ||
| 206 | + """ | ||
| 207 | + INSERT INTO federation_connections ( | ||
| 208 | + connection_id, | ||
| 209 | + issuer, | ||
| 210 | + organization_id, | ||
| 211 | + default_role, | ||
| 212 | + created_at | ||
| 213 | + ) VALUES (?, ?, ?, ?, ?) | ||
| 214 | + """, | ||
| 215 | + ( | ||
| 216 | + connection.connection_id, | ||
| 217 | + connection.issuer, | ||
| 218 | + connection.organization_id, | ||
| 219 | + connection.default_role, | ||
| 220 | + now, | ||
| 221 | + ), | ||
| 222 | + ) | ||
| 223 | + return | ||
| 224 | + | ||
| 225 | + settings = ( | ||
| 226 | + existing["issuer"], | ||
| 227 | + existing["organization_id"], | ||
| 228 | + existing["default_role"], | ||
| 229 | + ) | ||
| 230 | + expected = ( | ||
| 231 | + connection.issuer, | ||
| 232 | + connection.organization_id, | ||
| 233 | + connection.default_role, | ||
| 234 | + ) | ||
| 235 | + if settings != expected: | ||
| 236 | + raise ValueError("connection_id is already bound to different settings") | ||
| 237 | + | ||
| 238 | + async def _resolve_or_create_user( | ||
| 239 | + self, | ||
| 240 | + database: aiosqlite.Connection, | ||
| 241 | + connection: FederationConnection, | ||
| 242 | + identity: ExternalIdentity, | ||
| 243 | + ) -> LocalPrincipal: | ||
| 244 | + cursor = await database.execute( | ||
| 245 | + """ | ||
| 246 | + SELECT local_user_id | ||
| 247 | + FROM federated_identities | ||
| 248 | + WHERE connection_id = ? | ||
| 249 | + AND issuer = ? | ||
| 250 | + AND external_subject = ? | ||
| 251 | + """, | ||
| 252 | + ( | ||
| 253 | + identity.connection_id, | ||
| 254 | + identity.issuer, | ||
| 255 | + identity.external_subject, | ||
| 256 | + ), | ||
| 257 | + ) | ||
| 258 | + row = await cursor.fetchone() | ||
| 259 | + await cursor.close() | ||
| 260 | + | ||
| 261 | + now = _utc_now() | ||
| 262 | + user_id = row["local_user_id"] if row is not None else f"user_{uuid4().hex}" | ||
| 263 | + if row is None: | ||
| 264 | + await database.execute( | ||
| 265 | + """ | ||
| 266 | + INSERT INTO virtual_users ( | ||
| 267 | + user_id, | ||
| 268 | + display_name, | ||
| 269 | + email, | ||
| 270 | + created_at, | ||
| 271 | + updated_at | ||
| 272 | + ) VALUES (?, ?, ?, ?, ?) | ||
| 273 | + """, | ||
| 274 | + (user_id, identity.display_name, identity.email, now, now), | ||
| 275 | + ) | ||
| 276 | + await database.execute( | ||
| 277 | + """ | ||
| 278 | + INSERT INTO federated_identities ( | ||
| 279 | + connection_id, | ||
| 280 | + issuer, | ||
| 281 | + external_subject, | ||
| 282 | + local_user_id, | ||
| 283 | + attributes_json, | ||
| 284 | + first_login_at, | ||
| 285 | + last_login_at | ||
| 286 | + ) VALUES (?, ?, ?, ?, ?, ?, ?) | ||
| 287 | + """, | ||
| 288 | + ( | ||
| 289 | + identity.connection_id, | ||
| 290 | + identity.issuer, | ||
| 291 | + identity.external_subject, | ||
| 292 | + user_id, | ||
| 293 | + _attributes_json(identity), | ||
| 294 | + now, | ||
| 295 | + now, | ||
| 296 | + ), | ||
| 297 | + ) | ||
| 298 | + await database.execute( | ||
| 299 | + """ | ||
| 300 | + INSERT INTO organization_memberships ( | ||
| 301 | + organization_id, | ||
| 302 | + local_user_id, | ||
| 303 | + role, | ||
| 304 | + created_at | ||
| 305 | + ) VALUES (?, ?, ?, ?) | ||
| 306 | + """, | ||
| 307 | + ( | ||
| 308 | + connection.organization_id, | ||
| 309 | + user_id, | ||
| 310 | + connection.default_role, | ||
| 311 | + now, | ||
| 312 | + ), | ||
| 313 | + ) | ||
| 314 | + else: | ||
| 315 | + await database.execute( | ||
| 316 | + """ | ||
| 317 | + UPDATE virtual_users | ||
| 318 | + SET display_name = ?, email = ?, updated_at = ? | ||
| 319 | + WHERE user_id = ? | ||
| 320 | + """, | ||
| 321 | + (identity.display_name, identity.email, now, user_id), | ||
| 322 | + ) | ||
| 323 | + await database.execute( | ||
| 324 | + """ | ||
| 325 | + UPDATE federated_identities | ||
| 326 | + SET attributes_json = ?, last_login_at = ? | ||
| 327 | + WHERE connection_id = ? | ||
| 328 | + AND issuer = ? | ||
| 329 | + AND external_subject = ? | ||
| 330 | + """, | ||
| 331 | + ( | ||
| 332 | + _attributes_json(identity), | ||
| 333 | + now, | ||
| 334 | + identity.connection_id, | ||
| 335 | + identity.issuer, | ||
| 336 | + identity.external_subject, | ||
| 337 | + ), | ||
| 338 | + ) | ||
| 339 | + | ||
| 340 | + cursor = await database.execute( | ||
| 341 | + """ | ||
| 342 | + SELECT | ||
| 343 | + u.user_id, | ||
| 344 | + m.organization_id, | ||
| 345 | + u.display_name, | ||
| 346 | + u.email, | ||
| 347 | + m.role | ||
| 348 | + FROM virtual_users AS u | ||
| 349 | + JOIN organization_memberships AS m | ||
| 350 | + ON m.local_user_id = u.user_id | ||
| 351 | + WHERE u.user_id = ? | ||
| 352 | + AND m.organization_id = ? | ||
| 353 | + """, | ||
| 354 | + (user_id, connection.organization_id), | ||
| 355 | + ) | ||
| 356 | + principal_row = await cursor.fetchone() | ||
| 357 | + await cursor.close() | ||
| 358 | + if principal_row is None: # pragma: no cover - protected by schema writes | ||
| 359 | + raise RuntimeError("local principal was not persisted") | ||
| 360 | + return _principal_from_row(principal_row) | ||
| 361 | + | ||
| 362 | + | ||
| 363 | +def _principal_from_row(row: aiosqlite.Row) -> LocalPrincipal: | ||
| 364 | + return LocalPrincipal( | ||
| 365 | + user_id=row["user_id"], | ||
| 366 | + organization_id=row["organization_id"], | ||
| 367 | + display_name=row["display_name"], | ||
| 368 | + email=row["email"], | ||
| 369 | + roles=(row["role"],), | ||
| 370 | + ) | ||
| 371 | + | ||
| 372 | + | ||
| 373 | +def _attributes_json(identity: ExternalIdentity) -> str: | ||
| 374 | + return json.dumps(identity.attributes, ensure_ascii=False, sort_keys=True) | ||
| 375 | + | ||
| 376 | + | ||
| 377 | +def _utc_now() -> str: | ||
| 378 | + return datetime.now(UTC).isoformat() | ||
| 379 | + | ||
| 380 | + | ||
| 381 | +async def _configure_database(database: aiosqlite.Connection) -> None: | ||
| 382 | + database.row_factory = aiosqlite.Row | ||
| 383 | + await database.execute("PRAGMA foreign_keys = ON") | ||
| 384 | + await database.execute("PRAGMA journal_mode = WAL") | ||
| @@ -0,0 +1,46 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Clearly labelled local enterprise IdP simulator for the runnable example.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import html | ||
| 9 | + | ||
| 10 | +from fastapi import FastAPI | ||
| 11 | +from fastapi.responses import HTMLResponse | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class DemoEnterpriseIdentityProvider: | ||
| 15 | + """Render a local form that occupies the upstream SAML IdP adapter slot.""" | ||
| 16 | + | ||
| 17 | + | ||
| 18 | + def mount(fastapi: FastAPI) -> None: | ||
| 19 | + | ||
| 20 | + "/demo-enterprise-idp/login", | ||
| 21 | + response_class=HTMLResponse, | ||
| 22 | + tags=["federation-demo"], | ||
| 23 | + ) | ||
| 24 | + async def login(connection_id: str, authorization_request_id: str): | ||
| 25 | + safe_connection = html.escape(connection_id, quote=True) | ||
| 26 | + safe_request = html.escape(authorization_request_id, quote=True) | ||
| 27 | + return HTMLResponse( | ||
| 28 | + f"""<!doctype html> | ||
| 29 | +<html><head><meta charset="utf-8"><title>Enterprise Demo IdP</title> | ||
| 30 | +<style> | ||
| 31 | +body{{font-family:system-ui;max-width:460px;margin:60px auto;color:#222}} | ||
| 32 | +.card{{border:1px solid #ddd;border-radius:12px;padding:24px}} | ||
| 33 | +label,input,button{{display:block;width:100%;box-sizing:border-box}} | ||
| 34 | +input{{padding:10px;margin:6px 0 14px}}button{{padding:11px}} | ||
| 35 | +.warning{{background:#fff3cd;padding:10px;border-radius:6px;font-size:14px}} | ||
| 36 | +</style></head><body><div class="card"> | ||
| 37 | +<h2>Enterprise Demo IdP</h2> | ||
| 38 | +<p class="warning">Local simulation only. No SAML XML is accepted or verified here.</p> | ||
| 39 | +<form method="post" action="/auth/federation/{safe_connection}/callback"> | ||
| 40 | +<input type="hidden" name="authorization_request_id" value="{safe_request}"> | ||
| 41 | +<label>Employee ID<input name="employee_id" value="employee-10086" required></label> | ||
| 42 | +<label>Display name<input name="display_name" value="Enterprise Alice" required></label> | ||
| 43 | +<label>Email<input name="email" value="alice@enterprise.example"></label> | ||
| 44 | +<button type="submit">Enterprise sign in</button></form> | ||
| 45 | +</div></body></html>""" | ||
| 46 | + ) | ||
| @@ -0,0 +1,46 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Minimal domain objects shared by federation providers and identity stores.""" | ||
| 5 | + | ||
| 6 | +from typing import Any | ||
| 7 | + | ||
| 8 | +from pydantic import BaseModel, ConfigDict, Field | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +class FederationConnection(BaseModel): | ||
| 12 | + """A trusted enterprise identity connection bound to one local organization.""" | ||
| 13 | + | ||
| 14 | + model_config = ConfigDict(frozen=True) | ||
| 15 | + | ||
| 16 | + connection_id: str = Field(min_length=1) | ||
| 17 | + issuer: str = Field(min_length=1) | ||
| 18 | + organization_id: str = Field(min_length=1) | ||
| 19 | + organization_name: str = Field(min_length=1) | ||
| 20 | + default_role: str = Field(default="member", min_length=1) | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +class ExternalIdentity(BaseModel): | ||
| 24 | + """Normalized identity produced after an upstream provider is validated.""" | ||
| 25 | + | ||
| 26 | + model_config = ConfigDict(frozen=True) | ||
| 27 | + | ||
| 28 | + connection_id: str = Field(min_length=1) | ||
| 29 | + issuer: str = Field(min_length=1) | ||
| 30 | + external_subject: str = Field(min_length=1) | ||
| 31 | + display_name: str = Field(min_length=1) | ||
| 32 | + email: str | None = None | ||
| 33 | + attributes: dict[str, Any] = Field(default_factory=dict) | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class LocalPrincipal(BaseModel): | ||
| 37 | + """Local identity consumed by the example's OAuth2 and handler layers.""" | ||
| 38 | + | ||
| 39 | + model_config = ConfigDict(frozen=True) | ||
| 40 | + | ||
| 41 | + user_id: str | ||
| 42 | + organization_id: str | ||
| 43 | + display_name: str | ||
| 44 | + email: str | None = None | ||
| 45 | + roles: tuple[str, ...] | ||
| 46 | + auth_source: str = "saml" | ||
| @@ -0,0 +1,160 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Federated identity store contract and dictionary-backed test implementation.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +from abc import ABC, abstractmethod | ||
| 10 | +from dataclasses import dataclass | ||
| 11 | +from uuid import uuid4 | ||
| 12 | + | ||
| 13 | +from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | ||
| 14 | + | ||
| 15 | +IdentityKey = tuple[str, str, str] | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class FederatedIdentityStore(ABC): | ||
| 19 | + """Resolve validated external identities into stable local principals.""" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + async def resolve_or_create( | ||
| 23 | + self, | ||
| 24 | + connection: FederationConnection, | ||
| 25 | + identity: ExternalIdentity, | ||
| 26 | + ) -> LocalPrincipal: | ||
| 27 | + """Return the existing principal or create its local shadow records.""" | ||
| 28 | + raise NotImplementedError | ||
| 29 | + | ||
| 30 | + | ||
| 31 | + async def find( | ||
| 32 | + self, | ||
| 33 | + *, | ||
| 34 | + connection_id: str, | ||
| 35 | + issuer: str, | ||
| 36 | + external_subject: str, | ||
| 37 | + ) -> LocalPrincipal | None: | ||
| 38 | + """Find a principal by its stable external identity key.""" | ||
| 39 | + raise NotImplementedError | ||
| 40 | + | ||
| 41 | + | ||
| 42 | + async def close(self) -> None: | ||
| 43 | + """Release resources owned by this store.""" | ||
| 44 | + raise NotImplementedError | ||
| 45 | + | ||
| 46 | + | ||
| 47 | + def validate_binding( | ||
| 48 | + connection: FederationConnection, | ||
| 49 | + identity: ExternalIdentity, | ||
| 50 | + ) -> None: | ||
| 51 | + """Ensure an identity can only be consumed by its trusted connection.""" | ||
| 52 | + if identity.connection_id != connection.connection_id: | ||
| 53 | + raise ValueError( | ||
| 54 | + "external identity connection_id does not match connection" | ||
| 55 | + ) | ||
| 56 | + if identity.issuer != connection.issuer: | ||
| 57 | + raise ValueError("external identity issuer does not match trusted issuer") | ||
| 58 | + | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +class _MemoryUser: | ||
| 62 | + user_id: str | ||
| 63 | + display_name: str | ||
| 64 | + email: str | None | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +class InMemoryFederatedIdentityStore(FederatedIdentityStore): | ||
| 68 | + """Dictionary-backed implementation intended for unit tests.""" | ||
| 69 | + | ||
| 70 | + def __init__(self) -> None: | ||
| 71 | + self._lock = asyncio.Lock() | ||
| 72 | + self._connections: dict[str, FederationConnection] = {} | ||
| 73 | + self._identities: dict[IdentityKey, str] = {} | ||
| 74 | + self._users: dict[str, _MemoryUser] = {} | ||
| 75 | + self._memberships: dict[tuple[str, str], str] = {} | ||
| 76 | + | ||
| 77 | + async def resolve_or_create( | ||
| 78 | + self, | ||
| 79 | + connection: FederationConnection, | ||
| 80 | + identity: ExternalIdentity, | ||
| 81 | + ) -> LocalPrincipal: | ||
| 82 | + self.validate_binding(connection, identity) | ||
| 83 | + key = _identity_key(identity) | ||
| 84 | + | ||
| 85 | + async with self._lock: | ||
| 86 | + self._remember_connection(connection) | ||
| 87 | + user_id = self._identities.get(key) | ||
| 88 | + if user_id is None: | ||
| 89 | + user_id = f"user_{uuid4().hex}" | ||
| 90 | + self._identities[key] = user_id | ||
| 91 | + self._memberships[(connection.organization_id, user_id)] = ( | ||
| 92 | + connection.default_role | ||
| 93 | + ) | ||
| 94 | + | ||
| 95 | + user = _MemoryUser( | ||
| 96 | + user_id=user_id, | ||
| 97 | + display_name=identity.display_name, | ||
| 98 | + email=identity.email, | ||
| 99 | + ) | ||
| 100 | + self._users[user_id] = user | ||
| 101 | + role = self._memberships[(connection.organization_id, user_id)] | ||
| 102 | + return _principal(user, connection.organization_id, role) | ||
| 103 | + | ||
| 104 | + async def find( | ||
| 105 | + self, | ||
| 106 | + *, | ||
| 107 | + connection_id: str, | ||
| 108 | + issuer: str, | ||
| 109 | + external_subject: str, | ||
| 110 | + ) -> LocalPrincipal | None: | ||
| 111 | + key = (connection_id, issuer, external_subject) | ||
| 112 | + async with self._lock: | ||
| 113 | + user_id = self._identities.get(key) | ||
| 114 | + connection = self._connections.get(connection_id) | ||
| 115 | + if user_id is None or connection is None: | ||
| 116 | + return None | ||
| 117 | + user = self._users[user_id] | ||
| 118 | + role = self._memberships[(connection.organization_id, user_id)] | ||
| 119 | + return _principal(user, connection.organization_id, role) | ||
| 120 | + | ||
| 121 | + async def close(self) -> None: | ||
| 122 | + """The dictionary implementation owns no external resources.""" | ||
| 123 | + | ||
| 124 | + def _remember_connection(self, connection: FederationConnection) -> None: | ||
| 125 | + existing = self._connections.get(connection.connection_id) | ||
| 126 | + if existing is not None and _connection_binding( | ||
| 127 | + existing | ||
| 128 | + ) != _connection_binding(connection): | ||
| 129 | + raise ValueError("connection_id is already bound to different settings") | ||
| 130 | + self._connections[connection.connection_id] = connection | ||
| 131 | + | ||
| 132 | + | ||
| 133 | +def _identity_key(identity: ExternalIdentity) -> IdentityKey: | ||
| 134 | + return ( | ||
| 135 | + identity.connection_id, | ||
| 136 | + identity.issuer, | ||
| 137 | + identity.external_subject, | ||
| 138 | + ) | ||
| 139 | + | ||
| 140 | + | ||
| 141 | +def _connection_binding(connection: FederationConnection) -> tuple[str, str, str]: | ||
| 142 | + return ( | ||
| 143 | + connection.issuer, | ||
| 144 | + connection.organization_id, | ||
| 145 | + connection.default_role, | ||
| 146 | + ) | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +def _principal( | ||
| 150 | + user: _MemoryUser, | ||
| 151 | + organization_id: str, | ||
| 152 | + role: str, | ||
| 153 | +) -> LocalPrincipal: | ||
| 154 | + return LocalPrincipal( | ||
| 155 | + user_id=user.user_id, | ||
| 156 | + organization_id=organization_id, | ||
| 157 | + display_name=user.display_name, | ||
| 158 | + email=user.email, | ||
| 159 | + roles=(role,), | ||
| 160 | + ) | ||
| @@ -0,0 +1,89 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Mount a federation provider into the example's local OAuth2 server.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from collections.abc import Mapping | ||
| 9 | + | ||
| 10 | +from fastapi import FastAPI, Request | ||
| 11 | +from fastapi.responses import JSONResponse, RedirectResponse | ||
| 12 | + | ||
| 13 | +from .domain import FederationConnection | ||
| 14 | +from .identity_store import FederatedIdentityStore | ||
| 15 | +from .oauth2_server import ExampleOAuth2AuthorizationServer, OAuth2FlowError | ||
| 16 | +from .provider import FederationProvider | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +class FederatedAuthModule: | ||
| 20 | + """Coordinate upstream federation, local shadow identity, and OAuth2.""" | ||
| 21 | + | ||
| 22 | + def __init__( | ||
| 23 | + self, | ||
| 24 | + *, | ||
| 25 | + provider: FederationProvider, | ||
| 26 | + identity_store: FederatedIdentityStore, | ||
| 27 | + oauth2_server: ExampleOAuth2AuthorizationServer, | ||
| 28 | + connections: Mapping[str, FederationConnection], | ||
| 29 | + ) -> None: | ||
| 30 | + self._provider = provider | ||
| 31 | + self._identity_store = identity_store | ||
| 32 | + self._oauth2_server = oauth2_server | ||
| 33 | + self._connections = dict(connections) | ||
| 34 | + | ||
| 35 | + def mount(self, fastapi: FastAPI) -> None: | ||
| 36 | + """Mount browser redirect and callback routes on one FastAPI app.""" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | + async def begin_federated_login( | ||
| 40 | + connection_id: str, | ||
| 41 | + authorization_request_id: str, | ||
| 42 | + ): | ||
| 43 | + connection = self._connections.get(connection_id) | ||
| 44 | + if connection is None: | ||
| 45 | + return JSONResponse( | ||
| 46 | + {"detail": "unknown federation connection"}, | ||
| 47 | + status_code=404, | ||
| 48 | + ) | ||
| 49 | + try: | ||
| 50 | + await self._oauth2_server.require_authorization_request( | ||
| 51 | + authorization_request_id | ||
| 52 | + ) | ||
| 53 | + login_url = await self._provider.begin_login( | ||
| 54 | + connection, | ||
| 55 | + authorization_request_id, | ||
| 56 | + ) | ||
| 57 | + except (OAuth2FlowError, ValueError) as exc: | ||
| 58 | + return JSONResponse({"detail": str(exc)}, status_code=400) | ||
| 59 | + return RedirectResponse(login_url, status_code=303) | ||
| 60 | + | ||
| 61 | + | ||
| 62 | + "/auth/federation/{connection_id}/callback", | ||
| 63 | + tags=["federation"], | ||
| 64 | + ) | ||
| 65 | + async def complete_federated_login(connection_id: str, request: Request): | ||
| 66 | + connection = self._connections.get(connection_id) | ||
| 67 | + if connection is None: | ||
| 68 | + return JSONResponse( | ||
| 69 | + {"detail": "unknown federation connection"}, | ||
| 70 | + status_code=404, | ||
| 71 | + ) | ||
| 72 | + form_data = await request.form() | ||
| 73 | + form = {key: str(value) for key, value in form_data.items()} | ||
| 74 | + try: | ||
| 75 | + result = await self._provider.consume_callback(connection, form) | ||
| 76 | + await self._oauth2_server.require_authorization_request( | ||
| 77 | + result.authorization_request_id | ||
| 78 | + ) | ||
| 79 | + principal = await self._identity_store.resolve_or_create( | ||
| 80 | + connection, | ||
| 81 | + result.identity, | ||
| 82 | + ) | ||
| 83 | + redirect_url = await self._oauth2_server.complete_authorization( | ||
| 84 | + result.authorization_request_id, | ||
| 85 | + principal, | ||
| 86 | + ) | ||
| 87 | + except (OAuth2FlowError, ValueError) as exc: | ||
| 88 | + return JSONResponse({"detail": str(exc)}, status_code=400) | ||
| 89 | + return RedirectResponse(redirect_url, status_code=303) | ||
| @@ -0,0 +1,375 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Small OAuth2 Authorization Code server used only by the runnable example.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import base64 | ||
| 10 | +import hashlib | ||
| 11 | +import html | ||
| 12 | +import secrets | ||
| 13 | +import time | ||
| 14 | +from dataclasses import dataclass | ||
| 15 | +from typing import Iterable | ||
| 16 | +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit | ||
| 17 | + | ||
| 18 | +from fastapi import FastAPI, Request | ||
| 19 | +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse | ||
| 20 | +from pydantic import BaseModel | ||
| 21 | + | ||
| 22 | +from .domain import FederationConnection, LocalPrincipal | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class AccessToken(BaseModel): | ||
| 26 | + access_token: str | ||
| 27 | + token_type: str = "bearer" | ||
| 28 | + expires_in: int | ||
| 29 | + | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +class _AuthorizationRequest: | ||
| 33 | + client_id: str | ||
| 34 | + redirect_uri: str | ||
| 35 | + state: str | ||
| 36 | + code_challenge: str | None | ||
| 37 | + code_challenge_method: str | None | ||
| 38 | + expires_at: float | ||
| 39 | + | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +class _AuthorizationCode: | ||
| 43 | + principal: LocalPrincipal | ||
| 44 | + client_id: str | ||
| 45 | + redirect_uri: str | ||
| 46 | + code_challenge: str | None | ||
| 47 | + code_challenge_method: str | None | ||
| 48 | + expires_at: float | ||
| 49 | + | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +class _StoredAccessToken: | ||
| 53 | + principal: LocalPrincipal | ||
| 54 | + expires_at: float | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +class OAuth2FlowError(Exception): | ||
| 58 | + """OAuth2-compatible error raised by the example authorization server.""" | ||
| 59 | + | ||
| 60 | + def __init__(self, error: str, description: str, status_code: int = 400) -> None: | ||
| 61 | + super().__init__(description) | ||
| 62 | + self.error = error | ||
| 63 | + self.description = description | ||
| 64 | + self.status_code = status_code | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +class ExampleOAuth2AuthorizationServer: | ||
| 68 | + """Issue one-time authorization codes and local bearer access tokens.""" | ||
| 69 | + | ||
| 70 | + def __init__( | ||
| 71 | + self, | ||
| 72 | + *, | ||
| 73 | + client_id: str = "swagger-docs", | ||
| 74 | + authorization_ttl: int = 300, | ||
| 75 | + access_token_ttl: int = 3600, | ||
| 76 | + ) -> None: | ||
| 77 | + self.client_id = client_id | ||
| 78 | + self._authorization_ttl = authorization_ttl | ||
| 79 | + self._access_token_ttl = access_token_ttl | ||
| 80 | + self._requests: dict[str, _AuthorizationRequest] = {} | ||
| 81 | + self._codes: dict[str, _AuthorizationCode] = {} | ||
| 82 | + self._tokens: dict[str, _StoredAccessToken] = {} | ||
| 83 | + self._lock = asyncio.Lock() | ||
| 84 | + | ||
| 85 | + def mount( | ||
| 86 | + self, | ||
| 87 | + fastapi: FastAPI, | ||
| 88 | + connections: Iterable[FederationConnection], | ||
| 89 | + ) -> None: | ||
| 90 | + """Mount the example authorization and token endpoints.""" | ||
| 91 | + enterprise_connections = tuple(connections) | ||
| 92 | + | ||
| 93 | + | ||
| 94 | + "/oauth/authorize", | ||
| 95 | + response_class=HTMLResponse, | ||
| 96 | + tags=["authentication"], | ||
| 97 | + ) | ||
| 98 | + async def authorize(request: Request): | ||
| 99 | + try: | ||
| 100 | + authorization_request_id = await self.begin_authorization( | ||
| 101 | + response_type=request.query_params.get("response_type", ""), | ||
| 102 | + client_id=request.query_params.get("client_id", ""), | ||
| 103 | + redirect_uri=request.query_params.get("redirect_uri", ""), | ||
| 104 | + state=request.query_params.get("state", ""), | ||
| 105 | + code_challenge=request.query_params.get("code_challenge"), | ||
| 106 | + code_challenge_method=request.query_params.get( | ||
| 107 | + "code_challenge_method" | ||
| 108 | + ), | ||
| 109 | + ) | ||
| 110 | + except OAuth2FlowError as exc: | ||
| 111 | + return _oauth_error(exc) | ||
| 112 | + return HTMLResponse( | ||
| 113 | + _authorization_page( | ||
| 114 | + authorization_request_id, | ||
| 115 | + enterprise_connections, | ||
| 116 | + ) | ||
| 117 | + ) | ||
| 118 | + | ||
| 119 | + | ||
| 120 | + async def local_login(request: Request): | ||
| 121 | + form = await request.form() | ||
| 122 | + username = str(form.get("username") or "") | ||
| 123 | + password = str(form.get("password") or "") | ||
| 124 | + request_id = str(form.get("authorization_request_id") or "") | ||
| 125 | + if username != "demo" or password != "demo": | ||
| 126 | + return HTMLResponse( | ||
| 127 | + "<h2>Incorrect username or password</h2>", | ||
| 128 | + status_code=401, | ||
| 129 | + ) | ||
| 130 | + principal = LocalPrincipal( | ||
| 131 | + user_id="local-demo-user", | ||
| 132 | + organization_id="local-demo-organization", | ||
| 133 | + display_name="demo", | ||
| 134 | + roles=("developer",), | ||
| 135 | + auth_source="local", | ||
| 136 | + ) | ||
| 137 | + try: | ||
| 138 | + redirect_url = await self.complete_authorization( | ||
| 139 | + request_id, | ||
| 140 | + principal, | ||
| 141 | + ) | ||
| 142 | + except OAuth2FlowError as exc: | ||
| 143 | + return _oauth_error(exc) | ||
| 144 | + return RedirectResponse(redirect_url, status_code=303) | ||
| 145 | + | ||
| 146 | + | ||
| 147 | + "/oauth/token", response_model=AccessToken, tags=["authentication"] | ||
| 148 | + ) | ||
| 149 | + async def token(request: Request): | ||
| 150 | + form = await request.form() | ||
| 151 | + try: | ||
| 152 | + access_token = await self.exchange_code( | ||
| 153 | + grant_type=str(form.get("grant_type") or ""), | ||
| 154 | + code=str(form.get("code") or ""), | ||
| 155 | + client_id=str(form.get("client_id") or ""), | ||
| 156 | + redirect_uri=str(form.get("redirect_uri") or ""), | ||
| 157 | + code_verifier=str(form.get("code_verifier") or "") or None, | ||
| 158 | + ) | ||
| 159 | + except OAuth2FlowError as exc: | ||
| 160 | + return _oauth_error(exc) | ||
| 161 | + return access_token | ||
| 162 | + | ||
| 163 | + async def begin_authorization( | ||
| 164 | + self, | ||
| 165 | + *, | ||
| 166 | + response_type: str, | ||
| 167 | + client_id: str, | ||
| 168 | + redirect_uri: str, | ||
| 169 | + state: str, | ||
| 170 | + code_challenge: str | None, | ||
| 171 | + code_challenge_method: str | None, | ||
| 172 | + ) -> str: | ||
| 173 | + if response_type != "code": | ||
| 174 | + raise OAuth2FlowError( | ||
| 175 | + "unsupported_response_type", | ||
| 176 | + "the example only supports response_type=code", | ||
| 177 | + ) | ||
| 178 | + if client_id != self.client_id: | ||
| 179 | + raise OAuth2FlowError("invalid_client", "unknown OAuth2 client") | ||
| 180 | + _validate_docs_redirect_uri(redirect_uri) | ||
| 181 | + if code_challenge and code_challenge_method != "S256": | ||
| 182 | + raise OAuth2FlowError( | ||
| 183 | + "invalid_request", | ||
| 184 | + "the example only supports PKCE S256", | ||
| 185 | + ) | ||
| 186 | + | ||
| 187 | + request_id = secrets.token_urlsafe(24) | ||
| 188 | + async with self._lock: | ||
| 189 | + self._remove_expired_locked() | ||
| 190 | + self._requests[request_id] = _AuthorizationRequest( | ||
| 191 | + client_id=client_id, | ||
| 192 | + redirect_uri=redirect_uri, | ||
| 193 | + state=state, | ||
| 194 | + code_challenge=code_challenge, | ||
| 195 | + code_challenge_method=code_challenge_method, | ||
| 196 | + expires_at=time.time() + self._authorization_ttl, | ||
| 197 | + ) | ||
| 198 | + return request_id | ||
| 199 | + | ||
| 200 | + async def require_authorization_request(self, request_id: str) -> None: | ||
| 201 | + async with self._lock: | ||
| 202 | + self._remove_expired_locked() | ||
| 203 | + if request_id not in self._requests: | ||
| 204 | + raise OAuth2FlowError( | ||
| 205 | + "invalid_request", | ||
| 206 | + "authorization request is missing or expired", | ||
| 207 | + ) | ||
| 208 | + | ||
| 209 | + async def complete_authorization( | ||
| 210 | + self, | ||
| 211 | + request_id: str, | ||
| 212 | + principal: LocalPrincipal, | ||
| 213 | + ) -> str: | ||
| 214 | + async with self._lock: | ||
| 215 | + self._remove_expired_locked() | ||
| 216 | + authorization = self._requests.pop(request_id, None) | ||
| 217 | + if authorization is None: | ||
| 218 | + raise OAuth2FlowError( | ||
| 219 | + "invalid_request", | ||
| 220 | + "authorization request is missing or expired", | ||
| 221 | + ) | ||
| 222 | + code = secrets.token_urlsafe(32) | ||
| 223 | + self._codes[code] = _AuthorizationCode( | ||
| 224 | + principal=principal, | ||
| 225 | + client_id=authorization.client_id, | ||
| 226 | + redirect_uri=authorization.redirect_uri, | ||
| 227 | + code_challenge=authorization.code_challenge, | ||
| 228 | + code_challenge_method=authorization.code_challenge_method, | ||
| 229 | + expires_at=time.time() + self._authorization_ttl, | ||
| 230 | + ) | ||
| 231 | + return _append_query( | ||
| 232 | + authorization.redirect_uri, | ||
| 233 | + {"code": code, "state": authorization.state}, | ||
| 234 | + ) | ||
| 235 | + | ||
| 236 | + async def exchange_code( | ||
| 237 | + self, | ||
| 238 | + *, | ||
| 239 | + grant_type: str, | ||
| 240 | + code: str, | ||
| 241 | + client_id: str, | ||
| 242 | + redirect_uri: str, | ||
| 243 | + code_verifier: str | None, | ||
| 244 | + ) -> AccessToken: | ||
| 245 | + if grant_type != "authorization_code": | ||
| 246 | + raise OAuth2FlowError( | ||
| 247 | + "unsupported_grant_type", | ||
| 248 | + "the example only supports authorization_code", | ||
| 249 | + ) | ||
| 250 | + async with self._lock: | ||
| 251 | + self._remove_expired_locked() | ||
| 252 | + authorization = self._codes.get(code) | ||
| 253 | + if authorization is None: | ||
| 254 | + raise OAuth2FlowError("invalid_grant", "code is invalid or expired") | ||
| 255 | + if client_id != authorization.client_id: | ||
| 256 | + raise OAuth2FlowError("invalid_client", "client_id does not match code") | ||
| 257 | + if redirect_uri != authorization.redirect_uri: | ||
| 258 | + raise OAuth2FlowError( | ||
| 259 | + "invalid_grant", | ||
| 260 | + "redirect_uri does not match authorization request", | ||
| 261 | + ) | ||
| 262 | + _verify_pkce(authorization, code_verifier) | ||
| 263 | + del self._codes[code] | ||
| 264 | + | ||
| 265 | + token = secrets.token_urlsafe(40) | ||
| 266 | + self._tokens[token] = _StoredAccessToken( | ||
| 267 | + principal=authorization.principal, | ||
| 268 | + expires_at=time.time() + self._access_token_ttl, | ||
| 269 | + ) | ||
| 270 | + return AccessToken( | ||
| 271 | + access_token=token, | ||
| 272 | + expires_in=self._access_token_ttl, | ||
| 273 | + ) | ||
| 274 | + | ||
| 275 | + async def validate_access_token(self, token: str): | ||
| 276 | + """Return the principal payload consumed by OAuth2AccessControl.""" | ||
| 277 | + async with self._lock: | ||
| 278 | + self._remove_expired_locked() | ||
| 279 | + stored = self._tokens.get(token) | ||
| 280 | + if stored is None: | ||
| 281 | + return None | ||
| 282 | + principal = stored.principal | ||
| 283 | + payload = principal.model_dump(mode="json") | ||
| 284 | + payload["username"] = principal.display_name | ||
| 285 | + return payload | ||
| 286 | + | ||
| 287 | + def _remove_expired_locked(self) -> None: | ||
| 288 | + now = time.time() | ||
| 289 | + self._requests = { | ||
| 290 | + key: value | ||
| 291 | + for key, value in self._requests.items() | ||
| 292 | + if value.expires_at > now | ||
| 293 | + } | ||
| 294 | + self._codes = { | ||
| 295 | + key: value for key, value in self._codes.items() if value.expires_at > now | ||
| 296 | + } | ||
| 297 | + self._tokens = { | ||
| 298 | + key: value for key, value in self._tokens.items() if value.expires_at > now | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + | ||
| 302 | +def _authorization_page( | ||
| 303 | + authorization_request_id: str, | ||
| 304 | + connections: tuple[FederationConnection, ...], | ||
| 305 | +) -> str: | ||
| 306 | + request_id = html.escape(authorization_request_id, quote=True) | ||
| 307 | + enterprise_links = "".join( | ||
| 308 | + ( | ||
| 309 | + '<a class="sso" href="/auth/federation/' | ||
| 310 | + f"{html.escape(connection.connection_id, quote=True)}" | ||
| 311 | + f'/login?authorization_request_id={request_id}">' | ||
| 312 | + f"Sign in with {html.escape(connection.organization_name)}</a>" | ||
| 313 | + ) | ||
| 314 | + for connection in connections | ||
| 315 | + ) | ||
| 316 | + return f"""<!doctype html> | ||
| 317 | +<html><head><meta charset="utf-8"><title>OAuth2 Sign In</title> | ||
| 318 | +<style> | ||
| 319 | +body{{font-family:system-ui;max-width:460px;margin:60px auto;color:#222}} | ||
| 320 | +.card{{border:1px solid #ddd;border-radius:12px;padding:24px}} | ||
| 321 | +label,input,button,.sso{{display:block;width:100%;box-sizing:border-box}} | ||
| 322 | +input{{padding:10px;margin:6px 0 14px}}button,.sso{{padding:11px;margin-top:12px}} | ||
| 323 | +.sso{{text-align:center;background:#1769aa;color:white;text-decoration:none;border-radius:6px}} | ||
| 324 | +.hint{{color:#666;font-size:14px}} | ||
| 325 | +</style></head><body><div class="card"> | ||
| 326 | +<h2>OpenJiuwen OAuth2</h2> | ||
| 327 | +<p class="hint">Local demo account: demo / demo</p> | ||
| 328 | +<form method="post" action="/auth/local/login"> | ||
| 329 | +<input type="hidden" name="authorization_request_id" value="{request_id}"> | ||
| 330 | +<label>Username<input name="username" value="demo" required></label> | ||
| 331 | +<label>Password<input name="password" type="password" value="demo" required></label> | ||
| 332 | +<button type="submit">Local sign in</button></form> | ||
| 333 | +<hr>{enterprise_links} | ||
| 334 | +</div></body></html>""" | ||
| 335 | + | ||
| 336 | + | ||
| 337 | +def _validate_docs_redirect_uri(redirect_uri: str) -> None: | ||
| 338 | + parsed = urlsplit(redirect_uri) | ||
| 339 | + valid_origin = parsed.scheme in {"http", "https"} and bool(parsed.netloc) | ||
| 340 | + valid_callback = parsed.path == "/docs/oauth2-redirect" and not parsed.fragment | ||
| 341 | + if not valid_origin or not valid_callback: | ||
| 342 | + raise OAuth2FlowError( | ||
| 343 | + "invalid_request", | ||
| 344 | + "redirect_uri must target /docs/oauth2-redirect", | ||
| 345 | + ) | ||
| 346 | + | ||
| 347 | + | ||
| 348 | +def _append_query(url: str, values: dict[str, str]) -> str: | ||
| 349 | + parsed = urlsplit(url) | ||
| 350 | + query = parse_qsl(parsed.query, keep_blank_values=True) | ||
| 351 | + query.extend(values.items()) | ||
| 352 | + return urlunsplit( | ||
| 353 | + (parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment) | ||
| 354 | + ) | ||
| 355 | + | ||
| 356 | + | ||
| 357 | +def _verify_pkce( | ||
| 358 | + authorization: _AuthorizationCode, | ||
| 359 | + code_verifier: str | None, | ||
| 360 | +) -> None: | ||
| 361 | + if authorization.code_challenge is None: | ||
| 362 | + return | ||
| 363 | + if authorization.code_challenge_method != "S256" or not code_verifier: | ||
| 364 | + raise OAuth2FlowError("invalid_grant", "a PKCE code_verifier is required") | ||
| 365 | + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() | ||
| 366 | + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") | ||
| 367 | + if not secrets.compare_digest(challenge, authorization.code_challenge): | ||
| 368 | + raise OAuth2FlowError("invalid_grant", "PKCE verification failed") | ||
| 369 | + | ||
| 370 | + | ||
| 371 | +def _oauth_error(exc: OAuth2FlowError) -> JSONResponse: | ||
| 372 | + return JSONResponse( | ||
| 373 | + {"error": exc.error, "error_description": exc.description}, | ||
| 374 | + status_code=exc.status_code, | ||
| 375 | + ) | ||
| @@ -0,0 +1,88 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Federation provider contract and local enterprise-identity demonstration.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from abc import ABC, abstractmethod | ||
| 9 | +from dataclasses import dataclass | ||
| 10 | +from typing import Mapping | ||
| 11 | +from urllib.parse import urlencode | ||
| 12 | + | ||
| 13 | +from .domain import ExternalIdentity, FederationConnection | ||
| 14 | + | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class FederationAuthenticationResult: | ||
| 18 | + """Validated upstream identity and the local OAuth2 request it completes.""" | ||
| 19 | + | ||
| 20 | + authorization_request_id: str | ||
| 21 | + identity: ExternalIdentity | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +class FederationProvider(ABC): | ||
| 25 | + """Asynchronous boundary around an upstream enterprise identity protocol.""" | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + async def begin_login( | ||
| 29 | + self, | ||
| 30 | + connection: FederationConnection, | ||
| 31 | + authorization_request_id: str, | ||
| 32 | + ) -> str: | ||
| 33 | + """Return the upstream login URL for one OAuth2 authorization request.""" | ||
| 34 | + raise NotImplementedError | ||
| 35 | + | ||
| 36 | + | ||
| 37 | + async def consume_callback( | ||
| 38 | + self, | ||
| 39 | + connection: FederationConnection, | ||
| 40 | + form: Mapping[str, str], | ||
| 41 | + ) -> FederationAuthenticationResult: | ||
| 42 | + """Validate an upstream callback and return a normalized identity.""" | ||
| 43 | + raise NotImplementedError | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +class DemoFederationProvider(FederationProvider): | ||
| 47 | + """Local provider used to exercise federation without pretending to verify SAML.""" | ||
| 48 | + | ||
| 49 | + async def begin_login( | ||
| 50 | + self, | ||
| 51 | + connection: FederationConnection, | ||
| 52 | + authorization_request_id: str, | ||
| 53 | + ) -> str: | ||
| 54 | + query = urlencode( | ||
| 55 | + { | ||
| 56 | + "connection_id": connection.connection_id, | ||
| 57 | + "authorization_request_id": authorization_request_id, | ||
| 58 | + } | ||
| 59 | + ) | ||
| 60 | + return f"/demo-enterprise-idp/login?{query}" | ||
| 61 | + | ||
| 62 | + async def consume_callback( | ||
| 63 | + self, | ||
| 64 | + connection: FederationConnection, | ||
| 65 | + form: Mapping[str, str], | ||
| 66 | + ) -> FederationAuthenticationResult: | ||
| 67 | + authorization_request_id = _required(form, "authorization_request_id") | ||
| 68 | + employee_id = _required(form, "employee_id") | ||
| 69 | + display_name = _required(form, "display_name") | ||
| 70 | + email = str(form.get("email") or "").strip() or None | ||
| 71 | + return FederationAuthenticationResult( | ||
| 72 | + authorization_request_id=authorization_request_id, | ||
| 73 | + identity=ExternalIdentity( | ||
| 74 | + connection_id=connection.connection_id, | ||
| 75 | + issuer=connection.issuer, | ||
| 76 | + external_subject=employee_id, | ||
| 77 | + display_name=display_name, | ||
| 78 | + email=email, | ||
| 79 | + attributes={"employee_id": employee_id}, | ||
| 80 | + ), | ||
| 81 | + ) | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +def _required(form: Mapping[str, str], name: str) -> str: | ||
| 85 | + value = str(form.get(name) or "").strip() | ||
| 86 | + if not value: | ||
| 87 | + raise ValueError(f"missing required federation field: {name}") | ||
| 88 | + return value | ||
| @@ -0,0 +1,312 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""REST/SSE multi-handler example with extensible handlers and simple OAuth2. | ||
| 5 | + | ||
| 6 | +Run from the ``service`` directory:: | ||
| 7 | + | ||
| 8 | + uv run python examples/multi_handler_app.py | ||
| 9 | + | ||
| 10 | +Then open ``http://127.0.0.1:8090/docs`` and click ``Authorize``. The OAuth2 | ||
| 11 | +authorization page supports the local credentials ``demo`` / ``demo`` and a | ||
| 12 | +clearly labelled enterprise IdP simulation. Federated shadow identities are | ||
| 13 | +persisted in a local SQLite file; the example's business user store remains | ||
| 14 | +process-local and should be replaced by ``ctx.db`` or ``ctx.kv`` in a service. | ||
| 15 | +""" | ||
| 16 | + | ||
| 17 | +from __future__ import annotations | ||
| 18 | + | ||
| 19 | +import asyncio | ||
| 20 | +import os | ||
| 21 | +from pathlib import Path | ||
| 22 | + | ||
| 23 | +from pydantic import BaseModel, Field | ||
| 24 | + | ||
| 25 | +from custom_handlers import custom_handlers | ||
| 26 | +from federated_auth import ( | ||
| 27 | + DatabaseFederatedIdentityStore, | ||
| 28 | + DemoEnterpriseIdentityProvider, | ||
| 29 | + DemoFederationProvider, | ||
| 30 | + ExampleOAuth2AuthorizationServer, | ||
| 31 | + FederatedAuthModule, | ||
| 32 | + FederationConnection, | ||
| 33 | +) | ||
| 34 | +from openjiuwen_runtime.service import ( | ||
| 35 | + App, | ||
| 36 | + Envelope, | ||
| 37 | + HandlerSpec, | ||
| 38 | + MessageHandler, | ||
| 39 | + OAuth2AccessControl, | ||
| 40 | + StreamMessageHandler, | ||
| 41 | + SystemContext, | ||
| 42 | +) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class CreateUserInput(BaseModel): | ||
| 46 | + name: str = Field(min_length=1, examples=["alice"]) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +class ByIdInput(BaseModel): | ||
| 50 | + id: int = Field(gt=0, examples=[1]) | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +class ChatInput(BaseModel): | ||
| 54 | + text: str = Field(min_length=1, examples=["hello"]) | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +class ErrorDemoInput(BaseModel): | ||
| 58 | + message: str = "example validation error" | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +class UserOutput(BaseModel): | ||
| 62 | + id: int | ||
| 63 | + name: str | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +class CreatedUserOutput(UserOutput): | ||
| 67 | + created_by: str | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +class UserListOutput(BaseModel): | ||
| 71 | + users: list[UserOutput] | ||
| 72 | + total: int | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +class RemoveUserOutput(BaseModel): | ||
| 76 | + removed: bool | ||
| 77 | + | ||
| 78 | + | ||
| 79 | +class IdentityOutput(BaseModel): | ||
| 80 | + user_id: str | ||
| 81 | + organization_id: str | ||
| 82 | + display_name: str | ||
| 83 | + roles: list[str] | ||
| 84 | + auth_source: str | ||
| 85 | + | ||
| 86 | + | ||
| 87 | +class DemoUserStore: | ||
| 88 | + """Small process-local repository used only by this runnable example.""" | ||
| 89 | + | ||
| 90 | + def __init__(self) -> None: | ||
| 91 | + self._users: dict[int, dict] = {} | ||
| 92 | + self._next_id = 1 | ||
| 93 | + self._lock = asyncio.Lock() | ||
| 94 | + | ||
| 95 | + async def create(self, name: str) -> dict: | ||
| 96 | + async with self._lock: | ||
| 97 | + user = {"id": self._next_id, "name": name} | ||
| 98 | + self._users[self._next_id] = user | ||
| 99 | + self._next_id += 1 | ||
| 100 | + return dict(user) | ||
| 101 | + | ||
| 102 | + async def list(self) -> list[dict]: | ||
| 103 | + async with self._lock: | ||
| 104 | + return [dict(user) for user in self._users.values()] | ||
| 105 | + | ||
| 106 | + async def get(self, user_id: int) -> dict | None: | ||
| 107 | + async with self._lock: | ||
| 108 | + user = self._users.get(user_id) | ||
| 109 | + return dict(user) if user is not None else None | ||
| 110 | + | ||
| 111 | + async def remove(self, user_id: int) -> bool: | ||
| 112 | + async with self._lock: | ||
| 113 | + return self._users.pop(user_id, None) is not None | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +class CreateUserHandler(MessageHandler): | ||
| 117 | + spec = HandlerSpec( | ||
| 118 | + msg_type="users.create", | ||
| 119 | + request_model=CreateUserInput, | ||
| 120 | + response_model=CreatedUserOutput, | ||
| 121 | + summary="Create user", | ||
| 122 | + tags=("users",), | ||
| 123 | + ) | ||
| 124 | + | ||
| 125 | + def __init__(self, store: DemoUserStore) -> None: | ||
| 126 | + self._store = store | ||
| 127 | + | ||
| 128 | + async def handle(self, ctx, env: Envelope): | ||
| 129 | + user = await self._store.create(env.rawdata.name) | ||
| 130 | + user["created_by"] = ctx.principal["username"] | ||
| 131 | + return user | ||
| 132 | + | ||
| 133 | + | ||
| 134 | +class ListUsersHandler(MessageHandler): | ||
| 135 | + spec = HandlerSpec( | ||
| 136 | + msg_type="users.list", | ||
| 137 | + response_model=UserListOutput, | ||
| 138 | + summary="List users", | ||
| 139 | + tags=("users",), | ||
| 140 | + ) | ||
| 141 | + | ||
| 142 | + def __init__(self, store: DemoUserStore) -> None: | ||
| 143 | + self._store = store | ||
| 144 | + | ||
| 145 | + async def handle(self, ctx, env: Envelope): | ||
| 146 | + users = await self._store.list() | ||
| 147 | + return {"users": users, "total": len(users)} | ||
| 148 | + | ||
| 149 | + | ||
| 150 | +class GetUserHandler(MessageHandler): | ||
| 151 | + spec = HandlerSpec( | ||
| 152 | + msg_type="users.get", | ||
| 153 | + request_model=ByIdInput, | ||
| 154 | + response_model=UserOutput, | ||
| 155 | + summary="Get user", | ||
| 156 | + tags=("users",), | ||
| 157 | + ) | ||
| 158 | + | ||
| 159 | + def __init__(self, store: DemoUserStore) -> None: | ||
| 160 | + self._store = store | ||
| 161 | + | ||
| 162 | + async def handle(self, ctx, env: Envelope): | ||
| 163 | + user = await self._store.get(env.rawdata.id) | ||
| 164 | + if user is None: | ||
| 165 | + from openjiuwen_runtime.service import NotFoundError | ||
| 166 | + | ||
| 167 | + raise NotFoundError(f"user {env.rawdata.id} not found") | ||
| 168 | + return user | ||
| 169 | + | ||
| 170 | + | ||
| 171 | +class RemoveUserHandler(MessageHandler): | ||
| 172 | + spec = HandlerSpec( | ||
| 173 | + msg_type="users.remove", | ||
| 174 | + request_model=ByIdInput, | ||
| 175 | + response_model=RemoveUserOutput, | ||
| 176 | + summary="Remove user", | ||
| 177 | + tags=("users",), | ||
| 178 | + ) | ||
| 179 | + | ||
| 180 | + def __init__(self, store: DemoUserStore) -> None: | ||
| 181 | + self._store = store | ||
| 182 | + | ||
| 183 | + async def handle(self, ctx, env: Envelope): | ||
| 184 | + return {"removed": await self._store.remove(env.rawdata.id)} | ||
| 185 | + | ||
| 186 | + | ||
| 187 | +class ChatHandler(StreamMessageHandler): | ||
| 188 | + spec = HandlerSpec( | ||
| 189 | + msg_type="chat", | ||
| 190 | + request_model=ChatInput, | ||
| 191 | + summary="Stream chat characters", | ||
| 192 | + tags=("chat",), | ||
| 193 | + ) | ||
| 194 | + | ||
| 195 | + async def handle_stream(self, ctx, env: Envelope): | ||
| 196 | + for character in env.rawdata.text: | ||
| 197 | + yield {"chunk": character, "user": ctx.principal["username"]} | ||
| 198 | + | ||
| 199 | + | ||
| 200 | +enterprise_connection = FederationConnection( | ||
| 201 | + connection_id="enterprise-demo", | ||
| 202 | + issuer="https://idp.enterprise-demo.example", | ||
| 203 | + organization_id="virtual-org-enterprise-demo", | ||
| 204 | + organization_name="Enterprise Demo SSO", | ||
| 205 | +) | ||
| 206 | +federation_connections = { | ||
| 207 | + enterprise_connection.connection_id: enterprise_connection, | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +default_identity_database = ( | ||
| 211 | + Path(__file__).resolve().parent / "federated_auth" / ".data" / "federated_auth.db" | ||
| 212 | +) | ||
| 213 | +identity_store = DatabaseFederatedIdentityStore( | ||
| 214 | + Path(os.getenv("FEDERATED_AUTH_DATABASE_PATH", default_identity_database)) | ||
| 215 | +) | ||
| 216 | +oauth2_server = ExampleOAuth2AuthorizationServer() | ||
| 217 | + | ||
| 218 | + | ||
| 219 | +oauth2 = OAuth2AccessControl( | ||
| 220 | + token_url="/oauth/token", | ||
| 221 | + authorization_url="/oauth/authorize", | ||
| 222 | + token_validator=oauth2_server.validate_access_token, | ||
| 223 | + scheme_name="OAuth2AuthorizationCode", | ||
| 224 | +) | ||
| 225 | + | ||
| 226 | +app = App( | ||
| 227 | + lambda: SystemContext(), | ||
| 228 | + title="OpenJiuwen Multi Handler Example", | ||
| 229 | + enable_ws=False, | ||
| 230 | + oauth2=oauth2, | ||
| 231 | +) | ||
| 232 | +app.asgi.swagger_ui_init_oauth = { | ||
| 233 | + "clientId": oauth2_server.client_id, | ||
| 234 | + "usePkceWithAuthorizationCodeGrant": True, | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +oauth2_server.mount(app.asgi, federation_connections.values()) | ||
| 238 | +FederatedAuthModule( | ||
| 239 | + provider=DemoFederationProvider(), | ||
| 240 | + identity_store=identity_store, | ||
| 241 | + oauth2_server=oauth2_server, | ||
| 242 | + connections=federation_connections, | ||
| 243 | +).mount(app.asgi) | ||
| 244 | +DemoEnterpriseIdentityProvider().mount(app.asgi) | ||
| 245 | + | ||
| 246 | +demo_user_store = DemoUserStore() | ||
| 247 | + | ||
| 248 | +# Register one object, then a batch of object-oriented handlers. | ||
| 249 | +app.register(CreateUserHandler(demo_user_store)) | ||
| 250 | +app.register_all( | ||
| 251 | + [ | ||
| 252 | + ListUsersHandler(demo_user_store), | ||
| 253 | + GetUserHandler(demo_user_store), | ||
| 254 | + RemoveUserHandler(demo_user_store), | ||
| 255 | + ChatHandler(), | ||
| 256 | + ] | ||
| 257 | +) | ||
| 258 | + | ||
| 259 | +# Include a separately maintained handler module without modifying the host app. | ||
| 260 | +app.include(custom_handlers) | ||
| 261 | + | ||
| 262 | + | ||
| 263 | + | ||
| 264 | + "ping", | ||
| 265 | + summary="Ping", | ||
| 266 | + description="Decorator registration remains the shortest option.", | ||
| 267 | + tags=["system"], | ||
| 268 | +) | ||
| 269 | +async def ping(ctx, env: Envelope): | ||
| 270 | + return { | ||
| 271 | + "pong": True, | ||
| 272 | + "request_id": ctx.request_id, | ||
| 273 | + "authenticated_user": ctx.principal["username"], | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + | ||
| 277 | + | ||
| 278 | + "identity.me", | ||
| 279 | + response_model=IdentityOutput, | ||
| 280 | + summary="Current local identity", | ||
| 281 | + description="Shows the local principal created by local or federated login.", | ||
| 282 | + tags=["identity"], | ||
| 283 | +) | ||
| 284 | +async def identity_me(ctx, env: Envelope): | ||
| 285 | + return { | ||
| 286 | + "user_id": ctx.principal["user_id"], | ||
| 287 | + "organization_id": ctx.principal["organization_id"], | ||
| 288 | + "display_name": ctx.principal["username"], | ||
| 289 | + "roles": ctx.principal["roles"], | ||
| 290 | + "auth_source": ctx.principal["auth_source"], | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + | ||
| 294 | + | ||
| 295 | + "demo.error", | ||
| 296 | + request_model=ErrorDemoInput, | ||
| 297 | + summary="Return a validation error envelope", | ||
| 298 | + tags=["errors"], | ||
| 299 | +) | ||
| 300 | +async def demo_error(ctx, env: Envelope): | ||
| 301 | + from openjiuwen_runtime.service import ValidationError | ||
| 302 | + | ||
| 303 | + raise ValidationError(env.rawdata.message) | ||
| 304 | + | ||
| 305 | + | ||
| 306 | + | ||
| 307 | +async def health(): | ||
| 308 | + return {"status": "healthy", "app": "OpenJiuwen Multi Handler Example"} | ||
| 309 | + | ||
| 310 | + | ||
| 311 | +if __name__ == "__main__": | ||
| 312 | + app.run() | ||
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 对外出口随子模块实现逐步充实(Envelope / App / SystemContext / 原语 / 错误类)。 | 6 | 对外出口随子模块实现逐步充实(Envelope / App / SystemContext / 原语 / 错误类)。 |
| 7 | """ | 7 | """ |
| 8 | + | ||
| 8 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk | 9 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk |
| 9 | from .config import ServiceConfig | 10 | from .config import ServiceConfig |
| 10 | from .errors import ( | 11 | from .errors import ( |
| @@ -30,6 +31,16 @@ from .context import ( | |||
| 30 | TypedAppContext, | 31 | TypedAppContext, |
| 31 | ) | 32 | ) |
| 32 | from .context.primitives.idempotency import idempotency_guard | 33 | from .context.primitives.idempotency import idempotency_guard |
| 34 | +from .routing.handlers import ( | ||
| 35 | + FunctionMessageHandler, | ||
| 36 | + FunctionStreamMessageHandler, | ||
| 37 | + HandlerModule, | ||
| 38 | + HandlerRegistry, | ||
| 39 | + HandlerSpec, | ||
| 40 | + MessageHandler, | ||
| 41 | + StreamMessageHandler, | ||
| 42 | +) | ||
| 43 | +from .security import OAuth2AccessControl | ||
| 33 | from .server.app import App | 44 | from .server.app import App |
| 34 | 45 | ||
| 35 | __version__ = "0.1.0" | 46 | __version__ = "0.1.0" |
| @@ -62,8 +73,18 @@ __all__ = [ | |||
| 62 | "AuditLogger", | 73 | "AuditLogger", |
| 63 | "LoggingAuditLogger", | 74 | "LoggingAuditLogger", |
| 64 | "NoopAuditLogger", | 75 | "NoopAuditLogger", |
| 76 | + # handlers | ||
| 77 | + "HandlerSpec", | ||
| 78 | + "MessageHandler", | ||
| 79 | + "StreamMessageHandler", | ||
| 80 | + "FunctionMessageHandler", | ||
| 81 | + "FunctionStreamMessageHandler", | ||
| 82 | + "HandlerRegistry", | ||
| 83 | + "HandlerModule", | ||
| 65 | # middleware | 84 | # middleware |
| 66 | "idempotency_guard", | 85 | "idempotency_guard", |
| 86 | + # security | ||
| 87 | + "OAuth2AccessControl", | ||
| 67 | # server | 88 | # server |
| 68 | "App", | 89 | "App", |
| 69 | ] | 90 | ] |