已合并
feat(auth): 集成联合认证与Manager登录 #427
feat(auth): 集成联合认证与Manager登录 #427
已合并
Wal1et创建于 26 天前
42 个文件变更+2721-187
@@ -115,6 +115,65 @@ Vite 开发服务器已配置代理,`/api` → `8765`、`/idp` → `8770`(
115> 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。 115> 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。
116`/web/invoke``/file-api` 依赖 Gateway / User Server,未启动时聊天相关功能不可用,管理面主体功能可正常使用。116`/web/invoke``/file-api` 依赖 Gateway / User Server,未启动时聊天相关功能不可用,管理面主体功能可正常使用。
117 117 
118+### 联合认证本地联调
119+ 
120+Manager 保留原有 OAuth2 密码登录和本地 JWT,同时支持通过可替换的联合
121+认证 Provider 接入企业身份。仓库当前没有真实企业 IdP 配置,因此只提供一个
122+默认关闭、显式标注为本地模拟的 Demo Provider。它用于验证完整应用链路,
123+不接收或验证 SAML XML,不能作为生产 SAML 实现。
124+ 
125+启动身份中心前设置:
126+ 
127+```env
128+IDENTITY_FEDERATION_DEMO_ENABLED=true
129+# 通过 Vite 或 manager-web 的 /idp 同源代理访问时保持默认值
130+IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX=/idp
131+# Demo 中映射为本地管理员的模拟企业用户组
132+IDENTITY_FEDERATION_DEMO_ADMIN_GROUP=enterprise-admins
133+```
134+ 
135+重启 `identity-center` 后,访问 `http://127.0.0.1:5273/auth`,登录框下方会出现
136+`Enterprise Demo SSO` 入口。从模拟企业页面登录后,身份中心会:
137+ 
138+1. 根据受信的 `connection_id + issuer + external_subject` 查找外部身份;
139+2. 首次登录时在一个数据库事务中创建本地虚拟组织、虚拟用户、外部身份映射和成员关系;
140+3. 根据本地受信规则将 Provider 已验证的 Claim 映射为本地角色,并同步 `is_admin`
141+4. 重复登录复用同一个本地 `user_id`,更新展示名、已验证属性和当前权限;
142+5. 若用户已不属于企业管理员组,下次登录会撤销其本地管理员权限;
143+6. 向浏览器返回短时、一次性换码,再由前端换取与本地登录完全相同的 access JWT 和 refresh token。
144+ 
145+Demo 登录页的 `Groups` 输入 `employees` 会得到普通用户,输入
146+`employees,enterprise-admins` 会得到管理员。这里模拟的是“Provider 已经验证过的企业
147+用户组 Claim”;回调中任意附加 `is_admin=true``role=admin` 都不会被信任。生产
148+SAML/OIDC Provider 必须先完成协议校验,再把验证后的 Claim 交给映射层。
149+ 
150+业务侧始终只消费身份中心签发的本地 JWT,不需要解析 SAML 或依赖具体企业
151+协议。接入真实 SAML 时,应实现 Service Framework 提供的异步
152+`FederationProvider` 接口,并完成签名、Issuer/Audience、`InResponseTo`、时间窗口和
153+重放防护等验证;Manager 的本地身份映射、JWT 和前端业务页无需更换。
154+ 
155+联合认证新增的身份库表如下:
156+ 
157+| 表 | 职责 |
158+|------|------|
159+| `federation_connection` | 保存受信连接与本地组织的稳定绑定 |
160+| `federated_identity` | 保存外部 Subject 到本地 `app_user.user_id` 的唯一映射 |
161+| `federation_role_mapping` | 保存受信 Claim 精确值到本地角色的映射规则 |
162+| `federation_login_state` | 保存有效期内的浏览器联合登录状态 |
163+| `federation_login_code` | 保存一次性换码的 SHA-256,不保存换码明文 |
164+ 
165+`federation_connection` 中的组织绑定不允许通过普通组织删除接口破坏。虚拟用户仍是
166+标准 `app_user`,因此可直接使用现有 `/me``/me/orgs`、前端角色分流及已挂载的
167+Manager 权限守卫。
168+ 
169+身份库各类数据按职责分离:`app_user` 是本地用户和最终权限的唯一业务主体;
170+`auth_identity` 只保存本地用户名/口令等认证凭据;`federated_identity` 只保存稳定的
171+外部身份绑定和最近一次经 Provider 验证的属性;`org``user_org_membership` 管理本地
172+组织目录;`auth_session` 管理可撤销的 refresh token;access JWT 自包含且不落库。
173+联合认证不会把企业内部组织直接等同于平台任意 `group_id`,而是由
174+`federation_connection` 明确绑定到一个受控的本地虚拟组织,避免企业目录命名与平台
175+业务组织发生碰撞。
176+ 
118---177---
119 178 
120## 生产 / 集成模式(统一入口)179## 生产 / 集成模式(统一入口)
@@ -166,6 +225,9 @@ IDENTITY_REST_HOST=0.0.0.0
166IDENTITY_REST_PORT=8770225IDENTITY_REST_PORT=8770
167IDENTITY_DB_TYPE=sqlite226IDENTITY_DB_TYPE=sqlite
168IDENTITY_SQLITE_PATH=identity.db227IDENTITY_SQLITE_PATH=identity.db
228+IDENTITY_FEDERATION_DEMO_ENABLED=false
229+IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX=/idp
230+IDENTITY_FEDERATION_DEMO_ADMIN_GROUP=enterprise-admins
169 231 
170# 管理 API232# 管理 API
171MANAGER_REST_HOST=0.0.0.0233MANAGER_REST_HOST=0.0.0.0
@@ -23,11 +23,13 @@ dependencies = [
23 "python-multipart>=0.0.9",23 "python-multipart>=0.0.9",
24 # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources]24 # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources]
25 "openjiuwen-runtime-foundation",25 "openjiuwen-runtime-foundation",
26+ "openjiuwen-runtime-service",
26 "asyncpg>=0.29",27 "asyncpg>=0.29",
27]28]
28 29 
29[project.optional-dependencies]30[project.optional-dependencies]
30dev = [31dev = [
32+ "httpx>=0.27",
31 "pytest>=9.0.2",33 "pytest>=9.0.2",
32 "pytest-asyncio>=1.3.0",34 "pytest-asyncio>=1.3.0",
33 "ruff>=0.6",35 "ruff>=0.6",
@@ -39,6 +41,7 @@ identity-center = "identity_center.main:main"
39# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取41# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取
40[tool.uv.sources]42[tool.uv.sources]
41openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true }43openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true }
44+openjiuwen-runtime-service = { path = "../../../service", editable = true }
42 45 
43[tool.setuptools.packages.find]46[tool.setuptools.packages.find]
44where = ["src"]47where = ["src"]
@@ -26,6 +26,14 @@ async def lifespan(application: FastAPI):
26 await db_handler.connect()26 await db_handler.connect()
27 await init_all_tables(db_handler)27 await init_all_tables(db_handler)
28 28 
29+ from openjiuwen_runtime.foundation.db.sqlalchemy_handler import SQLAlchemyHandler
30+ from identity_center.core.federation import IdentityFederationService
31+ 
32+ if not isinstance(db_handler, SQLAlchemyHandler):
33+ raise RuntimeError("identity federation requires a SQLAlchemy database handler")
34+ federation_service = await IdentityFederationService.create(db_handler, settings)
35+ application.state.federation_service = federation_service
36+ 
29 from identity_center.core.auth import seed_defaults37 from identity_center.core.auth import seed_defaults
30 38 
31 await seed_defaults(db_handler)39 await seed_defaults(db_handler)
@@ -45,6 +53,7 @@ async def lifespan(application: FastAPI):
45 access_ttl=settings.access_ttl_seconds,53 access_ttl=settings.access_ttl_seconds,
46 )54 )
47 yield55 yield
56+ await federation_service.close()
48 await db_handler.disconnect()57 await db_handler.disconnect()
49 _log.info("shutdown")58 _log.info("shutdown")
50 59 
@@ -105,6 +105,15 @@ class IdentityAuthService:
105 _log.info("[Auth] login.ok", user_id=user_id, provider=provider)105 _log.info("[Auth] login.ok", user_id=user_id, provider=provider)
106 return await self._issue_for(user)106 return await self._issue_for(user)
107 107 
108+ async def issue_for_user_id(self, user_id: str) -> dict[str, Any] | str:
109+ """Issue the normal local token bundle for an already authenticated user."""
110+ user = await self._h.get(_APP_USER, {"user_id": user_id})
111+ if user is None:
112+ return "bad_credentials"
113+ if str(getattr(user, "status", "")) != "active":
114+ return "disabled"
115+ return await self._issue_for(user)
116+ 
108 async def refresh(self, refresh_token: str) -> dict[str, Any] | str:117 async def refresh(self, refresh_token: str) -> dict[str, Any] | str:
109 """用 refresh 换新 access(并轮换 refresh)。失败返回 ``"invalid_refresh"``。"""118 """用 refresh 换新 access(并轮换 refresh)。失败返回 ``"invalid_refresh"``。"""
110 if not refresh_token:119 if not refresh_token:
@@ -0,0 +1,11 @@
1+"""Identity Center adapters for Service Framework federation contracts."""
2+ 
3+from .login_service import IdentityFederationService
4+from .provider import DemoFederationProvider
5+from .store import IdentityCenterFederatedIdentityStore
6+ 
7+__all__ = [
8+ "DemoFederationProvider",
9+ "IdentityCenterFederatedIdentityStore",
10+ "IdentityFederationService",
11+]
@@ -0,0 +1,456 @@
1+"""Browser federation flow for Identity Center."""
2+ 
3+from __future__ import annotations
4+ 
5+import asyncio
6+import hashlib
7+import secrets
8+from dataclasses import dataclass
9+from datetime import UTC, datetime, timedelta
10+from typing import Any
11+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
12+ 
13+from sqlalchemy import delete, insert, select, update
14+from sqlalchemy.exc import IntegrityError
15+ 
16+from openjiuwen_runtime.foundation.db.sqlalchemy_handler import SQLAlchemyHandler
17+from openjiuwen_runtime.service import (
18+ FederationBindingError,
19+ FederationConnection,
20+ FederationCoordinator,
21+ FederationError,
22+ FederationProvider,
23+ LocalPrincipal,
24+ SystemContext,
25+ UnknownFederationConnection,
26+)
27+ 
28+from identity_center.core.auth.service import IdentityAuthService
29+from identity_center.infrastructure.config import Settings
30+from identity_center.infrastructure.utils import utc_now
31+from identity_center.models.identity_models import (
32+ FEDERATION_CONNECTION_TABLE_DEF,
33+ FEDERATION_LOGIN_CODE_TABLE_DEF,
34+ FEDERATION_LOGIN_STATE_TABLE_DEF,
35+ FEDERATION_ROLE_MAPPING_TABLE_DEF,
36+)
37+ 
38+from .provider import DemoFederationProvider
39+from .store import IdentityCenterFederatedIdentityStore
40+ 
41+ 
42+@dataclass(frozen=True)
43+class FederationRoleMapping:
44+ claim_name: str
45+ claim_value: str
46+ local_role: str
47+ 
48+ 
49+@dataclass(frozen=True)
50+class ConfiguredFederation:
51+ connection: FederationConnection
52+ provider_type: str
53+ provider: FederationProvider
54+ role_mappings: tuple[FederationRoleMapping, ...] = ()
55+ 
56+ 
57+class IdentityFederationService:
58+ """Join external authentication to local JWT/refresh-token issuance."""
59+ 
60+ def __init__(
61+ self,
62+ *,
63+ handler: SQLAlchemyHandler,
64+ settings: Settings,
65+ registrations: tuple[ConfiguredFederation, ...],
66+ ) -> None:
67+ self._handler = handler
68+ self._settings = settings
69+ self._system_context = SystemContext(db=handler)
70+ self._store = IdentityCenterFederatedIdentityStore(handler)
71+ self._connections = {
72+ registration.connection.connection_id: registration
73+ for registration in registrations
74+ }
75+ self._coordinators = {
76+ registration.connection.connection_id: FederationCoordinator(
77+ provider=registration.provider,
78+ identity_store=self._store,
79+ connections={
80+ registration.connection.connection_id: registration.connection
81+ },
82+ )
83+ for registration in registrations
84+ }
85+ self._connection_table = handler.get_table(
86+ FEDERATION_CONNECTION_TABLE_DEF.table_name
87+ )
88+ self._state_table = handler.get_table(
89+ FEDERATION_LOGIN_STATE_TABLE_DEF.table_name
90+ )
91+ self._code_table = handler.get_table(FEDERATION_LOGIN_CODE_TABLE_DEF.table_name)
92+ self._role_mapping_table = handler.get_table(
93+ FEDERATION_ROLE_MAPPING_TABLE_DEF.table_name
94+ )
95+ 
96+ @classmethod
97+ async def create(
98+ cls,
99+ handler: SQLAlchemyHandler,
100+ settings: Settings,
101+ ) -> "IdentityFederationService":
102+ registrations = _configured_federations(settings)
103+ service = cls(
104+ handler=handler,
105+ settings=settings,
106+ registrations=registrations,
107+ )
108+ await service._persist_connections()
109+ return service
110+ 
111+ @property
112+ def connections(self) -> tuple[FederationConnection, ...]:
113+ return tuple(item.connection for item in self._connections.values())
114+ 
115+ @property
116+ def demo_enabled(self) -> bool:
117+ return bool(self._settings.federation_demo_enabled)
118+ 
119+ @property
120+ def demo_admin_group(self) -> str:
121+ return self._settings.federation_demo_admin_group.strip()
122+ 
123+ def public_path(self, path: str) -> str:
124+ normalized_path = "/" + str(path or "").lstrip("/")
125+ prefix = str(self._settings.federation_public_path_prefix or "").strip()
126+ prefix = prefix.rstrip("/")
127+ return f"{prefix}{normalized_path}" if prefix else normalized_path
128+ 
129+ async def begin_login(self, connection_id: str, return_to: str) -> str:
130+ coordinator = self._require_coordinator(connection_id)
131+ safe_return_to = _validate_return_to(return_to)
132+ request_id = secrets.token_urlsafe(32)
133+ now = utc_now()
134+ async with self._system_context.transaction() as session:
135+ await session.execute(
136+ delete(self._state_table).where(self._state_table.c.expires_at <= now)
137+ )
138+ await session.execute(
139+ insert(self._state_table).values(
140+ request_id=request_id,
141+ connection_id=connection_id,
142+ return_to=safe_return_to,
143+ created_at=now,
144+ expires_at=now
145+ + timedelta(
146+ seconds=self._settings.federation_request_ttl_seconds
147+ ),
148+ )
149+ )
150+ try:
151+ return await coordinator.begin_login(connection_id, request_id)
152+ except BaseException:
153+ await self._handler.delete(
154+ FEDERATION_LOGIN_STATE_TABLE_DEF.table_name,
155+ {"request_id": request_id},
156+ )
157+ raise
158+ 
159+ async def require_pending_request(
160+ self,
161+ connection_id: str,
162+ request_id: str,
163+ ) -> None:
164+ row = await self._handler.get(
165+ FEDERATION_LOGIN_STATE_TABLE_DEF.table_name,
166+ {"request_id": request_id},
167+ )
168+ if row is None or str(getattr(row, "connection_id", "")) != connection_id:
169+ raise FederationError("federation login request is missing or expired")
170+ if _as_utc(getattr(row, "expires_at", None)) <= utc_now():
171+ await self._handler.delete(
172+ FEDERATION_LOGIN_STATE_TABLE_DEF.table_name,
173+ {"request_id": request_id},
174+ )
175+ raise FederationError("federation login request is missing or expired")
176+ 
177+ async def complete_callback(
178+ self,
179+ connection_id: str,
180+ parameters: dict[str, str],
181+ ) -> tuple[str, LocalPrincipal]:
182+ coordinator = self._require_coordinator(connection_id)
183+ authentication = await coordinator.consume_callback(connection_id, parameters)
184+ return_to = await self._consume_request(
185+ connection_id,
186+ authentication.authorization_request_id,
187+ )
188+ principal = await coordinator.resolve_or_create(
189+ connection_id,
190+ authentication.identity,
191+ )
192+ code = await self._create_login_code(principal.user_id)
193+ return _append_query(return_to, {"federation_code": code}), principal
194+ 
195+ async def exchange_code(self, code: str) -> dict[str, Any] | str:
196+ code_hash = _hash_code(code)
197+ async with self._system_context.transaction() as session:
198+ row = await _one_mapping(
199+ session,
200+ select(self._code_table).where(
201+ self._code_table.c.code_hash == code_hash
202+ ),
203+ )
204+ if row is None:
205+ return "invalid_federation_code"
206+ deleted = await session.execute(
207+ delete(self._code_table).where(
208+ self._code_table.c.code_hash == code_hash
209+ )
210+ )
211+ if deleted.rowcount != 1:
212+ return "invalid_federation_code"
213+ if _as_utc(row["expires_at"]) <= utc_now():
214+ return "invalid_federation_code"
215+ user_id = str(row["user_id"])
216+ return await IdentityAuthService(self._handler).issue_for_user_id(user_id)
217+ 
218+ async def close(self) -> None:
219+ await self._store.close()
220+ 
221+ def _require_coordinator(self, connection_id: str) -> FederationCoordinator:
222+ coordinator = self._coordinators.get(str(connection_id or "").strip())
223+ if coordinator is None:
224+ raise UnknownFederationConnection(
225+ f"unknown federation connection: {connection_id or '<empty>'}"
226+ )
227+ return coordinator
228+ 
229+ async def _consume_request(self, connection_id: str, request_id: str) -> str:
230+ async with self._system_context.transaction() as session:
231+ row = await _one_mapping(
232+ session,
233+ select(self._state_table).where(
234+ self._state_table.c.request_id == request_id
235+ ),
236+ )
237+ if row is None or str(row["connection_id"]) != connection_id:
238+ raise FederationError("federation login request is missing or expired")
239+ deleted = await session.execute(
240+ delete(self._state_table).where(
241+ self._state_table.c.request_id == request_id
242+ )
243+ )
244+ if deleted.rowcount != 1:
245+ raise FederationError("federation login request is missing or expired")
246+ if _as_utc(row["expires_at"]) <= utc_now():
247+ raise FederationError("federation login request is missing or expired")
248+ return str(row["return_to"])
249+ 
250+ async def _create_login_code(self, user_id: str) -> str:
251+ code = secrets.token_urlsafe(32)
252+ now = utc_now()
253+ async with self._system_context.transaction() as session:
254+ await session.execute(
255+ delete(self._code_table).where(self._code_table.c.expires_at <= now)
256+ )
257+ await session.execute(
258+ insert(self._code_table).values(
259+ code_hash=_hash_code(code),
260+ user_id=user_id,
261+ created_at=now,
262+ expires_at=now
263+ + timedelta(seconds=self._settings.federation_code_ttl_seconds),
264+ )
265+ )
266+ return code
267+ 
268+ async def _persist_connections(self) -> None:
269+ for attempt in range(3):
270+ try:
271+ await self._persist_connections_once()
272+ return
273+ except IntegrityError:
274+ # Multiple Identity Center replicas may initialize the same trusted
275+ # connection at once. The unique keys elect a winner; the loser
276+ # retries and then reconciles the persisted configuration.
277+ if attempt == 2:
278+ raise
279+ await asyncio.sleep(0.01 * (attempt + 1))
280+ 
281+ async def _persist_connections_once(self) -> None:
282+ async with self._system_context.transaction() as session:
283+ for registration in self._connections.values():
284+ connection = registration.connection
285+ row = await _one_mapping(
286+ session,
287+ select(self._connection_table).where(
288+ self._connection_table.c.connection_id
289+ == connection.connection_id
290+ ),
291+ )
292+ now = utc_now()
293+ if row is None:
294+ await session.execute(
295+ insert(self._connection_table).values(
296+ connection_id=connection.connection_id,
297+ provider_type=registration.provider_type,
298+ issuer=connection.issuer,
299+ group_id=connection.organization_id,
300+ name=connection.organization_name,
301+ default_role=connection.default_role,
302+ status="active",
303+ created_at=now,
304+ updated_at=now,
305+ )
306+ )
307+ else:
308+ persisted_binding = (
309+ str(row["provider_type"]),
310+ str(row["issuer"]),
311+ str(row["group_id"]),
312+ )
313+ configured_binding = (
314+ registration.provider_type,
315+ connection.issuer,
316+ connection.organization_id,
317+ )
318+ if persisted_binding != configured_binding:
319+ raise FederationBindingError(
320+ "connection_id is already bound to different federation settings"
321+ )
322+ await session.execute(
323+ update(self._connection_table)
324+ .where(
325+ self._connection_table.c.connection_id
326+ == connection.connection_id
327+ )
328+ .values(
329+ name=connection.organization_name,
330+ default_role=connection.default_role,
331+ status="active",
332+ updated_at=now,
333+ )
334+ )
335+ 
336+ normalized_mappings = {
337+ (
338+ mapping.claim_name.strip(),
339+ mapping.claim_value.strip(),
340+ mapping.local_role.strip(),
341+ )
342+ for mapping in registration.role_mappings
343+ }
344+ if any(not all(mapping) for mapping in normalized_mappings):
345+ raise FederationBindingError(
346+ "federation role mappings must not contain empty values"
347+ )
348+ existing_result = await session.execute(
349+ select(self._role_mapping_table).where(
350+ self._role_mapping_table.c.connection_id
351+ == connection.connection_id
352+ )
353+ )
354+ existing_mappings = {
355+ (
356+ str(row["claim_name"]),
357+ str(row["claim_value"]),
358+ str(row["local_role"]),
359+ ): row
360+ for row in existing_result.mappings().all()
361+ }
362+ for mapping, row in existing_mappings.items():
363+ if mapping not in normalized_mappings:
364+ await session.execute(
365+ delete(self._role_mapping_table).where(
366+ self._role_mapping_table.c.id == row["id"]
367+ )
368+ )
369+ for claim_name, claim_value, local_role in sorted(
370+ normalized_mappings - set(existing_mappings)
371+ ):
372+ await session.execute(
373+ insert(self._role_mapping_table).values(
374+ connection_id=connection.connection_id,
375+ claim_name=claim_name,
376+ claim_value=claim_value,
377+ local_role=local_role,
378+ created_at=now,
379+ updated_at=now,
380+ )
381+ )
382+ 
383+ 
384+def _configured_federations(settings: Settings) -> tuple[ConfiguredFederation, ...]:
385+ if not settings.federation_demo_enabled:
386+ return ()
387+ connection = FederationConnection(
388+ connection_id="enterprise-demo",
389+ issuer="https://idp.enterprise-demo.example",
390+ organization_id="federated-enterprise-demo",
391+ organization_name="Enterprise Demo SSO",
392+ default_role="member",
393+ )
394+ admin_group = settings.federation_demo_admin_group.strip()
395+ role_mappings = (
396+ (
397+ FederationRoleMapping(
398+ claim_name="groups",
399+ claim_value=admin_group,
400+ local_role="admin",
401+ ),
402+ )
403+ if admin_group
404+ else ()
405+ )
406+ return (
407+ ConfiguredFederation(
408+ connection=connection,
409+ provider_type="demo",
410+ provider=DemoFederationProvider(settings.federation_public_path_prefix),
411+ role_mappings=role_mappings,
412+ ),
413+ )
414+ 
415+ 
416+def _validate_return_to(value: str) -> str:
417+ normalized = str(value or "/auth").strip() or "/auth"
418+ parsed = urlsplit(normalized)
419+ has_external_origin = bool(parsed.scheme or parsed.netloc)
420+ if has_external_origin or parsed.fragment or parsed.path != "/auth":
421+ raise FederationError("federation return_to must be the local /auth route")
422+ return urlunsplit(("", "", parsed.path, parsed.query, ""))
423+ 
424+ 
425+def _hash_code(code: str) -> str:
426+ normalized = str(code or "").strip()
427+ if not normalized:
428+ return ""
429+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
430+ 
431+ 
432+def _as_utc(value: Any) -> datetime:
433+ if not isinstance(value, datetime):
434+ raise FederationError("federation record has invalid expiration time")
435+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
436+ 
437+ 
438+def _append_query(url: str, values: dict[str, str]) -> str:
439+ parsed = urlsplit(url)
440+ query = parse_qsl(parsed.query, keep_blank_values=True)
441+ query.extend(values.items())
442+ return urlunsplit(
443+ (parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)
444+ )
445+ 
446+ 
447+async def _one_mapping(session: Any, statement: Any) -> Any | None:
448+ result = await session.execute(statement)
449+ return result.mappings().one_or_none()
450+ 
451+ 
452+__all__ = [
453+ "ConfiguredFederation",
454+ "FederationRoleMapping",
455+ "IdentityFederationService",
456+]
@@ -0,0 +1,84 @@
1+"""Development-only enterprise identity provider adapter."""
2+ 
3+from __future__ import annotations
4+ 
5+from collections.abc import Mapping
6+from urllib.parse import urlencode
7+ 
8+from openjiuwen_runtime.service import (
9+ ExternalIdentity,
10+ FederationAuthenticationResult,
11+ FederationConnection,
12+ FederationError,
13+ FederationProvider,
14+)
15+ 
16+ 
17+class DemoFederationProvider(FederationProvider):
18+ """Simulate a validated enterprise identity for local integration testing.
19+ 
20+ This provider deliberately does not accept SAML XML. Production deployments
21+ must register a provider that performs complete SAML or OIDC validation.
22+ """
23+ 
24+ def __init__(self, public_path_prefix: str = "/idp") -> None:
25+ self._public_path_prefix = _normalize_prefix(public_path_prefix)
26+ 
27+ async def begin_login(
28+ self,
29+ connection: FederationConnection,
30+ authorization_request_id: str,
31+ ) -> str:
32+ query = urlencode(
33+ {
34+ "connection_id": connection.connection_id,
35+ "authorization_request_id": authorization_request_id,
36+ }
37+ )
38+ return f"{self._public_path_prefix}/v1/auth/federation/demo/idp/login?{query}"
39+ 
40+ async def consume_callback(
41+ self,
42+ connection: FederationConnection,
43+ parameters: Mapping[str, str],
44+ ) -> FederationAuthenticationResult:
45+ request_id = str(parameters.get("authorization_request_id") or "").strip()
46+ employee_id = str(parameters.get("employee_id") or "").strip()
47+ display_name = str(parameters.get("display_name") or "").strip()
48+ email = str(parameters.get("email") or "").strip() or None
49+ groups = tuple(
50+ item.strip()
51+ for item in str(parameters.get("groups") or "").split(",")
52+ if item.strip()
53+ )
54+ if not request_id or not employee_id or not display_name:
55+ raise FederationError(
56+ "authorization_request_id, employee_id and display_name are required"
57+ )
58+ return FederationAuthenticationResult(
59+ authorization_request_id=request_id,
60+ identity=ExternalIdentity(
61+ connection_id=connection.connection_id,
62+ issuer=connection.issuer,
63+ external_subject=employee_id,
64+ display_name=display_name,
65+ email=email,
66+ attributes={
67+ "employee_id": employee_id,
68+ "email": email,
69+ "groups": list(groups),
70+ },
71+ ),
72+ )
73+ 
74+ 
75+def _normalize_prefix(value: str) -> str:
76+ normalized = str(value or "").strip().rstrip("/")
77+ if not normalized:
78+ return ""
79+ if not normalized.startswith("/") or normalized.startswith("//"):
80+ raise ValueError("federation public path prefix must be a relative URL path")
81+ return normalized
82+ 
83+ 
84+__all__ = ["DemoFederationProvider"]
@@ -0,0 +1,327 @@
1+"""Transactional federation identity store backed by the Identity Center DB."""
2+ 
3+from __future__ import annotations
4+ 
5+import asyncio
6+from typing import Any
7+from uuid import uuid4
8+ 
9+from sqlalchemy import insert, select, update
10+from sqlalchemy.exc import IntegrityError
11+ 
12+from openjiuwen_runtime.foundation.db.sqlalchemy_handler import SQLAlchemyHandler
13+from openjiuwen_runtime.service import (
14+ ExternalIdentity,
15+ FederatedIdentityStore,
16+ FederationBindingError,
17+ FederationConnection,
18+ LocalPrincipal,
19+ SystemContext,
20+)
21+ 
22+from identity_center.infrastructure.utils import utc_now
23+from identity_center.models.identity_models import (
24+ APP_USER_TABLE_DEF,
25+ FEDERATED_IDENTITY_TABLE_DEF,
26+ FEDERATION_CONNECTION_TABLE_DEF,
27+ FEDERATION_ROLE_MAPPING_TABLE_DEF,
28+ ORG_TABLE_DEF,
29+ USER_ORG_MEMBERSHIP_TABLE_DEF,
30+)
31+ 
32+ 
33+class IdentityCenterFederatedIdentityStore(FederatedIdentityStore):
34+ """Map an external subject to local user/org rows in one DB transaction."""
35+ 
36+ def __init__(self, handler: SQLAlchemyHandler) -> None:
37+ if not callable(getattr(handler, "session_factory", None)):
38+ raise TypeError("federated identity store requires a SQLAlchemy DB handler")
39+ self._handler = handler
40+ self._system_context = SystemContext(db=handler)
41+ self._users = handler.get_table(APP_USER_TABLE_DEF.table_name)
42+ self._orgs = handler.get_table(ORG_TABLE_DEF.table_name)
43+ self._memberships = handler.get_table(USER_ORG_MEMBERSHIP_TABLE_DEF.table_name)
44+ self._connections = handler.get_table(
45+ FEDERATION_CONNECTION_TABLE_DEF.table_name
46+ )
47+ self._identities = handler.get_table(FEDERATED_IDENTITY_TABLE_DEF.table_name)
48+ self._role_mappings = handler.get_table(
49+ FEDERATION_ROLE_MAPPING_TABLE_DEF.table_name
50+ )
51+ 
52+ async def resolve_or_create(
53+ self,
54+ connection: FederationConnection,
55+ identity: ExternalIdentity,
56+ ) -> LocalPrincipal:
57+ self.validate_binding(connection, identity)
58+ for attempt in range(3):
59+ try:
60+ return await self._resolve_or_create_once(connection, identity)
61+ except IntegrityError:
62+ # Concurrent first login: one transaction wins the unique external
63+ # identity key. Retry the complete operation so the current verified
64+ # claims still refresh the user's profile and effective role.
65+ if attempt == 2:
66+ raise
67+ await asyncio.sleep(0.01 * (attempt + 1))
68+ raise AssertionError("unreachable")
69+ 
70+ async def find(
71+ self,
72+ *,
73+ connection_id: str,
74+ issuer: str,
75+ external_subject: str,
76+ ) -> LocalPrincipal | None:
77+ async with self._system_context.transaction() as session:
78+ identity_row = await _one_mapping(
79+ session,
80+ select(self._identities).where(
81+ self._identities.c.connection_id == connection_id,
82+ self._identities.c.issuer == issuer,
83+ self._identities.c.external_subject == external_subject,
84+ ),
85+ )
86+ if identity_row is None:
87+ return None
88+ connection_row = await _one_mapping(
89+ session,
90+ select(self._connections).where(
91+ self._connections.c.connection_id == connection_id
92+ ),
93+ )
94+ user_row = await _one_mapping(
95+ session,
96+ select(self._users).where(
97+ self._users.c.user_id == identity_row["user_id"]
98+ ),
99+ )
100+ if connection_row is None or user_row is None:
101+ raise FederationBindingError(
102+ "federated identity references missing connection or user"
103+ )
104+ roles = await self._resolve_roles(
105+ session,
106+ connection_id,
107+ connection_row,
108+ identity_row.get("attributes"),
109+ )
110+ return _principal(
111+ connection_row,
112+ user_row,
113+ identity_row.get("attributes"),
114+ roles,
115+ )
116+ 
117+ async def close(self) -> None:
118+ """The Identity Center owns the shared database lifecycle."""
119+ 
120+ async def _resolve_or_create_once(
121+ self,
122+ connection: FederationConnection,
123+ identity: ExternalIdentity,
124+ ) -> LocalPrincipal:
125+ async with self._system_context.transaction() as session:
126+ connection_row = await _one_mapping(
127+ session,
128+ select(self._connections).where(
129+ self._connections.c.connection_id == connection.connection_id
130+ ),
131+ )
132+ _validate_connection_row(connection, connection_row)
133+ await self._ensure_active_org(session, connection)
134+ roles = await self._resolve_roles(
135+ session,
136+ connection.connection_id,
137+ connection_row,
138+ identity.attributes,
139+ )
140+ is_admin = "admin" in roles
141+ 
142+ identity_row = await _one_mapping(
143+ session,
144+ select(self._identities).where(
145+ self._identities.c.connection_id == identity.connection_id,
146+ self._identities.c.issuer == identity.issuer,
147+ self._identities.c.external_subject == identity.external_subject,
148+ ),
149+ )
150+ now = utc_now()
151+ if identity_row is None:
152+ user_id = f"fuser_{uuid4().hex}"
153+ await session.execute(
154+ insert(self._users).values(
155+ user_id=user_id,
156+ display_name=identity.display_name,
157+ is_admin=is_admin,
158+ status="active",
159+ created_at=now,
160+ updated_at=now,
161+ )
162+ )
163+ await session.execute(
164+ insert(self._identities).values(
165+ connection_id=identity.connection_id,
166+ issuer=identity.issuer,
167+ external_subject=identity.external_subject,
168+ user_id=user_id,
169+ attributes=identity.attributes,
170+ first_login_at=now,
171+ last_login_at=now,
172+ )
173+ )
174+ await session.execute(
175+ insert(self._memberships).values(
176+ user_id=user_id,
177+ group_id=connection.organization_id,
178+ created_at=now,
179+ )
180+ )
181+ else:
182+ user_id = str(identity_row["user_id"])
183+ user_row = await _one_mapping(
184+ session,
185+ select(self._users).where(self._users.c.user_id == user_id),
186+ )
187+ if user_row is None:
188+ raise FederationBindingError(
189+ "federated identity references a missing local user"
190+ )
191+ await session.execute(
192+ update(self._users)
193+ .where(self._users.c.user_id == user_id)
194+ .values(
195+ display_name=identity.display_name,
196+ is_admin=is_admin,
197+ updated_at=now,
198+ )
199+ )
200+ await session.execute(
201+ update(self._identities)
202+ .where(self._identities.c.id == identity_row["id"])
203+ .values(attributes=identity.attributes, last_login_at=now)
204+ )
205+ membership = await _one_mapping(
206+ session,
207+ select(self._memberships).where(
208+ self._memberships.c.user_id == user_id,
209+ self._memberships.c.group_id == connection.organization_id,
210+ ),
211+ )
212+ if membership is None:
213+ await session.execute(
214+ insert(self._memberships).values(
215+ user_id=user_id,
216+ group_id=connection.organization_id,
217+ created_at=now,
218+ )
219+ )
220+ 
221+ user_row = await _one_mapping(
222+ session,
223+ select(self._users).where(self._users.c.user_id == user_id),
224+ )
225+ if user_row is None:
226+ raise FederationBindingError("local user was not persisted")
227+ return _principal(connection_row, user_row, identity.attributes, roles)
228+ 
229+ async def _resolve_roles(
230+ self,
231+ session: Any,
232+ connection_id: str,
233+ connection_row: Any,
234+ attributes: dict[str, Any] | None,
235+ ) -> tuple[str, ...]:
236+ roles = {str(connection_row["default_role"])}
237+ claims = attributes if isinstance(attributes, dict) else {}
238+ result = await session.execute(
239+ select(self._role_mappings).where(
240+ self._role_mappings.c.connection_id == connection_id
241+ )
242+ )
243+ for mapping in result.mappings().all():
244+ claim_values = _claim_values(claims.get(str(mapping["claim_name"])))
245+ if str(mapping["claim_value"]) in claim_values:
246+ roles.add(str(mapping["local_role"]))
247+ return tuple(sorted(role for role in roles if role))
248+ 
249+ async def _ensure_active_org(
250+ self,
251+ session: Any,
252+ connection: FederationConnection,
253+ ) -> None:
254+ org = await _one_mapping(
255+ session,
256+ select(self._orgs).where(
257+ self._orgs.c.group_id == connection.organization_id
258+ ),
259+ )
260+ if org is None:
261+ now = utc_now()
262+ await session.execute(
263+ insert(self._orgs).values(
264+ group_id=connection.organization_id,
265+ name=connection.organization_name,
266+ status="active",
267+ created_at=now,
268+ updated_at=now,
269+ )
270+ )
271+ return
272+ if str(org["status"]) != "active":
273+ raise FederationBindingError("federation organization is not active")
274+ 
275+ 
276+async def _one_mapping(session: Any, statement: Any) -> Any | None:
277+ result = await session.execute(statement)
278+ return result.mappings().one_or_none()
279+ 
280+ 
281+def _validate_connection_row(
282+ connection: FederationConnection,
283+ row: Any | None,
284+) -> None:
285+ if row is None:
286+ raise FederationBindingError("federation connection is not persisted")
287+ expected = (
288+ connection.issuer,
289+ connection.organization_id,
290+ connection.default_role,
291+ )
292+ actual = (str(row["issuer"]), str(row["group_id"]), str(row["default_role"]))
293+ if actual != expected:
294+ raise FederationBindingError(
295+ "persisted federation connection does not match trusted configuration"
296+ )
297+ if str(row["status"]) != "active":
298+ raise FederationBindingError("federation connection is not active")
299+ 
300+ 
301+def _principal(
302+ connection_row: Any,
303+ user_row: Any,
304+ attributes: dict[str, Any] | None,
305+ roles: tuple[str, ...],
306+) -> LocalPrincipal:
307+ claims = attributes if isinstance(attributes, dict) else {}
308+ return LocalPrincipal(
309+ user_id=str(user_row["user_id"]),
310+ organization_id=str(connection_row["group_id"]),
311+ display_name=str(user_row["display_name"]),
312+ email=str(claims.get("email") or "") or None,
313+ roles=roles,
314+ auth_source=f"federated:{connection_row['connection_id']}",
315+ )
316+ 
317+ 
318+def _claim_values(value: Any) -> set[str]:
319+ if isinstance(value, str):
320+ normalized = value.strip()
321+ return {normalized} if normalized else set()
322+ if isinstance(value, (list, tuple, set, frozenset)):
323+ return {str(item).strip() for item in value if str(item).strip()}
324+ return set()
325+ 
326+ 
327+__all__ = ["IdentityCenterFederatedIdentityStore"]
@@ -23,6 +23,9 @@ from identity_center.models.identity_models import (
23 APP_USER_TABLE_DEF,23 APP_USER_TABLE_DEF,
24 AUTH_IDENTITY_TABLE_DEF,24 AUTH_IDENTITY_TABLE_DEF,
25 AUTH_SESSION_TABLE_DEF,25 AUTH_SESSION_TABLE_DEF,
26+ FEDERATED_IDENTITY_TABLE_DEF,
27+ FEDERATION_CONNECTION_TABLE_DEF,
28+ FEDERATION_LOGIN_CODE_TABLE_DEF,
26 NO_ORG_GROUP_ID,29 NO_ORG_GROUP_ID,
27 ORG_TABLE_DEF,30 ORG_TABLE_DEF,
28 USER_ORG_MEMBERSHIP_TABLE_DEF,31 USER_ORG_MEMBERSHIP_TABLE_DEF,
@@ -33,6 +36,9 @@ _log = get_logger(__name__)
33_APP_USER = APP_USER_TABLE_DEF.table_name36_APP_USER = APP_USER_TABLE_DEF.table_name
34_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name37_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name
35_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name38_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name
39+_FEDERATED_IDENTITY = FEDERATED_IDENTITY_TABLE_DEF.table_name
40+_FEDERATION_CONNECTION = FEDERATION_CONNECTION_TABLE_DEF.table_name
41+_FEDERATION_LOGIN_CODE = FEDERATION_LOGIN_CODE_TABLE_DEF.table_name
36_ORG = ORG_TABLE_DEF.table_name42_ORG = ORG_TABLE_DEF.table_name
37_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name43_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name
38_LOCAL = "local"44_LOCAL = "local"
@@ -122,6 +128,14 @@ class OrgService:
122 raise ValueError("cannot delete the reserved '无组织' org")128 raise ValueError("cannot delete the reserved '无组织' org")
123 if await self._h.get(_ORG, {"group_id": group_id}) is None:129 if await self._h.get(_ORG, {"group_id": group_id}) is None:
124 return False130 return False
131+ bound_connections = await self._h.list_records(
132+ _FEDERATION_CONNECTION,
133+ {"group_id": group_id},
134+ limit=1,
135+ offset=0,
136+ )
137+ if bound_connections:
138+ raise ValueError("cannot delete an org bound to a federation connection")
125 await _delete_where(self._h, _MEMBERSHIP, {"group_id": group_id}, "id")139 await _delete_where(self._h, _MEMBERSHIP, {"group_id": group_id}, "id")
126 # bot 可见性(org scope)在管理库,由 claw_manager 侧自行清理(跨库,不在此处理)。140 # bot 可见性(org scope)在管理库,由 claw_manager 侧自行清理(跨库,不在此处理)。
127 await self._h.delete(_ORG, {"group_id": group_id})141 await self._h.delete(_ORG, {"group_id": group_id})
@@ -269,6 +283,8 @@ class UserService:
269 return False283 return False
270 await _delete_where(self._h, _AUTH_IDENTITY, {"user_id": user_id}, "id")284 await _delete_where(self._h, _AUTH_IDENTITY, {"user_id": user_id}, "id")
271 await _delete_where(self._h, _AUTH_SESSION, {"user_id": user_id}, "refresh_token")285 await _delete_where(self._h, _AUTH_SESSION, {"user_id": user_id}, "refresh_token")
286+ await _delete_where(self._h, _FEDERATION_LOGIN_CODE, {"user_id": user_id}, "code_hash")
287+ await _delete_where(self._h, _FEDERATED_IDENTITY, {"user_id": user_id}, "id")
272 await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id}, "id")288 await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id}, "id")
273 await self._h.delete(_APP_USER, {"user_id": user_id})289 await self._h.delete(_APP_USER, {"user_id": user_id})
274 _log.info("[IAM] user.delete", user_id=user_id)290 _log.info("[IAM] user.delete", user_id=user_id)
@@ -45,6 +45,28 @@ class Settings(BaseSettings):
45 refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL")45 refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL")
46 # JWT 签名密钥落身份库(表 identity_jwt_signing_key,生成一次→落库→多副本读同一行)。46 # JWT 签名密钥落身份库(表 identity_jwt_signing_key,生成一次→落库→多副本读同一行)。
47 47 
48+ # ---- 联合认证(当前仓库仅提供显式开启的本地 Demo Provider)----
49+ federation_demo_enabled: bool = Field(
50+ default=False,
51+ validation_alias="IDENTITY_FEDERATION_DEMO_ENABLED",
52+ )
53+ federation_public_path_prefix: str = Field(
54+ default="/idp",
55+ validation_alias="IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX",
56+ )
57+ federation_request_ttl_seconds: int = Field(
58+ default=300,
59+ validation_alias="IDENTITY_FEDERATION_REQUEST_TTL",
60+ )
61+ federation_code_ttl_seconds: int = Field(
62+ default=60,
63+ validation_alias="IDENTITY_FEDERATION_CODE_TTL",
64+ )
65+ federation_demo_admin_group: str = Field(
66+ default="enterprise-admins",
67+ validation_alias="IDENTITY_FEDERATION_DEMO_ADMIN_GROUP",
68+ )
69+ 
48 # ---- 引导播种 ----70 # ---- 引导播种 ----
49 seed_admin: bool = Field(default=True, validation_alias="IDENTITY_SEED_ADMIN")71 seed_admin: bool = Field(default=True, validation_alias="IDENTITY_SEED_ADMIN")
50 seed_user1: bool = Field(default=True, validation_alias="IDENTITY_SEED_USER1")72 seed_user1: bool = Field(default=True, validation_alias="IDENTITY_SEED_USER1")
@@ -1,8 +1,9 @@
1"""身份服务表定义:用户 / 认证身份 / 刷新会话 / 组织 / 成员。1"""身份服务表定义:用户 / 认证身份 / 刷新会话 / 组织 / 成员。
2 2 
3权威的"人 + 凭据 + 目录(组织/成员)"数据源,独立于 claw_manager 管理库。3权威的"人 + 凭据 + 目录(组织/成员)"数据源,独立于 claw_manager 管理库。
4-认证与身份解耦:``app_user`` 存身份/角色,``auth_identity`` 存凭据/外部 IdP4+认证与身份解耦:``app_user`` 存身份/角色,``auth_identity`` 存本地口令等可直接
5-(二次开发新增 provider 不动)。bot / 可见性 / 模板等平台配置留在管理库。5+认证的身份。企联合身份使用独立的受信连接与外部身份映射,避免把 issuer
6+connection_id 编码进 provider 字符串。bot / 可见性 / 模板等平台配置留在管理库。
6"""7"""
7 8 
8from __future__ import annotations9from __future__ import annotations
@@ -28,7 +29,8 @@ APP_USER_TABLE_DEF = TableDefinition(
28 ],29 ],
29)30)
30 31 
31-# 认证身份:一个 user 可挂多种登录方式(local / oidc / ldap...)。换登录式只动这张表。32+# 凭据型认证身份:当前保存 local 用户名/口令;企业联合身份使用下独立映射表。
33+# 一个 user 可挂多种由身份中心直接校验的凭据,换凭据不改业务主体。
32AUTH_IDENTITY_TABLE_DEF = TableDefinition(34AUTH_IDENTITY_TABLE_DEF = TableDefinition(
33 table_name="auth_identity",35 table_name="auth_identity",
34 columns=[36 columns=[
@@ -103,6 +105,97 @@ IDENTITY_JWT_SIGNING_KEY_TABLE_DEF = TableDefinition(
103 ],105 ],
104)106)
105 107 
108+# 联合认证连接:可信 issuer 与一个本地组织(group_id)的稳定绑定。
109+FEDERATION_CONNECTION_TABLE_DEF = TableDefinition(
110+ table_name="federation_connection",
111+ columns=[
112+ ColumnDefinition("connection_id", "string", length=64, primary_key=True, nullable=False),
113+ ColumnDefinition("provider_type", "string", length=32, nullable=False),
114+ ColumnDefinition("issuer", "string", length=512, nullable=False),
115+ ColumnDefinition("group_id", "string", length=64, nullable=False),
116+ ColumnDefinition("name", "string", length=128, nullable=False),
117+ ColumnDefinition("default_role", "string", length=32, nullable=False, default="member"),
118+ ColumnDefinition("status", "string", length=16, nullable=False, default="active"),
119+ ColumnDefinition("created_at", "datetime", nullable=False),
120+ ColumnDefinition("updated_at", "datetime", nullable=False),
121+ ],
122+ indexes=[
123+ IndexDefinition(["issuer"], unique=False),
124+ IndexDefinition(["group_id"], unique=False),
125+ ],
126+)
127+ 
128+# 稳定外部身份键。一个外部主体只映射一个本地用户;同一用户可绑定多个外部身份。
129+FEDERATED_IDENTITY_TABLE_DEF = TableDefinition(
130+ table_name="federated_identity",
131+ columns=[
132+ ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False),
133+ ColumnDefinition("connection_id", "string", length=64, nullable=False),
134+ ColumnDefinition("issuer", "string", length=512, nullable=False),
135+ ColumnDefinition("external_subject", "string", length=256, nullable=False),
136+ ColumnDefinition("user_id", "string", length=64, nullable=False),
137+ ColumnDefinition("attributes", "json", nullable=False),
138+ ColumnDefinition("first_login_at", "datetime", nullable=False),
139+ ColumnDefinition("last_login_at", "datetime", nullable=False),
140+ ],
141+ indexes=[
142+ IndexDefinition(
143+ ["connection_id", "issuer", "external_subject"],
144+ unique=True,
145+ ),
146+ IndexDefinition(["user_id"], unique=False),
147+ ],
148+)
149+ 
150+# 可审计的受信授权映射:Provider 验证后的 Claim 精确值 -> 本地角色。
151+# 回调携带的任意 role/is_admin 不会被直接信任。
152+FEDERATION_ROLE_MAPPING_TABLE_DEF = TableDefinition(
153+ table_name="federation_role_mapping",
154+ columns=[
155+ ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False),
156+ ColumnDefinition("connection_id", "string", length=64, nullable=False),
157+ ColumnDefinition("claim_name", "string", length=128, nullable=False),
158+ ColumnDefinition("claim_value", "string", length=256, nullable=False),
159+ ColumnDefinition("local_role", "string", length=32, nullable=False),
160+ ColumnDefinition("created_at", "datetime", nullable=False),
161+ ColumnDefinition("updated_at", "datetime", nullable=False),
162+ ],
163+ indexes=[
164+ IndexDefinition(
165+ ["connection_id", "claim_name", "claim_value", "local_role"],
166+ unique=True,
167+ ),
168+ IndexDefinition(["connection_id"], unique=False),
169+ ],
170+)
171+ 
172+# 浏览器联合登录状态及一次性换码。只保存 code 的 SHA-256,不保存明文。
173+FEDERATION_LOGIN_STATE_TABLE_DEF = TableDefinition(
174+ table_name="federation_login_state",
175+ columns=[
176+ ColumnDefinition("request_id", "string", length=128, primary_key=True, nullable=False),
177+ ColumnDefinition("connection_id", "string", length=64, nullable=False),
178+ ColumnDefinition("return_to", "string", length=512, nullable=False),
179+ ColumnDefinition("created_at", "datetime", nullable=False),
180+ ColumnDefinition("expires_at", "datetime", nullable=False),
181+ ],
182+ indexes=[IndexDefinition(["expires_at"], unique=False)],
183+)
184+ 
185+FEDERATION_LOGIN_CODE_TABLE_DEF = TableDefinition(
186+ table_name="federation_login_code",
187+ columns=[
188+ ColumnDefinition("code_hash", "string", length=64, primary_key=True, nullable=False),
189+ ColumnDefinition("user_id", "string", length=64, nullable=False),
190+ ColumnDefinition("created_at", "datetime", nullable=False),
191+ ColumnDefinition("expires_at", "datetime", nullable=False),
192+ ],
193+ indexes=[
194+ IndexDefinition(["user_id"], unique=False),
195+ IndexDefinition(["expires_at"], unique=False),
196+ ],
197+)
198+ 
106IDENTITY_TABLE_DEFINITIONS = (199IDENTITY_TABLE_DEFINITIONS = (
107 APP_USER_TABLE_DEF,200 APP_USER_TABLE_DEF,
108 AUTH_IDENTITY_TABLE_DEF,201 AUTH_IDENTITY_TABLE_DEF,
@@ -110,4 +203,9 @@ IDENTITY_TABLE_DEFINITIONS = (
110 ORG_TABLE_DEF,203 ORG_TABLE_DEF,
111 USER_ORG_MEMBERSHIP_TABLE_DEF,204 USER_ORG_MEMBERSHIP_TABLE_DEF,
112 IDENTITY_JWT_SIGNING_KEY_TABLE_DEF,205 IDENTITY_JWT_SIGNING_KEY_TABLE_DEF,
206+ FEDERATION_CONNECTION_TABLE_DEF,
207+ FEDERATED_IDENTITY_TABLE_DEF,
208+ FEDERATION_ROLE_MAPPING_TABLE_DEF,
209+ FEDERATION_LOGIN_STATE_TABLE_DEF,
210+ FEDERATION_LOGIN_CODE_TABLE_DEF,
113)211)
@@ -0,0 +1,154 @@
1+"""Browser routes joining enterprise federation to local OAuth2/JWT tokens."""
2+ 
3+from __future__ import annotations
4+ 
5+import html
6+from typing import Annotated
7+ 
8+from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
9+from fastapi.responses import HTMLResponse, RedirectResponse
10+from openjiuwen_runtime.service import (
11+ FederationError,
12+ UnknownFederationConnection,
13+)
14+ 
15+from identity_center.core.federation import IdentityFederationService
16+from identity_center.schemas.auth_schemas import TokenResponse
17+from identity_center.schemas.federation_schemas import (
18+ FederationCodeExchangeBody,
19+ FederationConnectionOut,
20+ FederationConnectionsOut,
21+)
22+ 
23+federation_router = APIRouter()
24+ 
25+ 
26+def get_federation_service(request: Request) -> IdentityFederationService:
27+ service = getattr(request.app.state, "federation_service", None)
28+ if not isinstance(service, IdentityFederationService):
29+ raise HTTPException(
30+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
31+ detail="federation service is unavailable",
32+ )
33+ return service
34+ 
35+ 
36+_Service = Annotated[IdentityFederationService, Depends(get_federation_service)]
37+ 
38+ 
39+@federation_router.get("/connections", response_model=FederationConnectionsOut)
40+async def list_connections(service: _Service):
41+ """List enabled enterprise login choices without exposing issuer details."""
42+ return FederationConnectionsOut(
43+ connections=[
44+ FederationConnectionOut(
45+ connection_id=connection.connection_id,
46+ name=connection.organization_name,
47+ )
48+ for connection in service.connections
49+ ]
50+ )
51+ 
52+ 
53+@federation_router.get("/{connection_id}/login")
54+async def begin_login(
55+ connection_id: str,
56+ service: _Service,
57+ return_to: str = Query(default="/auth"),
58+):
59+ try:
60+ login_url = await service.begin_login(connection_id, return_to)
61+ except UnknownFederationConnection as exc:
62+ raise HTTPException(status_code=404, detail=str(exc)) from exc
63+ except FederationError as exc:
64+ raise HTTPException(status_code=400, detail=str(exc)) from exc
65+ return RedirectResponse(login_url, status_code=status.HTTP_303_SEE_OTHER)
66+ 
67+ 
68+@federation_router.get(
69+ "/demo/idp/login",
70+ response_class=HTMLResponse,
71+ include_in_schema=False,
72+)
73+async def demo_login(
74+ connection_id: str,
75+ authorization_request_id: str,
76+ service: _Service,
77+):
78+ """Render the explicitly labelled development-only enterprise login form."""
79+ if not service.demo_enabled or connection_id != "enterprise-demo":
80+ raise HTTPException(status_code=404, detail="demo federation is disabled")
81+ try:
82+ await service.require_pending_request(
83+ connection_id,
84+ authorization_request_id,
85+ )
86+ except FederationError as exc:
87+ raise HTTPException(status_code=400, detail=str(exc)) from exc
88+ safe_connection = html.escape(connection_id, quote=True)
89+ safe_request = html.escape(authorization_request_id, quote=True)
90+ safe_admin_group = html.escape(service.demo_admin_group)
91+ action = html.escape(
92+ service.public_path(f"/v1/auth/federation/{connection_id}/callback"),
93+ quote=True,
94+ )
95+ return HTMLResponse(
96+ f"""<!doctype html>
97+<html><head><meta charset="utf-8"><title>Enterprise Demo IdP</title>
98+<style>
99+body{{font-family:system-ui;max-width:460px;margin:60px auto;color:#222}}
100+.card{{border:1px solid #ddd;border-radius:12px;padding:24px}}
101+label,input,button{{display:block;width:100%;box-sizing:border-box}}
102+input{{padding:10px;margin:6px 0 14px}}button{{padding:11px}}
103+.warning{{background:#fff3cd;padding:10px;border-radius:6px;font-size:14px}}
104+</style></head><body><div class="card">
105+<h2>Enterprise Demo IdP</h2>
106+<p class="warning">Local simulation only. No SAML XML is accepted or verified.</p>
107+<form method="post" action="{action}">
108+<input type="hidden" name="authorization_request_id" value="{safe_request}">
109+<input type="hidden" name="connection_id" value="{safe_connection}">
110+<label>Employee ID<input name="employee_id" value="employee-10086" required></label>
111+<label>Display name<input name="display_name" value="Enterprise Alice" required></label>
112+<label>Email<input name="email" value="alice@enterprise.example"></label>
113+<label>Groups (comma-separated)<input name="groups" value="employees"></label>
114+<p>Use <code>{safe_admin_group}</code> to simulate a verified enterprise admin group.</p>
115+<button type="submit">Enterprise sign in</button></form>
116+</div></body></html>"""
117+ )
118+ 
119+ 
120+@federation_router.post("/{connection_id}/callback")
121+async def complete_login(connection_id: str, request: Request, service: _Service):
122+ form_data = await request.form()
123+ parameters = {key: str(value) for key, value in form_data.items()}
124+ try:
125+ redirect_url, _ = await service.complete_callback(
126+ connection_id,
127+ parameters,
128+ )
129+ except UnknownFederationConnection as exc:
130+ raise HTTPException(status_code=404, detail=str(exc)) from exc
131+ except FederationError as exc:
132+ raise HTTPException(status_code=400, detail=str(exc)) from exc
133+ return RedirectResponse(redirect_url, status_code=status.HTTP_303_SEE_OTHER)
134+ 
135+ 
136+@federation_router.post("/exchange", response_model=TokenResponse)
137+async def exchange_code(body: FederationCodeExchangeBody, service: _Service):
138+ result = await service.exchange_code(body.code)
139+ if isinstance(result, str):
140+ status_code = (
141+ status.HTTP_403_FORBIDDEN
142+ if result == "disabled"
143+ else status.HTTP_401_UNAUTHORIZED
144+ )
145+ raise HTTPException(status_code=status_code, detail=result)
146+ return TokenResponse(
147+ access_token=result["access_token"],
148+ token_type=result["token_type"],
149+ expires_in=result["expires_in"],
150+ refresh_token=result["refresh_token"],
151+ )
152+ 
153+ 
154+__all__ = ["federation_router", "get_federation_service"]
@@ -5,12 +5,18 @@ from __future__ import annotations
5from fastapi import APIRouter, FastAPI5from fastapi import APIRouter, FastAPI
6 6 
7from identity_center.routers.auth_routers import auth_router7from identity_center.routers.auth_routers import auth_router
8+from identity_center.routers.federation_routers import federation_router
8from identity_center.routers.iam_routers import org_router, user_router9from identity_center.routers.iam_routers import org_router, user_router
9 10 
10 11 
11def router_register(app: FastAPI) -> None:12def router_register(app: FastAPI) -> None:
12 v1 = APIRouter(prefix="/v1")13 v1 = APIRouter(prefix="/v1")
13 v1.include_router(auth_router, prefix="/auth", tags=["Auth"])14 v1.include_router(auth_router, prefix="/auth", tags=["Auth"])
15+ v1.include_router(
16+ federation_router,
17+ prefix="/auth/federation",
18+ tags=["Auth · Federation"],
19+ )
14 v1.include_router(org_router, prefix="/orgs", tags=["Directory · Orgs"])20 v1.include_router(org_router, prefix="/orgs", tags=["Directory · Orgs"])
15 v1.include_router(user_router, prefix="/users", tags=["Directory · Users"])21 v1.include_router(user_router, prefix="/users", tags=["Directory · Users"])
16 app.include_router(v1)22 app.include_router(v1)
@@ -0,0 +1,25 @@
1+"""Request/response models for browser federation."""
2+ 
3+from __future__ import annotations
4+ 
5+from pydantic import BaseModel, Field
6+ 
7+ 
8+class FederationConnectionOut(BaseModel):
9+ connection_id: str
10+ name: str
11+ 
12+ 
13+class FederationConnectionsOut(BaseModel):
14+ connections: list[FederationConnectionOut]
15+ 
16+ 
17+class FederationCodeExchangeBody(BaseModel):
18+ code: str = Field(min_length=1, max_length=256)
19+ 
20+ 
21+__all__ = [
22+ "FederationCodeExchangeBody",
23+ "FederationConnectionOut",
24+ "FederationConnectionsOut",
25+]
@@ -0,0 +1,142 @@
1+"""System test for the browser federation flow and local token boundary."""
2+ 
3+from __future__ import annotations
4+ 
5+from urllib.parse import parse_qs, urlsplit
6+ 
7+from fastapi.testclient import TestClient
8+ 
9+from identity_center.app import create_app
10+from identity_center.infrastructure.config import settings
11+ 
12+ 
13+def _query_value(url: str, name: str) -> str:
14+ values = parse_qs(urlsplit(url).query).get(name)
15+ assert values
16+ return values[0]
17+ 
18+ 
19+def test_federation_http_flow_issues_normal_identity_token(tmp_path, monkeypatch):
20+ monkeypatch.setattr(settings, "db_type", "sqlite")
21+ monkeypatch.setattr(settings, "sqlite_path", str(tmp_path / "identity.db"))
22+ monkeypatch.setattr(settings, "federation_demo_enabled", True)
23+ monkeypatch.setattr(settings, "federation_public_path_prefix", "")
24+ monkeypatch.setattr(settings, "seed_admin", False)
25+ monkeypatch.setattr(settings, "seed_user1", False)
26+ 
27+ with TestClient(create_app()) as client:
28+ connections = client.get("/v1/auth/federation/connections")
29+ assert connections.status_code == 200
30+ assert connections.json() == {
31+ "connections": [
32+ {
33+ "connection_id": "enterprise-demo",
34+ "name": "Enterprise Demo SSO",
35+ }
36+ ]
37+ }
38+ 
39+ started = client.get(
40+ "/v1/auth/federation/enterprise-demo/login",
41+ params={"return_to": "/auth"},
42+ follow_redirects=False,
43+ )
44+ assert started.status_code == 303
45+ upstream_url = started.headers["location"]
46+ assert upstream_url.startswith("/v1/auth/federation/demo/idp/login?")
47+ 
48+ provider_page = client.get(upstream_url)
49+ assert provider_page.status_code == 200
50+ assert "Local simulation only" in provider_page.text
51+ 
52+ callback = client.post(
53+ "/v1/auth/federation/enterprise-demo/callback",
54+ data={
55+ "authorization_request_id": _query_value(
56+ upstream_url,
57+ "authorization_request_id",
58+ ),
59+ "connection_id": "enterprise-demo",
60+ "employee_id": "employee-http-10086",
61+ "display_name": "HTTP Enterprise User",
62+ "email": "http-user@example.test",
63+ "groups": "enterprise-admins",
64+ "is_admin": "false",
65+ },
66+ follow_redirects=False,
67+ )
68+ assert callback.status_code == 303
69+ code = _query_value(callback.headers["location"], "federation_code")
70+ 
71+ exchanged = client.post(
72+ "/v1/auth/federation/exchange",
73+ json={"code": code},
74+ )
75+ assert exchanged.status_code == 200
76+ token = exchanged.json()["access_token"]
77+ 
78+ replay = client.post(
79+ "/v1/auth/federation/exchange",
80+ json={"code": code},
81+ )
82+ assert replay.status_code == 401
83+ 
84+ me = client.get(
85+ "/v1/auth/me",
86+ headers={"Authorization": f"Bearer {token}"},
87+ )
88+ assert me.status_code == 200
89+ assert me.json()["display_name"] == "HTTP Enterprise User"
90+ assert me.json()["is_admin"] is True
91+ assert me.json()["groups"] == ["federated-enterprise-demo"]
92+ 
93+ admin_api = client.get(
94+ "/v1/users/",
95+ headers={"Authorization": f"Bearer {token}"},
96+ )
97+ assert admin_api.status_code == 200
98+ 
99+ started_again = client.get(
100+ "/v1/auth/federation/enterprise-demo/login",
101+ params={"return_to": "/auth"},
102+ follow_redirects=False,
103+ )
104+ callback_again = client.post(
105+ "/v1/auth/federation/enterprise-demo/callback",
106+ data={
107+ "authorization_request_id": _query_value(
108+ started_again.headers["location"],
109+ "authorization_request_id",
110+ ),
111+ "connection_id": "enterprise-demo",
112+ "employee_id": "employee-http-10086",
113+ "display_name": "HTTP Enterprise User",
114+ "email": "http-user@example.test",
115+ "groups": "employees",
116+ "is_admin": "true",
117+ "role": "admin",
118+ },
119+ follow_redirects=False,
120+ )
121+ downgraded = client.post(
122+ "/v1/auth/federation/exchange",
123+ json={
124+ "code": _query_value(
125+ callback_again.headers["location"],
126+ "federation_code",
127+ )
128+ },
129+ )
130+ downgraded_token = downgraded.json()["access_token"]
131+ downgraded_me = client.get(
132+ "/v1/auth/me",
133+ headers={"Authorization": f"Bearer {downgraded_token}"},
134+ )
135+ assert downgraded_me.status_code == 200
136+ assert downgraded_me.json()["user_id"] == me.json()["user_id"]
137+ assert downgraded_me.json()["is_admin"] is False
138+ denied_admin_api = client.get(
139+ "/v1/users/",
140+ headers={"Authorization": f"Bearer {downgraded_token}"},
141+ )
142+ assert denied_admin_api.status_code == 403
@@ -0,0 +1,297 @@
1+"""Unit tests for transactional federated-identity provisioning."""
2+ 
3+from __future__ import annotations
4+ 
5+from urllib.parse import parse_qs, urlsplit
6+ 
7+import pytest
8+from sqlalchemy.exc import IntegrityError
9+ 
10+from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
11+from openjiuwen_runtime.service import ExternalIdentity
12+ 
13+from identity_center.core.federation import IdentityFederationService
14+from identity_center.core.federation.store import (
15+ IdentityCenterFederatedIdentityStore,
16+)
17+from identity_center.core.iam.services import OrgService, UserService
18+from identity_center.infrastructure.config import Settings
19+from identity_center.infrastructure.utils import utc_now
20+from identity_center.models.identity_models import (
21+ APP_USER_TABLE_DEF,
22+ FEDERATED_IDENTITY_TABLE_DEF,
23+ FEDERATION_LOGIN_CODE_TABLE_DEF,
24+ FEDERATION_ROLE_MAPPING_TABLE_DEF,
25+ ORG_TABLE_DEF,
26+ USER_ORG_MEMBERSHIP_TABLE_DEF,
27+)
28+from identity_center.models.table_init import init_all_tables
29+from identity_center.security.jwt_keys import load_signing_key
30+ 
31+ 
32+def _settings(db_path: str) -> Settings:
33+ return Settings(
34+ _env_file=None,
35+ IDENTITY_DB_TYPE="sqlite",
36+ IDENTITY_SQLITE_PATH=db_path,
37+ IDENTITY_FEDERATION_DEMO_ENABLED=True,
38+ IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX="",
39+ IDENTITY_SEED_ADMIN=False,
40+ IDENTITY_SEED_USER1=False,
41+ )
42+ 
43+ 
44+async def _runtime(tmp_path):
45+ handler = SQLiteHandler(str(tmp_path / "identity.db"))
46+ await handler.init_database()
47+ await handler.connect()
48+ await init_all_tables(handler)
49+ await load_signing_key(handler)
50+ service = await IdentityFederationService.create(
51+ handler,
52+ _settings(str(tmp_path / "identity.db")),
53+ )
54+ return handler, service
55+ 
56+ 
57+def _query_value(url: str, name: str) -> str:
58+ values = parse_qs(urlsplit(url).query).get(name)
59+ assert values
60+ return values[0]
61+ 
62+ 
63+async def _federated_login(
64+ service: IdentityFederationService,
65+ *,
66+ employee_id: str,
67+ display_name: str,
68+ groups: str = "employees",
69+ extra_parameters: dict[str, str] | None = None,
70+):
71+ upstream = await service.begin_login("enterprise-demo", "/auth")
72+ request_id = _query_value(upstream, "authorization_request_id")
73+ parameters = {
74+ "authorization_request_id": request_id,
75+ "employee_id": employee_id,
76+ "display_name": display_name,
77+ "email": f"{employee_id}@example.test",
78+ "groups": groups,
79+ }
80+ parameters.update(extra_parameters or {})
81+ redirect, principal = await service.complete_callback(
82+ "enterprise-demo",
83+ parameters,
84+ )
85+ return _query_value(redirect, "federation_code"), principal
86+ 
87+ 
88+@pytest.mark.asyncio
89+async def test_first_login_is_idempotent_and_code_is_one_time(tmp_path):
90+ handler, service = await _runtime(tmp_path)
91+ try:
92+ code, first = await _federated_login(
93+ service,
94+ employee_id="employee-10086",
95+ display_name="Enterprise Alice",
96+ )
97+ 
98+ tokens = await service.exchange_code(code)
99+ assert isinstance(tokens, dict)
100+ assert tokens["token_type"] == "bearer"
101+ assert await service.exchange_code(code) == "invalid_federation_code"
102+ 
103+ _, repeated = await _federated_login(
104+ service,
105+ employee_id="employee-10086",
106+ display_name="Enterprise Alice Updated",
107+ )
108+ assert repeated.user_id == first.user_id
109+ assert repeated.organization_id == "federated-enterprise-demo"
110+ assert repeated.display_name == "Enterprise Alice Updated"
111+ assert repeated.roles == ("member",)
112+ 
113+ assert await handler.count_records(APP_USER_TABLE_DEF.table_name, {}) == 1
114+ assert (
115+ await handler.count_records(FEDERATED_IDENTITY_TABLE_DEF.table_name, {})
116+ == 1
117+ )
118+ assert (
119+ await handler.count_records(USER_ORG_MEMBERSHIP_TABLE_DEF.table_name, {})
120+ == 1
121+ )
122+ assert await handler.count_records(ORG_TABLE_DEF.table_name, {}) == 1
123+ assert (
124+ await handler.count_records(FEDERATION_ROLE_MAPPING_TABLE_DEF.table_name, {})
125+ == 1
126+ )
127+ assert (
128+ await handler.count_records(FEDERATION_LOGIN_CODE_TABLE_DEF.table_name, {})
129+ == 1
130+ )
131+ finally:
132+ await service.close()
133+ await handler.disconnect()
134+ 
135+ 
136+@pytest.mark.asyncio
137+async def test_verified_group_grants_admin_and_next_login_can_revoke_it(tmp_path):
138+ handler, service = await _runtime(tmp_path)
139+ try:
140+ _, administrator = await _federated_login(
141+ service,
142+ employee_id="employee-admin",
143+ display_name="Enterprise Administrator",
144+ groups="employees, enterprise-admins",
145+ )
146+ assert administrator.roles == ("admin", "member")
147+ user = await handler.get(
148+ APP_USER_TABLE_DEF.table_name,
149+ {"user_id": administrator.user_id},
150+ )
151+ assert user is not None
152+ assert user.is_admin is True
153+ 
154+ _, ordinary_user = await _federated_login(
155+ service,
156+ employee_id="employee-admin",
157+ display_name="Enterprise Administrator",
158+ groups="employees",
159+ extra_parameters={"is_admin": "true", "role": "admin"},
160+ )
161+ assert ordinary_user.user_id == administrator.user_id
162+ assert ordinary_user.roles == ("member",)
163+ user = await handler.get(
164+ APP_USER_TABLE_DEF.table_name,
165+ {"user_id": ordinary_user.user_id},
166+ )
167+ assert user is not None
168+ assert user.is_admin is False
169+ finally:
170+ await service.close()
171+ await handler.disconnect()
172+ 
173+ 
174+@pytest.mark.asyncio
175+async def test_role_mapping_reconciliation_is_idempotent_across_restarts(tmp_path):
176+ handler, service = await _runtime(tmp_path)
177+ try:
178+ before = await handler.list_records(
179+ FEDERATION_ROLE_MAPPING_TABLE_DEF.table_name,
180+ {},
181+ limit=10,
182+ offset=0,
183+ )
184+ assert len(before) == 1
185+ original_id = before[0].id
186+ original_created_at = before[0].created_at
187+ 
188+ await service.close()
189+ service = await IdentityFederationService.create(
190+ handler,
191+ _settings(str(tmp_path / "identity.db")),
192+ )
193+ after = await handler.list_records(
194+ FEDERATION_ROLE_MAPPING_TABLE_DEF.table_name,
195+ {},
196+ limit=10,
197+ offset=0,
198+ )
199+ assert len(after) == 1
200+ assert after[0].id == original_id
201+ assert after[0].created_at == original_created_at
202+ finally:
203+ await service.close()
204+ await handler.disconnect()
205+ 
206+ 
207+@pytest.mark.asyncio
208+async def test_provisioning_failure_rolls_back_org_user_and_mapping(
209+ tmp_path,
210+ monkeypatch,
211+):
212+ handler, service = await _runtime(tmp_path)
213+ try:
214+ colliding_user_id = "fuser_collision"
215+ now = utc_now()
216+ await handler.create(
217+ APP_USER_TABLE_DEF.table_name,
218+ {
219+ "user_id": colliding_user_id,
220+ "display_name": "Existing User",
221+ "is_admin": False,
222+ "status": "active",
223+ "created_at": now,
224+ "updated_at": now,
225+ },
226+ )
227+ 
228+ class _FixedUuid:
229+ hex = "collision"
230+ 
231+ monkeypatch.setattr(
232+ "identity_center.core.federation.store.uuid4",
233+ lambda: _FixedUuid(),
234+ )
235+ store = IdentityCenterFederatedIdentityStore(handler)
236+ with pytest.raises(IntegrityError):
237+ await store.resolve_or_create(
238+ service.connections[0],
239+ ExternalIdentity(
240+ connection_id="enterprise-demo",
241+ issuer="https://idp.enterprise-demo.example",
242+ external_subject="employee-collision",
243+ display_name="Should Roll Back",
244+ ),
245+ )
246+ 
247+ assert await handler.count_records(APP_USER_TABLE_DEF.table_name, {}) == 1
248+ assert await handler.count_records(ORG_TABLE_DEF.table_name, {}) == 0
249+ assert (
250+ await handler.count_records(FEDERATED_IDENTITY_TABLE_DEF.table_name, {})
251+ == 0
252+ )
253+ assert (
254+ await handler.count_records(USER_ORG_MEMBERSHIP_TABLE_DEF.table_name, {})
255+ == 0
256+ )
257+ finally:
258+ await service.close()
259+ await handler.disconnect()
260+ 
261+ 
262+@pytest.mark.asyncio
263+async def test_iam_preserves_connection_org_and_cleans_deleted_virtual_user(
264+ tmp_path,
265+):
266+ handler, service = await _runtime(tmp_path)
267+ try:
268+ _, principal = await _federated_login(
269+ service,
270+ employee_id="employee-delete",
271+ display_name="Enterprise Delete Test",
272+ )
273+ 
274+ with pytest.raises(ValueError, match="federation connection"):
275+ await OrgService(handler).delete("federated-enterprise-demo")
276+ 
277+ assert await UserService(handler).delete(principal.user_id) is True
278+ assert await handler.count_records(APP_USER_TABLE_DEF.table_name, {}) == 0
279+ assert (
280+ await handler.count_records(FEDERATED_IDENTITY_TABLE_DEF.table_name, {})
281+ == 0
282+ )
283+ assert (
284+ await handler.count_records(USER_ORG_MEMBERSHIP_TABLE_DEF.table_name, {})
285+ == 0
286+ )
287+ assert await handler.count_records(ORG_TABLE_DEF.table_name, {}) == 1
288+ 
289+ _, recreated = await _federated_login(
290+ service,
291+ employee_id="employee-delete",
292+ display_name="Enterprise Recreated User",
293+ )
294+ assert recreated.user_id != principal.user_id
295+ finally:
296+ await service.close()
297+ await handler.disconnect()
@@ -13,6 +13,7 @@ interface AuthContextValue {
13 user: AuthUser | null;13 user: AuthUser | null;
14 ready: boolean;14 ready: boolean;
15 login: (username: string, password: string) => Promise<void>;15 login: (username: string, password: string) => Promise<void>;
16+ completeFederatedLogin: (code: string) => Promise<void>;
16 logout: () => Promise<void>;17 logout: () => Promise<void>;
17}18}
18 19 
@@ -79,6 +80,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
79 dispatch({ kind: 'signed-in', account });80 dispatch({ kind: 'signed-in', account });
80 }, []);81 }, []);
81 82 
83+ const completeFederatedLogin = useCallback(async (code: string) => {
84+ const account = await AuthApi.exchangeFederationCode(code);
85+ dispatch({ kind: 'signed-in', account });
86+ }, []);
87+ 
82 const logout = useCallback(async () => {88 const logout = useCallback(async () => {
83 await AuthApi.logout();89 await AuthApi.logout();
84 dispatch({ kind: 'cleared' });90 dispatch({ kind: 'cleared' });
@@ -89,9 +95,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
89 user: session.account,95 user: session.account,
90 ready: session.bootstrapped,96 ready: session.bootstrapped,
91 login,97 login,
98+ completeFederatedLogin,
92 logout,99 logout,
93 }),100 }),
94- [session.account, session.bootstrapped, login, logout],101+ [session.account, session.bootstrapped, login, completeFederatedLogin, logout],
95 );102 );
96 103 
97 return <ManagerSession.Provider value={sessionApi}>{children}</ManagerSession.Provider>;104 return <ManagerSession.Provider value={sessionApi}>{children}</ManagerSession.Provider>;
Mapplications/manager/manager_web/src/i18n/locales/en.json+3-0文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/i18n/locales/zh.json+3-0文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/LoginPage.tsx+50-3文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/services/api.ts+20-0文件内容审核中,请稍后刷新重试
@@ -5,7 +5,8 @@ from datetime import datetime
5import logging5import logging
6from typing import Optional, Any6from typing import Optional, Any
7import json7import json
8-from sqlalchemy import Column, Integer, String, DateTime, JSON, Boolean, Float, create_engine, text, inspect, Index8+from sqlalchemy import Column, Integer, String, DateTime, JSON, Boolean, Float, text, inspect, Index
9+from sqlalchemy.engine import make_url
9from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker10from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
10from sqlalchemy.orm import DeclarativeBase11from sqlalchemy.orm import DeclarativeBase
11from sqlalchemy import select, update, delete, func12from sqlalchemy import select, update, delete, func
@@ -47,15 +48,24 @@ class SQLAlchemyHandler(DBHandler):
47 # 关闭 aiosqlite 的 DEBUG 日志48 # 关闭 aiosqlite 的 DEBUG 日志
48 logging.getLogger("aiosqlite").setLevel(logging.WARNING)49 logging.getLogger("aiosqlite").setLevel(logging.WARNING)
49 engine_kwargs = build_async_engine_kwargs(connect_args=self.connect_args)50 engine_kwargs = build_async_engine_kwargs(connect_args=self.connect_args)
51+ database_url = make_url(self.database_url)
52+ if (
53+ database_url.get_backend_name() == "sqlite"
54+ and database_url.database in {None, "", ":memory:"}
55+ ):
56+ # SQLAlchemy selects StaticPool for an in-memory SQLite database.
57+ # QueuePool-only options are invalid for that pool implementation.
58+ for option in ("pool_size", "max_overflow", "pool_timeout"):
59+ engine_kwargs.pop(option, None)
50 self.engine = create_async_engine(self.database_url, **engine_kwargs)60 self.engine = create_async_engine(self.database_url, **engine_kwargs)
51 self.session_factory = async_sessionmaker(61 self.session_factory = async_sessionmaker(
52 self.engine, class_=AsyncSession, expire_on_commit=False62 self.engine, class_=AsyncSession, expire_on_commit=False
53 )63 )
54 logger.info(64 logger.info(
55 "Database connected (pool_size=%s max_overflow=%s pool_timeout=%s)",65 "Database connected (pool_size=%s max_overflow=%s pool_timeout=%s)",
56- engine_kwargs["pool_size"],66+ engine_kwargs.get("pool_size", "default"),
57- engine_kwargs["max_overflow"],67+ engine_kwargs.get("max_overflow", "default"),
58- engine_kwargs["pool_timeout"],68+ engine_kwargs.get("pool_timeout", "default"),
59 )69 )
60 70 
61 async def disconnect(self) -> None:71 async def disconnect(self) -> None:
@@ -68,6 +78,18 @@ class SQLAlchemyHandler(DBHandler):
68 """获取 SQLAlchemy AsyncEngine 实例."""78 """获取 SQLAlchemy AsyncEngine 实例."""
69 return self.engine79 return self.engine
70 80 
81+ def get_table(self, table_name: str) -> Any:
82+ """Return the registered SQLAlchemy table for transactional operations.
83+ 
84+ High-level ``DBHandler`` CRUD methods intentionally own their sessions and
85+ commits. Applications that need several writes in one transaction can use
86+ this table together with the handler's ``session_factory``.
87+ """
88+ model = self._table_models.get(table_name)
89+ if model is None:
90+ raise ValueError(f"Table {table_name} not initialized")
91+ return model.__table__
92+ 
71 def _get_sqlalchemy_type(self, data_type: str, length: Optional[int] = None):93 def _get_sqlalchemy_type(self, data_type: str, length: Optional[int] = None):
72 """将数据类型字符串转换为 SQLAlchemy 类型"""94 """将数据类型字符串转换为 SQLAlchemy 类型"""
73 type_map = {95 type_map = {
@@ -94,7 +116,6 @@ class SQLAlchemyHandler(DBHandler):
94 def _get_dialect_name(self) -> str:116 def _get_dialect_name(self) -> str:
95 if self.engine is not None:117 if self.engine is not None:
96 return self.engine.dialect.name118 return self.engine.dialect.name
97- from sqlalchemy.engine import make_url
98 return make_url(self.database_url).get_backend_name()119 return make_url(self.database_url).get_backend_name()
99 120 
100 def _quote_identifier(self, identifier: str) -> str:121 def _quote_identifier(self, identifier: str) -> str:
@@ -48,6 +48,17 @@ class TestSQLiteHandler(unittest.IsolatedAsyncioTestCase):
48 self.assertIsNotNone(handler.session_factory)48 self.assertIsNotNone(handler.session_factory)
49 await handler.disconnect()49 await handler.disconnect()
50 50 
51+ async def test_get_table_returns_registered_sqlalchemy_table(self):
52+ await self.handler.init_table(self.test_table_def)
53+ 
54+ table = self.handler.get_table(self.test_table_def.table_name)
55+ 
56+ self.assertEqual(table.name, self.test_table_def.table_name)
57+ 
58+ def test_get_table_rejects_unregistered_table(self):
59+ with self.assertRaisesRegex(ValueError, "not initialized"):
60+ self.handler.get_table("missing_table")
61+ 
51 async def test_init_table(self):62 async def test_init_table(self):
52 """测试初始化表"""63 """测试初始化表"""
53 await self.handler.init_table(self.test_table_def)64 await self.handler.init_table(self.test_table_def)
@@ -40,7 +40,7 @@ examples/
40| --- | --- |40| --- | --- |
41| `multi_handler_app.py` | 组装 `App`、OAuth2、联合认证模块和全部示例 Handler,并提供可运行入口 |41| `multi_handler_app.py` | 组装 `App`、OAuth2、联合认证模块和全部示例 Handler,并提供可运行入口 |
42| `custom_handlers.py` | 展示独立功能模块如何通过 `HandlerRegistry` 向宿主应用贡献 Handler |42| `custom_handlers.py` | 展示独立功能模块如何通过 `HandlerRegistry` 向宿主应用贡献 Handler |
43-| `federated_auth/` | 联合身份标准化本地虚拟身份映射、SQLite 存储和示例 OAuth2 服务 |43+| `federated_auth/` | 使用正式联合认证契约的 Demo Provider、SQLite Store 和示例 OAuth2 服务 |
44| `federated_auth/README.md` | 联合认证模块的详细设计、通信时序、安全边界与扩展方法 |44| `federated_auth/README.md` | 联合认证模块的详细设计、通信时序、安全边界与扩展方法 |
45 45 
46## 2. 整体架构46## 2. 整体架构
@@ -149,6 +149,10 @@ email: alice@enterprise.example
149Enterprise Demo SSO 只是本地交互模拟器,不解析或验证 SAML XML。完整边界见149Enterprise Demo SSO 只是本地交互模拟器,不解析或验证 SAML XML。完整边界见
150[`federated_auth/README.md`](federated_auth/README.md)。150[`federated_auth/README.md`](federated_auth/README.md)。
151 151 
152+联合认证的正式领域对象、异步 Provider/Store 契约和传输无关编排位于
153+`openjiuwen_runtime.service.auth.federation`。本目录中的 Demo IdP、OAuth2 Server 和
154+SQLite Store 只用于演示,不会随正式认证能力被误认为生产实现。
155+ 
152## 4. 请求和响应协议156## 4. 请求和响应协议
153 157 
154### 4.1 统一请求 Envelope158### 4.1 统一请求 Envelope
@@ -1,6 +1,7 @@
1# Federated authentication example1# Federated authentication example
2 2 
3-模块展示如何把企业外部身份接入应用自己的 OAuth2 Authorization Code 流程,3+示例展示如何使用 Service Framework 的正式联合认证契约,把企业外部身份接入
4+应用自己的 OAuth2 Authorization Code 流程,
4并将外部身份稳定映射为本地虚拟组织和虚拟用户。它与5并将外部身份稳定映射为本地虚拟组织和虚拟用户。它与
5`multi_handler_app.py` 组合后,可以直接从 Swagger UI 体验完整链路。6`multi_handler_app.py` 组合后,可以直接从 Swagger UI 体验完整链路。
6 7 
@@ -45,7 +46,24 @@ OAuth2 Bearer Token 负责应用内部“本次请求以哪个本地身份访问
45`organization_id` 表示本地身份和租户边界;企业部门、项目组、Runtime Group、Bot46`organization_id` 表示本地身份和租户边界;企业部门、项目组、Runtime Group、Bot
46及 Agent 的映射属于更上层的授权和资源模型,不应在身份认证模块中隐式完成。47及 Agent 的映射属于更上层的授权和资源模型,不应在身份认证模块中隐式完成。
47 48 
48-## 2. 模块结构和职责49+## 2. 正式能力与示例实现
50+ 
51+联合认证的领域对象、异步 Provider/Store 契约和传输无关编排已经进入正式包:
52+ 
53+```text
54+openjiuwen_runtime/service/auth/federation/
55+├── domain.py
56+├── provider.py
57+├── identity_store.py
58+├── coordinator.py
59+└── errors.py
60+```
61+ 
62+应用应从 `openjiuwen_runtime.service`
63+`openjiuwen_runtime.service.auth.federation` 导入这些类型。正式包不依赖 FastAPI、
64+SQLite、示例 OAuth2 Server 或任何具体企业协议。
65+ 
66+本目录只保留可运行演示及测试实现:
49 67 
50```text68```text
51federated_auth/69federated_auth/
@@ -62,11 +80,11 @@ federated_auth/
62 80 
63| 文件 | 主要类型 | 职责 |81| 文件 | 主要类型 | 职责 |
64| --- | --- | --- |82| --- | --- | --- |
65-| `domain.py` | `FederationConnection`, `ExternalIdentity`, `LocalPrincipal` | 定义 Provider、Store OAuth2 之间共享的稳定领域对象 |83+| `domain.py` | 正式领域对象的兼容导入 | 兼容原有 example 导入;新代码应从正式 service 包导入 |
66-| `provider.py` | `FederationProvider`, `DemoFederationProvider` | 抽象企业身份协议的开始登录和回调消费边界 |84+| `provider.py` | `DemoFederationProvider` | 实现正式 `FederationProvider`,模拟企业身份回调 |
67-| `identity_store.py` | `FederatedIdentityStore`, `InMemoryFederatedIdentityStore` | 定义外部身份到本地 Principal 的映射接口并提供单元测试实现 |85+| `identity_store.py` | `InMemoryFederatedIdentityStore` | 实现正式 `FederatedIdentityStore`,供单元测试使用 |
68| `database_identity_store.py` | `DatabaseFederatedIdentityStore` | 使用一个 SQLite 文件持久化虚拟组织、用户、外部身份和成员关系 |86| `database_identity_store.py` | `DatabaseFederatedIdentityStore` | 使用一个 SQLite 文件持久化虚拟组织、用户、外部身份和成员关系 |
69-| `module.py` | `FederatedAuthModule` | 编排 Provider、Store 和 OAuth2 Server,并挂载联合登录路由 |87+| `module.py` | `FederatedAuthModule` | 使用正式 `FederationCoordinator`,并挂载示例 HTTP 登录路由 |
70| `oauth2_server.py` | `ExampleOAuth2AuthorizationServer` | 示例 Authorization Code、PKCE、访问令牌签发与校验 |88| `oauth2_server.py` | `ExampleOAuth2AuthorizationServer` | 示例 Authorization Code、PKCE、访问令牌签发与校验 |
71| `demo_idp.py` | `DemoEnterpriseIdentityProvider` | 提供明确标注的本地企业 IdP 表单模拟器 |89| `demo_idp.py` | `DemoEnterpriseIdentityProvider` | 提供明确标注的本地企业 IdP 表单模拟器 |
72 90 
@@ -75,9 +93,10 @@ federated_auth/
75```mermaid93```mermaid
76flowchart TD94flowchart TD
77 App["multi_handler_app.py"]95 App["multi_handler_app.py"]
78- Module["FederatedAuthModule"]96+ Module["Example FederatedAuthModule"]
79- Provider["FederationProvider"]97+ Coordinator["FederationCoordinator"]
80- Store["FederatedIdentityStore"]98+ Provider["FederationProvider contract"]
99+ Store["FederatedIdentityStore contract"]
81 OAuth["ExampleOAuth2AuthorizationServer"]100 OAuth["ExampleOAuth2AuthorizationServer"]
82 IdP["DemoEnterpriseIdentityProvider"]101 IdP["DemoEnterpriseIdentityProvider"]
83 SQLite["SQLite"]102 SQLite["SQLite"]
@@ -86,17 +105,18 @@ flowchart TD
86 App --> Module105 App --> Module
87 App --> OAuth106 App --> OAuth
88 App --> IdP107 App --> IdP
89- Module --> Provider108+ Module --> Coordinator
90- Module --> Store
91 Module --> OAuth109 Module --> OAuth
110+ Coordinator --> Provider
111+ Coordinator --> Store
92 Provider --> IdP112 Provider --> IdP
93 Store --> SQLite113 Store --> SQLite
94 OAuth --> Handler114 OAuth --> Handler
95```115```
96 116 
97-`FederatedAuthModule` 只依赖 `FederationProvider` 和117+`FederationCoordinator` 属于正式 Service Framework,只依赖异步
98-`FederatedIdentityStore` 抽象,不依赖演示类生产应用可以替换 Provider 和 Store118+`FederationProvider` 和 `FederatedIdentityStore` 契约示例 HTTP 模块负责把浏览器
99-实现,同时保留编排方式119+跳转、表单回调和 OAuth2 Server 接到该编排器上;生产应用可以使用自己的传输适配层
100 120 
101## 3. 核心领域对象121## 3. 核心领域对象
102 122 
@@ -259,6 +279,9 @@ Provisioning,JIT):
259 - 更新展示名、邮箱、外部属性和最近登录时间;279 - 更新展示名、邮箱、外部属性和最近登录时间;
2606. 返回 `LocalPrincipal`2806. 返回 `LocalPrincipal`
261 281 
282+正式编排器还会校验 Store 返回的 `LocalPrincipal.organization_id` 与连接绑定的
283+`organization_id` 一致,防止错误的持久化实现把用户映射到其他本地组织。
284+ 
262这保证同一个外部主体重复登录时,本地 `user_id` 稳定。以下改变会产生不同的外部285这保证同一个外部主体重复登录时,本地 `user_id` 稳定。以下改变会产生不同的外部
263身份键:286身份键:
264 287 
@@ -273,7 +296,11 @@ Provisioning,JIT):
273 296 
274### 7.1 抽象接口297### 7.1 抽象接口
275 298 
299+该接口定义在正式 Service Framework 中:
300+ 
276```python301```python
302+from openjiuwen_runtime.service import FederatedIdentityStore
303+ 
277class FederatedIdentityStore(ABC):304class FederatedIdentityStore(ABC):
278 async def resolve_or_create(305 async def resolve_or_create(
279 self,306 self,
@@ -310,7 +337,7 @@ SQLite 实现接收**文件路径**,不是数据库 URL:
310```python337```python
311from pathlib import Path338from pathlib import Path
312 339 
313-from federated_auth import DatabaseFederatedIdentityStore340+from examples.federated_auth import DatabaseFederatedIdentityStore
314 341 
315store = DatabaseFederatedIdentityStore(342store = DatabaseFederatedIdentityStore(
316 Path("examples/federated_auth/.data/federated_auth.db")343 Path("examples/federated_auth/.data/federated_auth.db")
@@ -400,15 +427,19 @@ erDiagram
400```python427```python
401from pathlib import Path428from pathlib import Path
402 429 
403-from federated_auth import (430+from examples.federated_auth import (
404 DatabaseFederatedIdentityStore,431 DatabaseFederatedIdentityStore,
405 DemoEnterpriseIdentityProvider,432 DemoEnterpriseIdentityProvider,
406 DemoFederationProvider,433 DemoFederationProvider,
407 ExampleOAuth2AuthorizationServer,434 ExampleOAuth2AuthorizationServer,
408 FederatedAuthModule,435 FederatedAuthModule,
409- FederationConnection,
410)436)
411-from openjiuwen_runtime.service import App, OAuth2AccessControl, SystemContext437+from openjiuwen_runtime.service import (
438+ App,
439+ FederationConnection,
440+ OAuth2AccessControl,
441+ SystemContext,
442+)
412 443 
413 444 
414connection = FederationConnection(445connection = FederationConnection(
@@ -454,8 +485,9 @@ FederatedAuthModule(
454DemoEnterpriseIdentityProvider().mount(app.asgi)485DemoEnterpriseIdentityProvider().mount(app.asgi)
455```486```
456 487 
457-组装中只有 `OAuth2AccessControl` 属于通用 Service Framework。Token 签发用户映射488+`FederationConnection`、`FederationProvider`、`FederatedIdentityStore`、
458-企业协议实现都属于应用层示例489+`FederationCoordinator` `OAuth2AccessControl` 属于正式 Service Framework
490+本例的 Token 签发、SQLite 用户映射、HTTP 登录页面和企业协议模拟仍属于应用层示例。
459 491 
460## 10. OAuth2 示例实现的行为492## 10. OAuth2 示例实现的行为
461 493 
@@ -494,9 +526,10 @@ DemoEnterpriseIdentityProvider().mount(app.asgi)
494```python526```python
495from collections.abc import Mapping527from collections.abc import Mapping
496 528 
497-from federated_auth.domain import ExternalIdentity, FederationConnection529+from openjiuwen_runtime.service import (
498-from federated_auth.provider import (530+ ExternalIdentity,
499 FederationAuthenticationResult,531 FederationAuthenticationResult,
532+ FederationConnection,
500 FederationProvider,533 FederationProvider,
501)534)
502 535 
@@ -545,8 +578,8 @@ class SamlFederationProvider(FederationProvider):
545只有在以上验证全部成功后,Provider 才能创建 `ExternalIdentity`。生产实现绝不能像578只有在以上验证全部成功后,Provider 才能创建 `ExternalIdentity`。生产实现绝不能像
546`DemoFederationProvider` 一样直接相信浏览器提交的 `employee_id`、展示名或邮箱。579`DemoFederationProvider` 一样直接相信浏览器提交的 `employee_id`、展示名或邮箱。
547 580 
548-Provider 返回标准化身份后,现有 `FederatedAuthModule`、Identity Store、OAuth2 完成581+Provider 返回标准化身份后,正式 `FederationCoordinator`、Identity Store、OAuth2
549-流程和业务 Handler 无需感知 SAML XML。582+完成流程和业务 Handler 无需感知 SAML XML。
550 583 
551## 12. 安全边界584## 12. 安全边界
552 585 
@@ -580,6 +613,7 @@ Provider 返回标准化身份后,现有 `FederatedAuthModule`、Identity Stor
580 613 
581```bash614```bash
582uv run pytest -q \615uv run pytest -q \
616+ tests/unit_tests/test_federation_core.py \
583 tests/unit_tests/test_federated_identity_store.py \617 tests/unit_tests/test_federated_identity_store.py \
584 tests/unit_tests/test_federated_oauth2.py618 tests/unit_tests/test_federated_oauth2.py
585```619```
@@ -587,6 +621,7 @@ uv run pytest -q \
587覆盖内容包括:621覆盖内容包括:
588 622 
589- 外部身份首次创建和稳定复用;623- 外部身份首次创建和稳定复用;
624+- 正式编排器的异步 Provider/Store 边界;
590- issuer、connection 绑定校验;625- issuer、connection 绑定校验;
591- Provider 到 LocalPrincipal 的标准化;626- Provider 到 LocalPrincipal 的标准化;
592- Authorization Code、PKCE 和一次性消费;627- Authorization Code、PKCE 和一次性消费;
@@ -3,13 +3,22 @@
3 3 
4"""Reusable identity components for the federated-auth example."""4"""Reusable identity components for the federated-auth example."""
5 5 
6+from openjiuwen_runtime.service.auth.federation import (
7+ ExternalIdentity,
8+ FederatedIdentityStore,
9+ FederationAuthenticationResult,
10+ FederationConnection,
11+ FederationCoordinator,
12+ FederationProvider,
13+ LocalPrincipal,
14+)
15+ 
6from .database_identity_store import DatabaseFederatedIdentityStore16from .database_identity_store import DatabaseFederatedIdentityStore
7from .demo_idp import DemoEnterpriseIdentityProvider17from .demo_idp import DemoEnterpriseIdentityProvider
8-from .domain import ExternalIdentity, FederationConnection, LocalPrincipal18+from .identity_store import InMemoryFederatedIdentityStore
9-from .identity_store import FederatedIdentityStore, InMemoryFederatedIdentityStore
10from .module import FederatedAuthModule19from .module import FederatedAuthModule
11from .oauth2_server import ExampleOAuth2AuthorizationServer20from .oauth2_server import ExampleOAuth2AuthorizationServer
12-from .provider import DemoFederationProvider, FederationProvider21+from .provider import DemoFederationProvider
13 22 
14__all__ = [23__all__ = [
15 "DatabaseFederatedIdentityStore",24 "DatabaseFederatedIdentityStore",
@@ -18,9 +27,11 @@ __all__ = [
18 "ExampleOAuth2AuthorizationServer",27 "ExampleOAuth2AuthorizationServer",
19 "ExternalIdentity",28 "ExternalIdentity",
20 "FederatedIdentityStore",29 "FederatedIdentityStore",
30+ "FederationAuthenticationResult",
21 "FederatedAuthModule",31 "FederatedAuthModule",
22 "FederationProvider",32 "FederationProvider",
23 "FederationConnection",33 "FederationConnection",
34+ "FederationCoordinator",
24 "InMemoryFederatedIdentityStore",35 "InMemoryFederatedIdentityStore",
25 "LocalPrincipal",36 "LocalPrincipal",
26]37]
@@ -12,9 +12,12 @@ from pathlib import Path
12from uuid import uuid412from uuid import uuid4
13 13 
14import aiosqlite14import aiosqlite
15- 15+from openjiuwen_runtime.service.auth.federation import (
16-from .domain import ExternalIdentity, FederationConnection, LocalPrincipal16+ ExternalIdentity,
17-from .identity_store import FederatedIdentityStore17+ FederatedIdentityStore,
18+ FederationConnection,
19+ LocalPrincipal,
20+)
18 21 
19_SCHEMA = """22_SCHEMA = """
20CREATE TABLE IF NOT EXISTS virtual_organizations (23CREATE TABLE IF NOT EXISTS virtual_organizations (
@@ -367,6 +370,7 @@ def _principal_from_row(row: aiosqlite.Row) -> LocalPrincipal:
367 display_name=row["display_name"],370 display_name=row["display_name"],
368 email=row["email"],371 email=row["email"],
369 roles=(row["role"],),372 roles=(row["role"],),
373+ auth_source="saml",
370 )374 )
371 375 
372 376 
@@ -1,46 +1,12 @@
1# coding: utf-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4-"""Minimal domain objects shared by federation providers and identity stores."""4+"""Compatibility imports for the example's former local domain module."""
5 5 
6-from typing import Any6+from openjiuwen_runtime.service.auth.federation import (
7+ ExternalIdentity,
8+ FederationConnection,
9+ LocalPrincipal,
10+)
7 11 
8-from pydantic import BaseModel, ConfigDict, Field12+__all__ = ["ExternalIdentity", "FederationConnection", "LocalPrincipal"]
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"
@@ -1,62 +1,24 @@
1# coding: utf-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4-"""Federated identity store contract and dictionary-backed test implementation."""4+"""Dictionary-backed test implementation of the formal federation store."""
5 5 
6from __future__ import annotations6from __future__ import annotations
7 7 
8import asyncio8import asyncio
9-from abc import ABC, abstractmethod
10from dataclasses import dataclass9from dataclasses import dataclass
11from uuid import uuid410from uuid import uuid4
12 11 
13-from .domain import ExternalIdentity, FederationConnection, LocalPrincipal12+from openjiuwen_runtime.service.auth.federation import (
13+ ExternalIdentity,
14+ FederatedIdentityStore,
15+ FederationConnection,
16+ LocalPrincipal,
17+)
14 18 
15IdentityKey = tuple[str, str, str]19IdentityKey = tuple[str, str, str]
16 20 
17 21 
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@dataclass22@dataclass
61class _MemoryUser:23class _MemoryUser:
62 user_id: str24 user_id: str
@@ -157,4 +119,8 @@ def _principal(
157 display_name=user.display_name,119 display_name=user.display_name,
158 email=user.email,120 email=user.email,
159 roles=(role,),121 roles=(role,),
122+ auth_source="saml",
160 )123 )
124+ 
125+ 
126+__all__ = ["FederatedIdentityStore", "InMemoryFederatedIdentityStore"]
@@ -9,11 +9,15 @@ from collections.abc import Mapping
9 9 
10from fastapi import FastAPI, Request10from fastapi import FastAPI, Request
11from fastapi.responses import JSONResponse, RedirectResponse11from fastapi.responses import JSONResponse, RedirectResponse
12+from openjiuwen_runtime.service.auth.federation import (
13+ FederatedIdentityStore,
14+ FederationConnection,
15+ FederationCoordinator,
16+ FederationProvider,
17+ UnknownFederationConnection,
18+)
12 19 
13-from .domain import FederationConnection
14-from .identity_store import FederatedIdentityStore
15from .oauth2_server import ExampleOAuth2AuthorizationServer, OAuth2FlowError20from .oauth2_server import ExampleOAuth2AuthorizationServer, OAuth2FlowError
16-from .provider import FederationProvider
17 21 
18 22 
19class FederatedAuthModule:23class FederatedAuthModule:
@@ -27,10 +31,12 @@ class FederatedAuthModule:
27 oauth2_server: ExampleOAuth2AuthorizationServer,31 oauth2_server: ExampleOAuth2AuthorizationServer,
28 connections: Mapping[str, FederationConnection],32 connections: Mapping[str, FederationConnection],
29 ) -> None:33 ) -> None:
30- self._provider = provider
31- self._identity_store = identity_store
32 self._oauth2_server = oauth2_server34 self._oauth2_server = oauth2_server
33- self._connections = dict(connections)35+ self._coordinator = FederationCoordinator(
36+ provider=provider,
37+ identity_store=identity_store,
38+ connections=connections,
39+ )
34 40 
35 def mount(self, fastapi: FastAPI) -> None:41 def mount(self, fastapi: FastAPI) -> None:
36 """Mount browser redirect and callback routes on one FastAPI app."""42 """Mount browser redirect and callback routes on one FastAPI app."""
@@ -40,20 +46,16 @@ class FederatedAuthModule:
40 connection_id: str,46 connection_id: str,
41 authorization_request_id: str,47 authorization_request_id: str,
42 ):48 ):
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:49 try:
50 await self._oauth2_server.require_authorization_request(50 await self._oauth2_server.require_authorization_request(
51 authorization_request_id51 authorization_request_id
52 )52 )
53- login_url = await self._provider.begin_login(53+ login_url = await self._coordinator.begin_login(
54- connection,54+ connection_id,
55 authorization_request_id,55 authorization_request_id,
56 )56 )
57+ except UnknownFederationConnection as exc:
58+ return JSONResponse({"detail": str(exc)}, status_code=404)
57 except (OAuth2FlowError, ValueError) as exc:59 except (OAuth2FlowError, ValueError) as exc:
58 return JSONResponse({"detail": str(exc)}, status_code=400)60 return JSONResponse({"detail": str(exc)}, status_code=400)
59 return RedirectResponse(login_url, status_code=303)61 return RedirectResponse(login_url, status_code=303)
@@ -63,27 +65,26 @@ class FederatedAuthModule:
63 tags=["federation"],65 tags=["federation"],
64 )66 )
65 async def complete_federated_login(connection_id: str, request: Request):67 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()68 form_data = await request.form()
73 form = {key: str(value) for key, value in form_data.items()}69 form = {key: str(value) for key, value in form_data.items()}
74 try:70 try:
75- result = await self._provider.consume_callback(connection, form)71+ result = await self._coordinator.consume_callback(
72+ connection_id,
73+ form,
74+ )
76 await self._oauth2_server.require_authorization_request(75 await self._oauth2_server.require_authorization_request(
77 result.authorization_request_id76 result.authorization_request_id
78 )77 )
79- principal = await self._identity_store.resolve_or_create(78+ principal = await self._coordinator.resolve_or_create(
80- connection,79+ connection_id,
81 result.identity,80 result.identity,
82 )81 )
83 redirect_url = await self._oauth2_server.complete_authorization(82 redirect_url = await self._oauth2_server.complete_authorization(
84 result.authorization_request_id,83 result.authorization_request_id,
85 principal,84 principal,
86 )85 )
86+ except UnknownFederationConnection as exc:
87+ return JSONResponse({"detail": str(exc)}, status_code=404)
87 except (OAuth2FlowError, ValueError) as exc:88 except (OAuth2FlowError, ValueError) as exc:
88 return JSONResponse({"detail": str(exc)}, status_code=400)89 return JSONResponse({"detail": str(exc)}, status_code=400)
89 return RedirectResponse(redirect_url, status_code=303)90 return RedirectResponse(redirect_url, status_code=303)
@@ -19,7 +19,10 @@ from fastapi import FastAPI, Request
19from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse19from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
20from pydantic import BaseModel20from pydantic import BaseModel
21 21 
22-from .domain import FederationConnection, LocalPrincipal22+from openjiuwen_runtime.service.auth.federation import (
23+ FederationConnection,
24+ LocalPrincipal,
25+)
23 26 
24 27 
25class AccessToken(BaseModel):28class AccessToken(BaseModel):
@@ -1,46 +1,19 @@
1# coding: utf-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4-"""Federation provider contract and local enterprise-identity demonstration."""4+"""Local demonstration provider for the formal federation contract."""
5 5 
6from __future__ import annotations6from __future__ import annotations
7 7 
8-from abc import ABC, abstractmethod
9-from dataclasses import dataclass
10from typing import Mapping8from typing import Mapping
11from urllib.parse import urlencode9from urllib.parse import urlencode
12 10 
13-from .domain import ExternalIdentity, FederationConnection11+from openjiuwen_runtime.service.auth.federation import (
14- 12+ ExternalIdentity,
15- 13+ FederationAuthenticationResult,
16-@dataclass(frozen=True)14+ FederationConnection,
17-class FederationAuthenticationResult:15+ FederationProvider,
18- """Validated upstream identity and the local OAuth2 request it completes."""16+)
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 17 
45 18 
46class DemoFederationProvider(FederationProvider):19class DemoFederationProvider(FederationProvider):
@@ -86,3 +59,10 @@ def _required(form: Mapping[str, str], name: str) -> str:
86 if not value:59 if not value:
87 raise ValueError(f"missing required federation field: {name}")60 raise ValueError(f"missing required federation field: {name}")
88 return value61 return value
62+ 
63+ 
64+__all__ = [
65+ "DemoFederationProvider",
66+ "FederationAuthenticationResult",
67+ "FederationProvider",
68+]
@@ -91,6 +91,18 @@ from .routing.handlers import (
91 StreamMessageHandler,91 StreamMessageHandler,
92)92)
93from .security import OAuth2AccessControl93from .security import OAuth2AccessControl
94+from .auth import (
95+ ExternalIdentity,
96+ FederatedIdentityStore,
97+ FederationAuthenticationResult,
98+ FederationBindingError,
99+ FederationConnection,
100+ FederationCoordinator,
101+ FederationError,
102+ FederationProvider,
103+ LocalPrincipal,
104+ UnknownFederationConnection,
105+)
94from .server.app import App106from .server.app import App
95 107 
96__version__ = "0.1.0"108__version__ = "0.1.0"
@@ -184,6 +196,16 @@ __all__ = [
184 "idempotency_guard",196 "idempotency_guard",
185 # security197 # security
186 "OAuth2AccessControl",198 "OAuth2AccessControl",
199+ "ExternalIdentity",
200+ "FederatedIdentityStore",
201+ "FederationAuthenticationResult",
202+ "FederationBindingError",
203+ "FederationConnection",
204+ "FederationCoordinator",
205+ "FederationError",
206+ "FederationProvider",
207+ "LocalPrincipal",
208+ "UnknownFederationConnection",
187 # server209 # server
188 "App",210 "App",
189]211]
@@ -0,0 +1,30 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Authentication and identity extension contracts for service applications."""
5+ 
6+from .federation import (
7+ ExternalIdentity,
8+ FederatedIdentityStore,
9+ FederationAuthenticationResult,
10+ FederationBindingError,
11+ FederationConnection,
12+ FederationCoordinator,
13+ FederationError,
14+ FederationProvider,
15+ LocalPrincipal,
16+ UnknownFederationConnection,
17+)
18+ 
19+__all__ = [
20+ "ExternalIdentity",
21+ "FederatedIdentityStore",
22+ "FederationAuthenticationResult",
23+ "FederationBindingError",
24+ "FederationConnection",
25+ "FederationCoordinator",
26+ "FederationError",
27+ "FederationProvider",
28+ "LocalPrincipal",
29+ "UnknownFederationConnection",
30+]
@@ -0,0 +1,95 @@
1+# Federated identity contracts
2+ 
3+本包提供 Service Framework 的联合身份正式契约和传输无关编排。它负责把已经由外部
4+身份协议验证的用户映射为稳定的本地 Principal,但不绑定 FastAPI、数据库实现、
5+OAuth2 Server 或某一种企业身份协议。
6+ 
7+## 1. 适用范围
8+ 
9+典型链路如下:
10+ 
11+```text
12+Browser / Client
13+ -> host authorization flow
14+ -> FederationProvider
15+ -> enterprise IdP
16+ -> validated ExternalIdentity
17+ -> FederationCoordinator
18+ -> FederatedIdentityStore
19+ -> LocalPrincipal
20+ -> host token issuer
21+ -> protected service Handler
22+```
23+ 
24+外部 SAML、OIDC 或其他协议负责证明外部用户是谁;宿主认证中心负责签发内部 OAuth2
25+Token;Service Handler 只消费经过映射的本地 Principal。
26+ 
27+## 2. 正式类型
28+ 
29+| 类型 | 职责 |
30+| --- | --- |
31+| `FederationConnection` | 保存受信任 issuer 与本地组织的稳定绑定 |
32+| `ExternalIdentity` | 表示 Provider 完成协议验证后输出的标准化外部身份 |
33+| `LocalPrincipal` | 表示业务授权和 Handler 使用的本地身份 |
34+| `FederationProvider` | 定义开始外部登录和验证回调的异步协议接口 |
35+| `FederatedIdentityStore` | 定义外部身份到本地 Principal 的异步持久化接口 |
36+| `FederationCoordinator` | 校验连接边界并编排 Provider 与 Store |
37+ 
38+`FederationCoordinator` 将回调验证和本地身份写入拆成两个步骤:
39+ 
40+```python
41+authentication = await coordinator.consume_callback(connection_id, parameters)
42+ 
43+# 宿主必须先验证自己的 OAuth2/SAML 关联状态、一次性请求和有效期。
44+await authorization_flow.require_request(
45+ authentication.authorization_request_id
46+)
47+ 
48+principal = await coordinator.resolve_or_create(
49+ connection_id,
50+ authentication.identity,
51+)
52+```
53+ 
54+这种顺序可以防止无效或已经过期的授权请求提前创建本地用户。
55+ 
56+## 3. 异步约束
57+ 
58+Provider 的 `begin_login()``consume_callback()`,Store 的
59+`resolve_or_create()``find()``close()` 必须使用 `async def`。编排器在构造阶段
60+检查这些方法,避免同步网络、数据库或文件操作进入事件循环。
61+ 
62+具体实现还应使用真正的异步驱动。仅把同步函数声明为 `async def` 不能消除阻塞。
63+ 
64+## 4. 宿主应用职责
65+ 
66+正式联合身份包不负责:
67+ 
68+- 创建 OAuth2 Authorization Request、Authorization Code 或 Token;
69+- 解析 HTTP Query、Form、Cookie 或返回 Redirect;
70+- 保存 SAML `AuthnRequest ID``InResponseTo` 或防重放状态;
71+- 创建用户、组织和成员关系的具体数据库表;
72+- 将企业部门自动映射为 Runtime `group_id`
73+- 提供真实 SAML/OIDC SDK 配置。
74+ 
75+这些能力由认证中心或应用适配器实现。正式包只保证不同实现共享相同的身份边界。
76+ 
77+## 5. 安全要求
78+ 
79+Provider 只能在完整验证外部协议后创建 `ExternalIdentity`。真实 SAML 实现至少需要
80+验证签名和证书、Issuer、Audience、Destination、Recipient、`InResponseTo`、时间窗口
81+和 Response/Assertion ID 防重放。
82+ 
83+`(connection_id, issuer, external_subject)` 是稳定外部身份键。邮箱和展示名默认不能
84+作为唯一身份键。`FederatedIdentityStore.validate_binding()` 会拒绝连接或 issuer
85+漂移;`FederationCoordinator` 还会拒绝不属于连接绑定组织的 `LocalPrincipal`。具体
86+Store 必须用唯一约束或事务保证并发首次登录只创建一个本地用户。
87+ 
88+## 6. 当前验证方式
89+ 
90+仓库没有真实企业 IdP。正式接口通过 Fake Provider/Store 单元测试验证;
91+`service/examples/federated_auth` 使用 Demo Provider、Demo IdP 和 SQLite Store 验证完整
92+浏览器、OAuth2 Authorization Code、PKCE 和 Principal 注入链路。
93+ 
94+Demo IdP 不解析或验证 SAML XML,不能作为真实企业认证实现。接入企业环境时应新增严格
95+验证协议的 Provider,并复用本包的 Coordinator、Store 契约和本地授权模型。
@@ -0,0 +1,27 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Transport-neutral contracts for federated identity integration."""
5+ 
6+from .coordinator import FederationCoordinator
7+from .domain import ExternalIdentity, FederationConnection, LocalPrincipal
8+from .errors import (
9+ FederationBindingError,
10+ FederationError,
11+ UnknownFederationConnection,
12+)
13+from .identity_store import FederatedIdentityStore
14+from .provider import FederationAuthenticationResult, FederationProvider
15+ 
16+__all__ = [
17+ "ExternalIdentity",
18+ "FederatedIdentityStore",
19+ "FederationAuthenticationResult",
20+ "FederationBindingError",
21+ "FederationConnection",
22+ "FederationCoordinator",
23+ "FederationError",
24+ "FederationProvider",
25+ "LocalPrincipal",
26+ "UnknownFederationConnection",
27+]
@@ -0,0 +1,142 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Transport-neutral orchestration for one federated identity provider."""
5+ 
6+from __future__ import annotations
7+ 
8+import inspect
9+from collections.abc import Mapping
10+ 
11+from .domain import ExternalIdentity, FederationConnection, LocalPrincipal
12+from .errors import (
13+ FederationBindingError,
14+ FederationError,
15+ UnknownFederationConnection,
16+)
17+from .identity_store import FederatedIdentityStore
18+from .provider import FederationAuthenticationResult, FederationProvider
19+ 
20+ 
21+class FederationCoordinator:
22+ """Coordinate trusted connections, an async provider, and an identity store.
23+ 
24+ OAuth2 authorization state, HTTP redirects, callback parsing, and token issuance
25+ stay in the host application. This class only validates connection boundaries and
26+ orchestrates the protocol-neutral provider/store operations.
27+ """
28+ 
29+ def __init__(
30+ self,
31+ *,
32+ provider: FederationProvider,
33+ identity_store: FederatedIdentityStore,
34+ connections: Mapping[str, FederationConnection],
35+ ) -> None:
36+ _require_async_method(provider, "begin_login", role="federation provider")
37+ _require_async_method(
38+ provider,
39+ "consume_callback",
40+ role="federation provider",
41+ )
42+ _require_async_method(
43+ identity_store,
44+ "resolve_or_create",
45+ role="federated identity store",
46+ )
47+ _require_async_method(identity_store, "find", role="federated identity store")
48+ _require_async_method(identity_store, "close", role="federated identity store")
49+ 
50+ normalized: dict[str, FederationConnection] = {}
51+ for key, connection in connections.items():
52+ connection_key = str(key).strip()
53+ if connection_key != connection.connection_id:
54+ raise FederationError(
55+ "federation connection mapping key must match connection_id"
56+ )
57+ normalized[connection_key] = connection
58+ 
59+ self._provider = provider
60+ self._identity_store = identity_store
61+ self._connections = normalized
62+ 
63+ @property
64+ def connections(self) -> tuple[FederationConnection, ...]:
65+ """Return configured trusted connections in registration order."""
66+ return tuple(self._connections.values())
67+ 
68+ def require_connection(self, connection_id: str) -> FederationConnection:
69+ """Return a trusted connection or raise a stable federation error."""
70+ normalized = str(connection_id or "").strip()
71+ connection = self._connections.get(normalized)
72+ if connection is None:
73+ raise UnknownFederationConnection(
74+ f"unknown federation connection: {normalized or '<empty>'}"
75+ )
76+ return connection
77+ 
78+ async def begin_login(
79+ self,
80+ connection_id: str,
81+ authorization_request_id: str,
82+ ) -> str:
83+ """Start upstream authentication for a trusted connection."""
84+ request_id = str(authorization_request_id or "").strip()
85+ if not request_id:
86+ raise FederationError("authorization_request_id must not be empty")
87+ connection = self.require_connection(connection_id)
88+ login_url = await self._provider.begin_login(connection, request_id)
89+ if not str(login_url or "").strip():
90+ raise FederationError("federation provider returned an empty login URL")
91+ return login_url
92+ 
93+ async def consume_callback(
94+ self,
95+ connection_id: str,
96+ parameters: Mapping[str, str],
97+ ) -> FederationAuthenticationResult:
98+ """Validate a provider callback without creating local identity records."""
99+ connection = self.require_connection(connection_id)
100+ result = await self._provider.consume_callback(connection, parameters)
101+ if not isinstance(result, FederationAuthenticationResult):
102+ raise FederationError(
103+ "federation provider returned an invalid authentication result"
104+ )
105+ if not str(result.authorization_request_id or "").strip():
106+ raise FederationError(
107+ "federation provider returned an empty authorization_request_id"
108+ )
109+ FederatedIdentityStore.validate_binding(connection, result.identity)
110+ return result
111+ 
112+ async def resolve_or_create(
113+ self,
114+ connection_id: str,
115+ identity: ExternalIdentity,
116+ ) -> LocalPrincipal:
117+ """Resolve a validated identity into a stable local principal."""
118+ connection = self.require_connection(connection_id)
119+ FederatedIdentityStore.validate_binding(connection, identity)
120+ principal = await self._identity_store.resolve_or_create(connection, identity)
121+ if not isinstance(principal, LocalPrincipal):
122+ raise FederationError(
123+ "federated identity store returned an invalid local principal"
124+ )
125+ if principal.organization_id != connection.organization_id:
126+ raise FederationBindingError(
127+ "local principal organization_id does not match connection"
128+ )
129+ return principal
130+ 
131+ async def close(self) -> None:
132+ """Close the configured identity store."""
133+ await self._identity_store.close()
134+ 
135+ 
136+def _require_async_method(instance: object, name: str, *, role: str) -> None:
137+ method = getattr(instance, name, None)
138+ if not callable(method) or not inspect.iscoroutinefunction(method):
139+ raise TypeError(f"{role}.{name} must be declared with async def")
140+ 
141+ 
142+__all__ = ["FederationCoordinator"]
@@ -0,0 +1,46 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Stable 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+ """Trusted external 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 returned after an external protocol 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+ """Stable local identity consumed by authorization and business handlers."""
38+ 
39+ model_config = ConfigDict(frozen=True)
40+ 
41+ user_id: str = Field(min_length=1)
42+ organization_id: str = Field(min_length=1)
43+ display_name: str = Field(min_length=1)
44+ email: str | None = None
45+ roles: tuple[str, ...]
46+ auth_source: str = Field(default="federated", min_length=1)
@@ -0,0 +1,23 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Errors raised by transport-neutral federation orchestration."""
5+ 
6+ 
7+class FederationError(ValueError):
8+ """Base error for invalid federation requests or trusted configuration."""
9+ 
10+ 
11+class UnknownFederationConnection(FederationError):
12+ """Raised when a request names a connection that is not configured."""
13+ 
14+ 
15+class FederationBindingError(FederationError):
16+ """Raised when an external identity does not match its trusted connection."""
17+ 
18+ 
19+__all__ = [
20+ "FederationBindingError",
21+ "FederationError",
22+ "UnknownFederationConnection",
23+]
@@ -0,0 +1,58 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Persistence contract for stable external-to-local identity mappings."""
5+ 
6+from __future__ import annotations
7+ 
8+from abc import ABC, abstractmethod
9+ 
10+from .domain import ExternalIdentity, FederationConnection, LocalPrincipal
11+from .errors import FederationBindingError
12+ 
13+ 
14+class FederatedIdentityStore(ABC):
15+ """Resolve validated external identities into stable local principals."""
16+ 
17+ @abstractmethod
18+ async def resolve_or_create(
19+ self,
20+ connection: FederationConnection,
21+ identity: ExternalIdentity,
22+ ) -> LocalPrincipal:
23+ """Return an existing principal or create its local shadow records."""
24+ raise NotImplementedError
25+ 
26+ @abstractmethod
27+ async def find(
28+ self,
29+ *,
30+ connection_id: str,
31+ issuer: str,
32+ external_subject: str,
33+ ) -> LocalPrincipal | None:
34+ """Find a principal by its stable external identity key."""
35+ raise NotImplementedError
36+ 
37+ @abstractmethod
38+ async def close(self) -> None:
39+ """Release resources owned by this store."""
40+ raise NotImplementedError
41+ 
42+ @staticmethod
43+ def validate_binding(
44+ connection: FederationConnection,
45+ identity: ExternalIdentity,
46+ ) -> None:
47+ """Ensure an identity can only be consumed by its trusted connection."""
48+ if identity.connection_id != connection.connection_id:
49+ raise FederationBindingError(
50+ "external identity connection_id does not match connection"
51+ )
52+ if identity.issuer != connection.issuer:
53+ raise FederationBindingError(
54+ "external identity issuer does not match trusted issuer"
55+ )
56+ 
57+ 
58+__all__ = ["FederatedIdentityStore"]
@@ -0,0 +1,45 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Asynchronous boundary around an external identity protocol."""
5+ 
6+from __future__ import annotations
7+ 
8+from abc import ABC, abstractmethod
9+from dataclasses import dataclass
10+from typing import Mapping
11+ 
12+from .domain import ExternalIdentity, FederationConnection
13+ 
14+ 
15+@dataclass(frozen=True)
16+class FederationAuthenticationResult:
17+ """Validated external identity and the local authorization request it serves."""
18+ 
19+ authorization_request_id: str
20+ identity: ExternalIdentity
21+ 
22+ 
23+class FederationProvider(ABC):
24+ """Validate an upstream protocol and return a normalized external identity."""
25+ 
26+ @abstractmethod
27+ async def begin_login(
28+ self,
29+ connection: FederationConnection,
30+ authorization_request_id: str,
31+ ) -> str:
32+ """Return the upstream login URL for one authorization request."""
33+ raise NotImplementedError
34+ 
35+ @abstractmethod
36+ async def consume_callback(
37+ self,
38+ connection: FederationConnection,
39+ parameters: Mapping[str, str],
40+ ) -> FederationAuthenticationResult:
41+ """Validate an upstream callback and normalize its trusted identity."""
42+ raise NotImplementedError
43+ 
44+ 
45+__all__ = ["FederationAuthenticationResult", "FederationProvider"]
@@ -0,0 +1,243 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Unit tests for the formal transport-neutral federation contracts."""
5+ 
6+from __future__ import annotations
7+ 
8+from collections.abc import Mapping
9+from types import SimpleNamespace
10+ 
11+import pytest
12+ 
13+from openjiuwen_runtime.service import (
14+ ExternalIdentity,
15+ FederatedIdentityStore,
16+ FederationAuthenticationResult,
17+ FederationBindingError,
18+ FederationConnection,
19+ FederationCoordinator,
20+ FederationError,
21+ FederationProvider,
22+ LocalPrincipal,
23+ UnknownFederationConnection,
24+)
25+ 
26+ 
27+def _connection() -> FederationConnection:
28+ return FederationConnection(
29+ connection_id="enterprise-a",
30+ issuer="https://idp.enterprise-a.example",
31+ organization_id="virtual-org-a",
32+ organization_name="Enterprise A",
33+ )
34+ 
35+ 
36+def _identity(*, issuer: str | None = None) -> ExternalIdentity:
37+ return ExternalIdentity(
38+ connection_id="enterprise-a",
39+ issuer=issuer or "https://idp.enterprise-a.example",
40+ external_subject="employee-10086",
41+ display_name="Enterprise Alice",
42+ )
43+ 
44+ 
45+class _Provider(FederationProvider):
46+ def __init__(self, identity: ExternalIdentity | None = None) -> None:
47+ self.identity = identity or _identity()
48+ 
49+ async def begin_login(
50+ self,
51+ connection: FederationConnection,
52+ authorization_request_id: str,
53+ ) -> str:
54+ return f"https://idp.example/login?request={authorization_request_id}"
55+ 
56+ async def consume_callback(
57+ self,
58+ connection: FederationConnection,
59+ parameters: Mapping[str, str],
60+ ) -> FederationAuthenticationResult:
61+ return FederationAuthenticationResult(
62+ authorization_request_id=parameters["authorization_request_id"],
63+ identity=self.identity,
64+ )
65+ 
66+ 
67+class _Store(FederatedIdentityStore):
68+ def __init__(self, principal: LocalPrincipal | None = None) -> None:
69+ self.resolve_count = 0
70+ self.closed = False
71+ self.principal = principal
72+ 
73+ async def resolve_or_create(
74+ self,
75+ connection: FederationConnection,
76+ identity: ExternalIdentity,
77+ ) -> LocalPrincipal:
78+ self.validate_binding(connection, identity)
79+ self.resolve_count += 1
80+ return self.principal or LocalPrincipal(
81+ user_id="local-user-1",
82+ organization_id=connection.organization_id,
83+ display_name=identity.display_name,
84+ roles=(connection.default_role,),
85+ )
86+ 
87+ async def find(
88+ self,
89+ *,
90+ connection_id: str,
91+ issuer: str,
92+ external_subject: str,
93+ ) -> LocalPrincipal | None:
94+ return None
95+ 
96+ async def close(self) -> None:
97+ self.closed = True
98+ 
99+ 
100+def _coordinator(
101+ *,
102+ provider: FederationProvider | None = None,
103+ store: _Store | None = None,
104+) -> tuple[FederationCoordinator, _Store]:
105+ active_store = store or _Store()
106+ coordinator = FederationCoordinator(
107+ provider=provider or _Provider(),
108+ identity_store=active_store,
109+ connections={"enterprise-a": _connection()},
110+ )
111+ return coordinator, active_store
112+ 
113+ 
114+@pytest.mark.unit
115+async def test_coordinator_runs_provider_and_store_in_separate_steps():
116+ coordinator, store = _coordinator()
117+ 
118+ login_url = await coordinator.begin_login("enterprise-a", "request-1")
119+ authentication = await coordinator.consume_callback(
120+ "enterprise-a",
121+ {"authorization_request_id": "request-1"},
122+ )
123+ 
124+ assert login_url.endswith("request=request-1")
125+ assert authentication.authorization_request_id == "request-1"
126+ assert store.resolve_count == 0
127+ 
128+ principal = await coordinator.resolve_or_create(
129+ "enterprise-a",
130+ authentication.identity,
131+ )
132+ 
133+ assert principal.user_id == "local-user-1"
134+ assert principal.organization_id == "virtual-org-a"
135+ assert store.resolve_count == 1
136+ 
137+ 
138+@pytest.mark.unit
139+async def test_coordinator_rejects_unknown_connection_before_provider_call():
140+ coordinator, _ = _coordinator()
141+ 
142+ with pytest.raises(UnknownFederationConnection, match="unknown"):
143+ await coordinator.begin_login("missing", "request-1")
144+ 
145+ 
146+@pytest.mark.unit
147+async def test_coordinator_rejects_untrusted_identity_before_store_write():
148+ coordinator, store = _coordinator(
149+ provider=_Provider(_identity(issuer="https://attacker.example")),
150+ )
151+ 
152+ with pytest.raises(FederationBindingError, match="issuer"):
153+ await coordinator.consume_callback(
154+ "enterprise-a",
155+ {"authorization_request_id": "request-1"},
156+ )
157+ 
158+ assert store.resolve_count == 0
159+ 
160+ 
161+@pytest.mark.unit
162+async def test_coordinator_rejects_principal_from_another_organization():
163+ store = _Store(
164+ LocalPrincipal(
165+ user_id="local-user-1",
166+ organization_id="another-organization",
167+ display_name="Enterprise Alice",
168+ roles=("member",),
169+ )
170+ )
171+ coordinator, _ = _coordinator(store=store)
172+ 
173+ with pytest.raises(FederationBindingError, match="organization_id"):
174+ await coordinator.resolve_or_create("enterprise-a", _identity())
175+ 
176+ 
177+@pytest.mark.unit
178+async def test_coordinator_rejects_empty_authorization_request_id():
179+ coordinator, _ = _coordinator()
180+ 
181+ with pytest.raises(FederationError, match="must not be empty"):
182+ await coordinator.begin_login("enterprise-a", " ")
183+ 
184+ 
185+@pytest.mark.unit
186+async def test_coordinator_closes_owned_store_contract():
187+ coordinator, store = _coordinator()
188+ 
189+ await coordinator.close()
190+ 
191+ assert store.closed is True
192+ 
193+ 
194+@pytest.mark.unit
195+def test_coordinator_rejects_connection_mapping_key_mismatch():
196+ with pytest.raises(FederationError, match="mapping key"):
197+ FederationCoordinator(
198+ provider=_Provider(),
199+ identity_store=_Store(),
200+ connections={"alias": _connection()},
201+ )
202+ 
203+ 
204+@pytest.mark.unit
205+def test_coordinator_rejects_synchronous_provider_methods():
206+ def sync_begin_login():
207+ return "https://idp.example/login"
208+ 
209+ def sync_consume_callback():
210+ return None
211+ 
212+ provider = SimpleNamespace(
213+ begin_login=sync_begin_login,
214+ consume_callback=sync_consume_callback,
215+ )
216+ 
217+ with pytest.raises(TypeError, match="begin_login must be declared with async def"):
218+ FederationCoordinator(
219+ provider=provider,
220+ identity_store=_Store(),
221+ connections={"enterprise-a": _connection()},
222+ )
223+ 
224+ 
225+@pytest.mark.unit
226+def test_coordinator_rejects_synchronous_store_methods():
227+ def sync_operation():
228+ return None
229+ 
230+ store = SimpleNamespace(
231+ resolve_or_create=sync_operation,
232+ find=sync_operation,
233+ close=sync_operation,
234+ )
235+ 
236+ with pytest.raises(
237+ TypeError, match="resolve_or_create must be declared with async def"
238+ ):
239+ FederationCoordinator(
240+ provider=_Provider(),
241+ identity_store=store,
242+ connections={"enterprise-a": _connection()},
243+ )