已合并
feat(service): 新增可扩展 Handler 与联合认证示例 #412
feat(service): 新增可扩展 Handler 与联合认证示例 #412
已合并
Wal1et创建于 8月7日
共 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+@custom_handlers.handle(
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+ @staticmethod
18+ def mount(fastapi: FastAPI) -> None:
19+ @fastapi.get(
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+ @abstractmethod
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+ @abstractmethod
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+ @abstractmethod
42+ async def close(self) -> None:
43+ """Release resources owned by this store."""
44+ raise NotImplementedError
45+ 
46+ @staticmethod
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+@dataclass
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+ @fastapi.get("/auth/federation/{connection_id}/login", tags=["federation"])
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+ @fastapi.post(
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+@dataclass(frozen=True)
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+@dataclass(frozen=True)
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+@dataclass(frozen=True)
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+ @fastapi.get(
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+ @fastapi.post("/auth/local/login", tags=["authentication"])
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+ @fastapi.post(
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+@dataclass(frozen=True)
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+ @abstractmethod
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+ @abstractmethod
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+@app.handle(
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+@app.handle(
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+@app.handle(
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+@app.asgi.get("/health", tags=["system"])
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+ 
8from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk9from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk
9from .config import ServiceConfig10from .config import ServiceConfig
10from .errors import (11from .errors import (
@@ -30,6 +31,16 @@ from .context import (
30 TypedAppContext,31 TypedAppContext,
31)32)
32from .context.primitives.idempotency import idempotency_guard33from .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
33from .server.app import App44from .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 # middleware84 # middleware
66 "idempotency_guard",85 "idempotency_guard",
86+ # security
87+ "OAuth2AccessControl",
67 # server88 # server
68 "App",89 "App",
69]90]