已合并
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+ 
@@ -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+ 
@@ -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]