已合并
feat(service): 新增可扩展 Handler 与联合认证示例 #412
Wal1et创建于 8月7日
feat(service): 新增可扩展 Handler 与联合认证示例 #412
已合并
共 25 个文件变更+4621-232
| @@ -0,0 +1,667 @@ | |||
| 1 | +# Service Framework examples | ||
| 2 | + | ||
| 3 | +本目录提供一个可以直接运行、调试和扩展的 `openjiuwen_runtime.service` | ||
| 4 | +示例。它的目标不是展示某个特定业务,而是回答开发者在使用 Service | ||
| 5 | +Framework 时最常见的几个问题: | ||
| 6 | + | ||
| 7 | +- 如何定义普通 Handler 和流式 Handler; | ||
| 8 | +- 如何为 Handler 声明输入、输出和 OpenAPI 元数据; | ||
| 9 | +- 如何通过装饰器、对象注册、批量注册和模块组合组织不同规模的服务; | ||
| 10 | +- HTTP 请求如何经过 FastAPI、OAuth2、统一 Envelope、消息路由和 Handler; | ||
| 11 | +- 流式 Handler 如何通过 HTTP Server-Sent Events(SSE)返回数据; | ||
| 12 | +- 如何在不修改框架代码的情况下增加自定义 Handler 模块; | ||
| 13 | +- 如何把本地登录和企业联合登录接入同一个 OAuth2 Authorization Code 流程。 | ||
| 14 | + | ||
| 15 | +> 本目录是开发者示例,不是可直接部署到生产环境的认证中心或用户服务。 | ||
| 16 | +> 文档中会明确标注演示实现及其生产替换点。 | ||
| 17 | + | ||
| 18 | +## 1. 目录结构 | ||
| 19 | + | ||
| 20 | +```text | ||
| 21 | +examples/ | ||
| 22 | +├── README.md | ||
| 23 | +├── multi_handler_app.py | ||
| 24 | +├── custom_handlers.py | ||
| 25 | +└── federated_auth/ | ||
| 26 | + ├── README.md | ||
| 27 | + ├── __init__.py | ||
| 28 | + ├── domain.py | ||
| 29 | + ├── identity_store.py | ||
| 30 | + ├── database_identity_store.py | ||
| 31 | + ├── provider.py | ||
| 32 | + ├── module.py | ||
| 33 | + ├── oauth2_server.py | ||
| 34 | + └── demo_idp.py | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +各文件职责如下: | ||
| 38 | + | ||
| 39 | +| 文件 | 职责 | | ||
| 40 | +| --- | --- | | ||
| 41 | +| `multi_handler_app.py` | 组装 `App`、OAuth2、联合认证模块和全部示例 Handler,并提供可运行入口 | | ||
| 42 | +| `custom_handlers.py` | 展示独立功能模块如何通过 `HandlerRegistry` 向宿主应用贡献 Handler | | ||
| 43 | +| `federated_auth/` | 联合身份标准化、本地虚拟身份映射、SQLite 存储和示例 OAuth2 服务 | | ||
| 44 | +| `federated_auth/README.md` | 联合认证模块的详细设计、通信时序、安全边界与扩展方法 | | ||
| 45 | + | ||
| 46 | +## 2. 整体架构 | ||
| 47 | + | ||
| 48 | +示例刻意把“通用框架”“应用组装”和“可插拔业务模块”分开: | ||
| 49 | + | ||
| 50 | +```mermaid | ||
| 51 | +flowchart LR | ||
| 52 | + Client["Swagger UI / HTTP client"] | ||
| 53 | + FastAPI["FastAPI / REST adapter"] | ||
| 54 | + Auth["OAuth2AccessControl"] | ||
| 55 | + Router["MessageRouter"] | ||
| 56 | + Context["SystemContext / RequestContext"] | ||
| 57 | + Handler["MessageHandler"] | ||
| 58 | + Stream["StreamMessageHandler"] | ||
| 59 | + Module["HandlerRegistry module"] | ||
| 60 | + | ||
| 61 | + Client --> FastAPI | ||
| 62 | + FastAPI --> Auth | ||
| 63 | + Auth --> Context | ||
| 64 | + Context --> Router | ||
| 65 | + Module -->|"App.include"| Router | ||
| 66 | + Router --> Handler | ||
| 67 | + Router --> Stream | ||
| 68 | + Handler -->|"ResponseEnvelope"| FastAPI | ||
| 69 | + Stream -->|"StreamChunk"| FastAPI | ||
| 70 | + FastAPI -->|"JSON or SSE"| Client | ||
| 71 | +``` | ||
| 72 | + | ||
| 73 | +关键原则: | ||
| 74 | + | ||
| 75 | +1. **Handler 与传输协议解耦。** Handler 只接收 `RequestContext` 和 | ||
| 76 | + `Envelope`,不直接依赖 FastAPI 的 `Request`、HTTP 或 WebSocket。 | ||
| 77 | +2. **所有注册方式共享同一份契约。** 装饰器、对象和模块最终都会转换成 | ||
| 78 | + `MessageHandler` 或 `StreamMessageHandler`,并进入同一个 | ||
| 79 | + `MessageRouter`。 | ||
| 80 | +3. **异步是强约束。** 普通 Handler 必须是 `async def`;流式 Handler | ||
| 81 | + 必须是异步生成器。同步实现会在注册阶段被拒绝,而不是等到请求期间阻塞事件循环。 | ||
| 82 | +4. **消息类型是唯一的路由键。** `Envelope.type` 同时决定 Handler 和默认 | ||
| 83 | + REST 路径,一个应用中不允许重复注册同一个消息类型。 | ||
| 84 | + | ||
| 85 | +## 3. 环境准备与运行 | ||
| 86 | + | ||
| 87 | +### 3.1 前置条件 | ||
| 88 | + | ||
| 89 | +- Python `>= 3.11.4` | ||
| 90 | +- `uv` | ||
| 91 | + | ||
| 92 | +从仓库的 `service` 目录执行: | ||
| 93 | + | ||
| 94 | +```bash | ||
| 95 | +uv sync | ||
| 96 | +uv run python examples/multi_handler_app.py | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +默认地址: | ||
| 100 | + | ||
| @@ -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 | + | ||
| 8 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk | 9 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk |
| 9 | from .config import ServiceConfig | 10 | from .config import ServiceConfig |
| 10 | from .errors import ( | 11 | from .errors import ( |
| @@ -30,6 +31,16 @@ from .context import ( | |||
| 30 | TypedAppContext, | 31 | TypedAppContext, |
| 31 | ) | 32 | ) |
| 32 | from .context.primitives.idempotency import idempotency_guard | 33 | from .context.primitives.idempotency import idempotency_guard |
| 34 | +from .routing.handlers import ( | ||
| 35 | + FunctionMessageHandler, | ||
| 36 | + FunctionStreamMessageHandler, | ||
| 37 | + HandlerModule, | ||
| 38 | + HandlerRegistry, | ||
| 39 | + HandlerSpec, | ||
| 40 | + MessageHandler, | ||
| 41 | + StreamMessageHandler, | ||
| 42 | +) | ||
| 43 | +from .security import OAuth2AccessControl | ||
| 33 | from .server.app import App | 44 | from .server.app import App |
| 34 | 45 | ||
| 35 | __version__ = "0.1.0" | 46 | __version__ = "0.1.0" |
| @@ -62,8 +73,18 @@ __all__ = [ | |||
| 62 | "AuditLogger", | 73 | "AuditLogger", |
| 63 | "LoggingAuditLogger", | 74 | "LoggingAuditLogger", |
| 64 | "NoopAuditLogger", | 75 | "NoopAuditLogger", |
| 76 | + # handlers | ||
| 77 | + "HandlerSpec", | ||
| 78 | + "MessageHandler", | ||
| 79 | + "StreamMessageHandler", | ||
| 80 | + "FunctionMessageHandler", | ||
| 81 | + "FunctionStreamMessageHandler", | ||
| 82 | + "HandlerRegistry", | ||
| 83 | + "HandlerModule", | ||
| 65 | # middleware | 84 | # middleware |
| 66 | "idempotency_guard", | 85 | "idempotency_guard", |
| 86 | + # security | ||
| 87 | + "OAuth2AccessControl", | ||
| 67 | # server | 88 | # server |
| 68 | "App", | 89 | "App", |
| 69 | ] | 90 | ] |