已合并
feat(auth): 集成联合认证与Manager登录 #427
Wal1et创建于 26 天前
feat(auth): 集成联合认证与Manager登录 #427
已合并
共 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 | |||
| 166 | IDENTITY_REST_PORT=8770 | 225 | IDENTITY_REST_PORT=8770 |
| 167 | IDENTITY_DB_TYPE=sqlite | 226 | IDENTITY_DB_TYPE=sqlite |
| 168 | IDENTITY_SQLITE_PATH=identity.db | 227 | IDENTITY_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 | # 管理 API | 232 | # 管理 API |
| 171 | MANAGER_REST_HOST=0.0.0.0 | 233 | MANAGER_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] |
| 30 | dev = [ | 31 | dev = [ |
| 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] |
| 41 | openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true } | 43 | openjiuwen-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] |
| 44 | where = ["src"] | 47 | where = ["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_defaults | 37 | 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 | yield | 55 | 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 | + | ||
| 43 | +class FederationRoleMapping: | ||
| 44 | + claim_name: str | ||
| 45 | + claim_value: str | ||
| 46 | + local_role: str | ||
| 47 | + | ||
| 48 | + | ||
| 49 | + | ||
| 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 | + | ||
| 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 | + | ||
| 112 | + def connections(self) -> tuple[FederationConnection, ...]: | ||
| 113 | + return tuple(item.connection for item in self._connections.values()) | ||
| 114 | + | ||
| 115 | + | ||
| 116 | + def demo_enabled(self) -> bool: | ||
| 117 | + return bool(self._settings.federation_demo_enabled) | ||
| 118 | + | ||
| 119 | + | ||
| 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_name | 36 | _APP_USER = APP_USER_TABLE_DEF.table_name |
| 34 | _AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name | 37 | _AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name |
| 35 | _AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name | 38 | _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_name | 42 | _ORG = ORG_TABLE_DEF.table_name |
| 37 | _MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name | 43 | _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 False | 130 | 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 False | 283 | 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`` 存凭据/外部 IdP | 4 | +认证与身份解耦:``app_user`` 存身份/角色,``auth_identity`` 存本地口令等可直接 |
| 5 | -(二次开发新增 provider 不动业务表)。bot / 可见性 / 模板等平台配置留在管理库。 | 5 | +认证的身份。企业联合身份使用独立的受信连接与外部身份映射表,避免把 issuer 和 |
| 6 | +connection_id 编码进 provider 字符串。bot / 可见性 / 模板等平台配置留在管理库。 | ||
| 6 | """ | 7 | """ |
| 7 | 8 | ||
| 8 | from __future__ import annotations | 9 | from __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 可挂多种由身份中心直接校验的凭据,换凭据不改业务主体。 | ||
| 32 | AUTH_IDENTITY_TABLE_DEF = TableDefinition( | 34 | AUTH_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 | + | ||
| 106 | IDENTITY_TABLE_DEFINITIONS = ( | 199 | IDENTITY_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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | |||
| 5 | from fastapi import APIRouter, FastAPI | 5 | from fastapi import APIRouter, FastAPI |
| 6 | 6 | ||
| 7 | from identity_center.routers.auth_routers import auth_router | 7 | from identity_center.routers.auth_routers import auth_router |
| 8 | +from identity_center.routers.federation_routers import federation_router | ||
| 8 | from identity_center.routers.iam_routers import org_router, user_router | 9 | from identity_center.routers.iam_routers import org_router, user_router |
| 9 | 10 | ||
| 10 | 11 | ||
| 11 | def router_register(app: FastAPI) -> None: | 12 | def 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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>; |
| @@ -5,7 +5,8 @@ from datetime import datetime | |||
| 5 | import logging | 5 | import logging |
| 6 | from typing import Optional, Any | 6 | from typing import Optional, Any |
| 7 | import json | 7 | import json |
| 8 | -from sqlalchemy import Column, Integer, String, DateTime, JSON, Boolean, Float, create_engine, text, inspect, Index | 8 | +from sqlalchemy import Column, Integer, String, DateTime, JSON, Boolean, Float, text, inspect, Index |
| 9 | +from sqlalchemy.engine import make_url | ||
| 9 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker | 10 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker |
| 10 | from sqlalchemy.orm import DeclarativeBase | 11 | from sqlalchemy.orm import DeclarativeBase |
| 11 | from sqlalchemy import select, update, delete, func | 12 | from 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=False | 62 | 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.engine | 79 | 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.name | 118 | 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 | |||
| 149 | Enterprise Demo SSO 只是本地交互模拟器,不解析或验证 SAML XML。完整边界见 | 149 | Enterprise 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 统一请求 Envelope | 158 | ### 4.1 统一请求 Envelope |
| @@ -1,6 +1,7 @@ | |||
| 1 | # Federated authentication example | 1 | # 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、Bot | 46 | `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 | ```text | 68 | ```text |
| 51 | federated_auth/ | 69 | federated_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 | ```mermaid | 93 | ```mermaid |
| 76 | flowchart TD | 94 | flowchart 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 --> Module | 105 | App --> Module |
| 87 | App --> OAuth | 106 | App --> OAuth |
| 88 | App --> IdP | 107 | App --> IdP |
| 89 | - Module --> Provider | 108 | + Module --> Coordinator |
| 90 | - Module --> Store | ||
| 91 | Module --> OAuth | 109 | Module --> OAuth |
| 110 | + Coordinator --> Provider | ||
| 111 | + Coordinator --> Store | ||
| 92 | Provider --> IdP | 112 | Provider --> IdP |
| 93 | Store --> SQLite | 113 | Store --> SQLite |
| 94 | OAuth --> Handler | 114 | OAuth --> Handler |
| 95 | ``` | 115 | ``` |
| 96 | 116 | ||
| 97 | -`FederatedAuthModule` 只依赖 `FederationProvider` 和 | 117 | +`FederationCoordinator` 属于正式 Service Framework,只依赖异步 |
| 98 | -`FederatedIdentityStore` 抽象,不依赖演示类。生产应用可以替换 Provider 和 Store | 118 | +`FederationProvider` 和 `FederatedIdentityStore` 契约。示例 HTTP 模块负责把浏览器 |
| 99 | -实现,同时保留编排方式。 | 119 | +跳转、表单回调和 OAuth2 Server 接到该编排器上;生产应用可以使用自己的传输适配层。 |
| 100 | 120 | ||
| 101 | ## 3. 核心领域对象 | 121 | ## 3. 核心领域对象 |
| 102 | 122 | ||
| @@ -259,6 +279,9 @@ Provisioning,JIT): | |||
| 259 | - 更新展示名、邮箱、外部属性和最近登录时间; | 279 | - 更新展示名、邮箱、外部属性和最近登录时间; |
| 260 | 6. 返回 `LocalPrincipal`。 | 280 | 6. 返回 `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 | ```python | 301 | ```python |
| 302 | +from openjiuwen_runtime.service import FederatedIdentityStore | ||
| 303 | + | ||
| 277 | class FederatedIdentityStore(ABC): | 304 | class 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 | ```python | 337 | ```python |
| 311 | from pathlib import Path | 338 | from pathlib import Path |
| 312 | 339 | ||
| 313 | -from federated_auth import DatabaseFederatedIdentityStore | 340 | +from examples.federated_auth import DatabaseFederatedIdentityStore |
| 314 | 341 | ||
| 315 | store = DatabaseFederatedIdentityStore( | 342 | store = 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 | ```python | 427 | ```python |
| 401 | from pathlib import Path | 428 | from 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, SystemContext | 437 | +from openjiuwen_runtime.service import ( |
| 438 | + App, | ||
| 439 | + FederationConnection, | ||
| 440 | + OAuth2AccessControl, | ||
| 441 | + SystemContext, | ||
| 442 | +) | ||
| 412 | 443 | ||
| 413 | 444 | ||
| 414 | connection = FederationConnection( | 445 | connection = FederationConnection( |
| @@ -454,8 +485,9 @@ FederatedAuthModule( | |||
| 454 | DemoEnterpriseIdentityProvider().mount(app.asgi) | 485 | DemoEnterpriseIdentityProvider().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 | ```python | 526 | ```python |
| 495 | from collections.abc import Mapping | 527 | from collections.abc import Mapping |
| 496 | 528 | ||
| 497 | -from federated_auth.domain import ExternalIdentity, FederationConnection | 529 | +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 | ```bash | 614 | ```bash |
| 582 | uv run pytest -q \ | 615 | uv 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.py | 618 | 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 | + | ||
| 6 | from .database_identity_store import DatabaseFederatedIdentityStore | 16 | from .database_identity_store import DatabaseFederatedIdentityStore |
| 7 | from .demo_idp import DemoEnterpriseIdentityProvider | 17 | from .demo_idp import DemoEnterpriseIdentityProvider |
| 8 | -from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | 18 | +from .identity_store import InMemoryFederatedIdentityStore |
| 9 | -from .identity_store import FederatedIdentityStore, InMemoryFederatedIdentityStore | ||
| 10 | from .module import FederatedAuthModule | 19 | from .module import FederatedAuthModule |
| 11 | from .oauth2_server import ExampleOAuth2AuthorizationServer | 20 | from .oauth2_server import ExampleOAuth2AuthorizationServer |
| 12 | -from .provider import DemoFederationProvider, FederationProvider | 21 | +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 | |||
| 12 | from uuid import uuid4 | 12 | from uuid import uuid4 |
| 13 | 13 | ||
| 14 | import aiosqlite | 14 | import aiosqlite |
| 15 | - | 15 | +from openjiuwen_runtime.service.auth.federation import ( |
| 16 | -from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | 16 | + ExternalIdentity, |
| 17 | -from .identity_store import FederatedIdentityStore | 17 | + FederatedIdentityStore, |
| 18 | + FederationConnection, | ||
| 19 | + LocalPrincipal, | ||
| 20 | +) | ||
| 18 | 21 | ||
| 19 | _SCHEMA = """ | 22 | _SCHEMA = """ |
| 20 | CREATE TABLE IF NOT EXISTS virtual_organizations ( | 23 | CREATE 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-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 Any | 6 | +from openjiuwen_runtime.service.auth.federation import ( |
| 7 | + ExternalIdentity, | ||
| 8 | + FederationConnection, | ||
| 9 | + LocalPrincipal, | ||
| 10 | +) | ||
| 7 | 11 | ||
| 8 | -from pydantic import BaseModel, ConfigDict, Field | 12 | +__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-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 | ||
| 6 | from __future__ import annotations | 6 | from __future__ import annotations |
| 7 | 7 | ||
| 8 | import asyncio | 8 | import asyncio |
| 9 | -from abc import ABC, abstractmethod | ||
| 10 | from dataclasses import dataclass | 9 | from dataclasses import dataclass |
| 11 | from uuid import uuid4 | 10 | from uuid import uuid4 |
| 12 | 11 | ||
| 13 | -from .domain import ExternalIdentity, FederationConnection, LocalPrincipal | 12 | +from openjiuwen_runtime.service.auth.federation import ( |
| 13 | + ExternalIdentity, | ||
| 14 | + FederatedIdentityStore, | ||
| 15 | + FederationConnection, | ||
| 16 | + LocalPrincipal, | ||
| 17 | +) | ||
| 14 | 18 | ||
| 15 | IdentityKey = tuple[str, str, str] | 19 | IdentityKey = tuple[str, str, str] |
| 16 | 20 | ||
| 17 | 21 | ||
| 18 | -class FederatedIdentityStore(ABC): | ||
| 19 | - """Resolve validated external identities into stable local principals.""" | ||
| 20 | - | ||
| 21 | - | ||
| 22 | - async def resolve_or_create( | ||
| 23 | - self, | ||
| 24 | - connection: FederationConnection, | ||
| 25 | - identity: ExternalIdentity, | ||
| 26 | - ) -> LocalPrincipal: | ||
| 27 | - """Return the existing principal or create its local shadow records.""" | ||
| 28 | - raise NotImplementedError | ||
| 29 | - | ||
| 30 | - | ||
| 31 | - async def find( | ||
| 32 | - self, | ||
| 33 | - *, | ||
| 34 | - connection_id: str, | ||
| 35 | - issuer: str, | ||
| 36 | - external_subject: str, | ||
| 37 | - ) -> LocalPrincipal | None: | ||
| 38 | - """Find a principal by its stable external identity key.""" | ||
| 39 | - raise NotImplementedError | ||
| 40 | - | ||
| 41 | - | ||
| 42 | - async def close(self) -> None: | ||
| 43 | - """Release resources owned by this store.""" | ||
| 44 | - raise NotImplementedError | ||
| 45 | - | ||
| 46 | - | ||
| 47 | - def validate_binding( | ||
| 48 | - connection: FederationConnection, | ||
| 49 | - identity: ExternalIdentity, | ||
| 50 | - ) -> None: | ||
| 51 | - """Ensure an identity can only be consumed by its trusted connection.""" | ||
| 52 | - if identity.connection_id != connection.connection_id: | ||
| 53 | - raise ValueError( | ||
| 54 | - "external identity connection_id does not match connection" | ||
| 55 | - ) | ||
| 56 | - if identity.issuer != connection.issuer: | ||
| 57 | - raise ValueError("external identity issuer does not match trusted issuer") | ||
| 58 | - | ||
| 59 | - | ||
| 60 | 22 | ||
| 61 | class _MemoryUser: | 23 | class _MemoryUser: |
| 62 | user_id: str | 24 | 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 | ||
| 10 | from fastapi import FastAPI, Request | 10 | from fastapi import FastAPI, Request |
| 11 | from fastapi.responses import JSONResponse, RedirectResponse | 11 | from 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 | ||
| 15 | from .oauth2_server import ExampleOAuth2AuthorizationServer, OAuth2FlowError | 20 | from .oauth2_server import ExampleOAuth2AuthorizationServer, OAuth2FlowError |
| 16 | -from .provider import FederationProvider | ||
| 17 | 21 | ||
| 18 | 22 | ||
| 19 | class FederatedAuthModule: | 23 | class 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_server | 34 | 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_id | 51 | 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_id | 76 | 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 | |||
| 19 | from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse | 19 | from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse |
| 20 | from pydantic import BaseModel | 20 | from pydantic import BaseModel |
| 21 | 21 | ||
| 22 | -from .domain import FederationConnection, LocalPrincipal | 22 | +from openjiuwen_runtime.service.auth.federation import ( |
| 23 | + FederationConnection, | ||
| 24 | + LocalPrincipal, | ||
| 25 | +) | ||
| 23 | 26 | ||
| 24 | 27 | ||
| 25 | class AccessToken(BaseModel): | 28 | class AccessToken(BaseModel): |
| @@ -1,46 +1,19 @@ | |||
| 1 | # coding: utf-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 | ||
| 6 | from __future__ import annotations | 6 | from __future__ import annotations |
| 7 | 7 | ||
| 8 | -from abc import ABC, abstractmethod | ||
| 9 | -from dataclasses import dataclass | ||
| 10 | from typing import Mapping | 8 | from typing import Mapping |
| 11 | from urllib.parse import urlencode | 9 | from urllib.parse import urlencode |
| 12 | 10 | ||
| 13 | -from .domain import ExternalIdentity, FederationConnection | 11 | +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 | - | ||
| 28 | - async def begin_login( | ||
| 29 | - self, | ||
| 30 | - connection: FederationConnection, | ||
| 31 | - authorization_request_id: str, | ||
| 32 | - ) -> str: | ||
| 33 | - """Return the upstream login URL for one OAuth2 authorization request.""" | ||
| 34 | - raise NotImplementedError | ||
| 35 | - | ||
| 36 | - | ||
| 37 | - async def consume_callback( | ||
| 38 | - self, | ||
| 39 | - connection: FederationConnection, | ||
| 40 | - form: Mapping[str, str], | ||
| 41 | - ) -> FederationAuthenticationResult: | ||
| 42 | - """Validate an upstream callback and return a normalized identity.""" | ||
| 43 | - raise NotImplementedError | ||
| 44 | 17 | ||
| 45 | 18 | ||
| 46 | class DemoFederationProvider(FederationProvider): | 19 | class 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 value | 61 | 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 | ) |
| 93 | from .security import OAuth2AccessControl | 93 | from .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 | +) | ||
| 94 | from .server.app import App | 106 | from .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 | # security | 197 | # 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 | # server | 209 | # 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 38 | + async def close(self) -> None: | ||
| 39 | + """Release resources owned by this store.""" | ||
| 40 | + raise NotImplementedError | ||
| 41 | + | ||
| 42 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + ) | ||