diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 8b371c1e..fa0e0d3a 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -2,10 +2,11 @@ > 外部身份架构说明:LDAP、DingTalk、CAS、SAML、可信代理及其他新外部身份接入, > 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。GitHub、 -> GitLab 和标准 OIDC 已迁入统一身份核心;`DirectAuthProvider` 和 -> `PassiveSessionAuthenticator` 仍是兼容扩展点,不是新 Provider 的目标契约。新 -> Provider 只能返回协议验证结果,由统一核心归一化为内部 `IdentityAssertion`,不得 -> 直接返回 `PlatformPrincipal`。 +> GitLab 和标准 OIDC 已迁入统一身份核心;Credential、Passive 和 Browser Adapter +> 已由 Provider Registry 统一发现和路由。旧名称 `DirectAuthProvider` 和 +> `PassiveSessionAuthenticator` 仅是待删除的源码迁移别名,已经不能返回 +> `PlatformPrincipal`。新 Provider 只能返回协议验证结果,由统一核心归一化为内部 +> `IdentityAssertion`。 ## 0. 身份标识约束 @@ -249,21 +250,29 @@ protocol、canonical Authority、SHA-256 fingerprint 和状态,不保存 clien - 接口:`POST /api/v1/auth/session/bootstrap` - 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程 -- 默认状态:关闭,开源版不提供任何 `PassiveSessionAuthenticator` 实现 +- 默认状态:关闭,开源版不提供任何 `PassiveAuthenticationAdapter` 实现 - 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度 扩展接口如下: ```java -public interface PassiveSessionAuthenticator { - String providerCode(); - Optional authenticate(HttpServletRequest request); +public interface PassiveAuthenticationAdapter { + ProviderInstanceDefinition provider(); + Optional authenticate( + PassiveAuthenticationRequest request + ); } ``` 约束如下: -- `authenticate()` 只负责验证外部被动会话并返回平台登录所需主体 +- Registry 先确认 Provider `READY`,再调用 `authenticate()` +- App 层把 Servlet 请求转换成不可变 `PassiveAuthenticationRequest`;Adapter 不能访问 + `HttpSession`、Servlet API 或 `SecurityContext` +- `authenticate()` 只负责验证外部被动会话并返回非敏感协议事实 +- 统一身份核心负责账号、Binding、审批和 `PlatformPrincipal` +- 断言存在但无效、重放或上游不可用时,Adapter 抛出只含稳定失败码的 + `ProviderAuthenticationException` - 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false` - 未启用时接口返回 `403` - 启用但 provider 不受支持时返回 `400` @@ -275,9 +284,11 @@ public interface PassiveSessionAuthenticator { 为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象: ```java -public interface DirectAuthProvider { - String providerCode(); - PlatformPrincipal authenticate(DirectAuthRequest request); +public interface CredentialAuthenticationAdapter { + ProviderInstanceDefinition provider(); + ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request + ); } ``` @@ -290,7 +301,10 @@ public interface DirectAuthProvider { - 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制 - 关闭时返回 `403` - provider 不受支持时返回 `400` -- provider 认证失败时沿用 provider 自身的认证异常语义 +- provider 认证失败时使用 `ProviderAuthenticationFailureCode` 的稳定分类 +- Provider 非 `READY` 时不会把凭证发送给 Adapter +- Adapter 不创建账号、Binding、角色或 Session +- 凭证无效、TLS、超时、配置或响应错误不会把上游详情写入用户响应 - 成功时建立标准 Session,并返回与 `/api/v1/auth/me` 一致的用户结构 - 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口 diff --git a/docs/11-auth-extensibility-and-private-sso.md b/docs/11-auth-extensibility-and-private-sso.md index 01f85d83..61596d41 100644 --- a/docs/11-auth-extensibility-and-private-sso.md +++ b/docs/11-auth-extensibility-and-private-sso.md @@ -1,130 +1,208 @@ # 认证扩展与私有 SSO 兼容设计 -> 状态说明:本文记录当前 Direct/Passive 私有 SSO 兼容入口。新外部身份实现和这些入口的 -> 后续迁移,以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。 -> `DirectAuthProvider` 和 `PassiveSessionAuthenticator` 直接返回 -> `PlatformPrincipal` 的方式属于待迁移设计,不应继续扩展到 LDAP、CAS、DingTalk、 -> SAML 或新的私有 SSO。新 Adapter 只返回协议验证结果,由统一核心归一化为 -> `IdentityAssertion`。 +> 本文描述 Provider Registry 落地后的当前扩展边界。完整身份、不变量、迁移顺序和协议 +> 路线以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。 ## 1. 目标 -在不影响当前开源版 OAuth 和本地账号登录能力的前提下,为未来私有仓库接入企业 SSO 预留稳定扩展点,并把代码差异控制在 provider 实现层和少量配置层。 +SkillHub 保留已有公共登录协议,同时允许受信、随发布物构建的企业认证 Adapter 接入。 +Adapter 只验证协议并返回外部身份事实;Provider Registry 和统一身份核心负责 Provider +路由、Authority 锁定、账号创建、身份绑定、审批、资料同步、角色保护和 Session。 -## 2. 已确认约束 +不支持上传或热加载第三方 JAR。Adapter 与 SkillHub 运行在同一 JVM,必须经过代码 +Review 和 Provider Conformance Kit。 -- 私有 SSO 能提供稳定唯一 UID -- 用户名密码校验与 Cookie 会话校验都会返回同一稳定 UID -- 生产部署预期为 `skill.xxx.com` 与 `sso.xxx.com` -- 私有版可通过后端内部接口/RPC 代调用 SSO 校验用户名密码 -- 首次 SSO 登录自动创建 skillhub 账号 -- 不做账号合并设计,不依赖 email -- 登出联动可保留扩展点,但不是近期目标 +## 2. 不可绕过的边界 -## 3. 开源版兼容策略 +- Adapter 不返回或构造 `PlatformPrincipal`。 +- Adapter 不创建、查询或修改 `UserAccount`、平台角色、Namespace role 或 Binding。 +- Adapter 不操作 `HttpSession`、`SecurityContext` 或 Redis。 +- Adapter 不把 provider code、Authority、平台 userId 或角色放入认证结果。 +- Adapter 不把 token、密码、Cookie、Ticket、Authorization header 或原始上游响应放入 + `ProviderAuthenticationResult`。 +- Adapter 的 `provider()` 定义必须来自受信代码和服务端配置,不能来自登录请求或上游 + 响应。 +- Provider 未启用、配置冲突、Authority 不匹配或状态非 `READY` 时,Registry 不返回 + 路由;Adapter 的网络认证方法不会被调用。 -### 3.1 不改变现有主链路 +外部 I/O 顺序固定为: -- 现有 OAuth 登录流程保持不变 -- 现有本地用户名密码登录保持不变 -- 现有 `/api/v1/auth/providers` 协议保持不变 -- 不在开源版中引入私有 SSO 的真实实现 - -### 3.2 新增的公共扩展协议 - -开源版新增显式被动会话引导接口: - -- `POST /api/v1/auth/session/bootstrap` - -请求: - -```json -{ - "provider": "private-sso" -} +```text +Provider Registry READY gate + → Adapter 验证协议或凭证(事务外) + → ProviderAuthenticationResult + → ExternalIdentityLoginService(事务内) + → PlatformPrincipal + → PlatformSessionService ``` -行为约束: +## 3. 公共协议兼容 -- 默认关闭,由 `skillhub.auth.session-bootstrap.enabled=false` 控制 -- 关闭时返回 `403` -- provider 不存在时返回 `400` -- 外部会话校验失败时返回 `401` -- 成功时建立 skillhub Session,并返回当前用户信息 - -同时新增默认关闭的直连认证兼容接口: +以下 HTTP API 保持不变: +- `GET /api/v1/auth/providers` +- `GET /api/v1/auth/methods` - `POST /api/v1/auth/direct/login` +- `POST /api/v1/auth/session/bootstrap` +- `POST /api/v1/auth/local/login` -请求: +兼容入口仍默认关闭: -```json -{ - "provider": "private-sso", - "username": "alice", - "password": "secret" -} +```yaml +skillhub: + auth: + direct: + enabled: false + session-bootstrap: + enabled: false ``` -行为约束: +关闭时返回 `403`;入口开启但 provider 不存在或不具备对应能力时返回 `400`;被动请求中 +没有有效外部身份时返回 `401`。Provider Authority 不匹配等运行故障返回 `503`。 -- 默认关闭,由 `skillhub.auth.direct.enabled=false` 控制 -- 关闭时返回 `403` -- provider 不存在时返回 `400` -- 成功时建立 skillhub Session,并返回当前用户信息 -- 开源版仍保留原始 `/api/v1/auth/local/login` +`provider=local` 的 direct-login 兼容行为保留,但本地密码不属于外部 Provider,也不进入 +Identity Binding。新的企业认证必须实现下面的 Adapter 契约。 -### 3.3 代码级扩展点 +## 4. Provider Instance 定义 + +Credential 和 Passive Adapter 都声明一个不可变的 +`ProviderInstanceDefinition`: ```java -public interface PassiveSessionAuthenticator { - String providerCode(); - Optional authenticate(HttpServletRequest request); -} +new ProviderInstanceDefinition( + "private-sso", + "private-sso", + "https://sso.example", + "Enterprise SSO", + "private_subject", + "private_subject", + Map.of("private_subject", SubjectNormalization.EXACT), + List.of("display_name"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED, + true +); ``` +字段含义: + +- `providerCode`:稳定 Provider Instance 标识;不能因部署或登录方式变化。 +- `protocol`:写入认证证据和 Authority fingerprint 的协议代码。 +- `canonicalAuthority`:该 Provider 所代表的身份域。 +- `primarySubjectType`:稳定外部主键的类型。 +- `subjectNormalizations`:核心允许的 Subject 类型和规范化规则。 +- 属性列表:统一核心可读取的 display name、email、avatar 候选键及优先级。 +- `emailAssuranceLimit`:该受信 Adapter 允许的 email assurance 上限。 +- `enabled`:Adapter 能力开关;关闭时不进入目录和路由。 + +同一 Provider 同时实现 Credential 和 Passive 能力时,两者返回的定义必须完全一致。 +Registry 检测到定义或同类能力冲突时,对整个 Provider fail closed。 + +## 5. Adapter 契约 + +### 5.1 Browser + +Browser 协议保留各自强类型 exchange: + ```java -public interface DirectAuthProvider { - String providerCode(); - PlatformPrincipal authenticate(DirectAuthRequest request); +public interface BrowserAuthenticationAdapter { + ProviderAuthenticationResult authenticate(T exchange); } ``` -私有版只需要新增实现,例如: +Redirect、Callback、state、nonce、SAML POST 或 CAS Ticket 的传输流程仍由协议模块持有, +不使用万能请求对象。现有 GitHub/GitLab claims Adapter 已使用此结果契约;OAuth 路由由 +Registry 对服务端 `ClientRegistration` 做身份匹配。 -- `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验 -- 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点 +### 5.2 Credential -为减少私有 fork 的前端硬编码,扩展 provider 可额外声明展示名称: +```java +public interface CredentialAuthenticationAdapter { + ProviderInstanceDefinition provider(); + ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request + ); +} +``` -- `DirectAuthProvider.displayName()` 默认回退为 `providerCode()` -- `PassiveSessionAuthenticator.displayName()` 默认回退为 `providerCode()` -- `GET /api/v1/auth/methods` 会返回该展示名称,供登录页直接渲染 +适用于 LDAP bind、企业 RPC 等主动凭证校验。全局入口由 +`skillhub.auth.direct.enabled` 控制。 -## 4. 本轮已落地内容 +### 5.3 Passive -- 新增 `PassiveSessionAuthenticator` SPI -- 新增 `DirectAuthProvider` SPI -- 新增统一会话建立服务 `PlatformSessionService` -- 新增 `POST /api/v1/auth/session/bootstrap` 协议 -- 新增 `POST /api/v1/auth/direct/login` 协议 -- 新增 `skillhub.auth.direct.enabled` 开关,默认关闭 -- 新增 `skillhub.auth.session-bootstrap.enabled` 开关,默认关闭 -- 前端新增基于运行时配置的账号密码兼容接入层 -- 前端新增基于运行时配置的被动会话兼容入口 -- 前端新增显式按钮和可选自动尝试逻辑,默认都不启用 -- 增加 controller 集成测试,验证: - - 默认关闭时不会影响现有系统 - - 启用并提供 authenticator 时可以建立 skillhub Session +```java +public interface PassiveAuthenticationAdapter { + ProviderInstanceDefinition provider(); + Optional authenticate( + PassiveAuthenticationRequest request + ); +} +``` -统一会话建立约束: +适用于可信 Header、签名 JWT、已有企业会话和 SPNEGO。App 层只向 Adapter 提供不可变的 +method、request URI、query、remote address 和 Header 快照;SPI 不依赖 Servlet, +Adapter 不能读取或写入 Session、Cookie response 或 Security Context。全局入口由 +`skillhub.auth.session-bootstrap.enabled` 控制。 -- 本地登录、OAuth 成功回调、direct auth、session bootstrap、mock 登录旁路都走 `PlatformSessionService` -- 会话写入统一依赖 `HttpSession` 属性:`platformPrincipal` 与 `SPRING_SECURITY_CONTEXT` -- 因此在生产环境启用 Spring Session Redis 时,不需要为不同登录方式分别处理 Session 序列化或存储逻辑 -- 交互式登录默认轮换 session id;OAuth 这类已在 Spring Security 认证链中的流程复用现有 `Authentication` +旧名称 `DirectAuthProvider` 和 `PassiveSessionAuthenticator` 仅保留为待删除的源码迁移 +别名;它们已经继承新契约,不能再返回 `PlatformPrincipal`。 -前端运行时配置: +### 5.4 失败分类 + +Adapter 不能抛出携带上游响应、用户名或凭证的自由文本异常。协议校验或上游调用失败时, +统一抛出 `ProviderAuthenticationException`,只携带 +`ProviderAuthenticationFailureCode`: + +- `UPSTREAM_INVALID_CREDENTIALS`、`REPLAY_DETECTED` → 401。 +- `UPSTREAM_ACCESS_DENIED` → 403。 +- `UPSTREAM_UNAVAILABLE`、`UPSTREAM_MISCONFIGURED`、 + `TLS_VALIDATION_FAILED`、`UPSTREAM_INVALID_RESPONSE` → 503。 + +`PassiveAuthenticationAdapter` 只有在请求完全没有外部认证信息时返回 `Optional.empty()`; +断言存在但无效、过期、重放或无法验证时必须使用稳定失败码,不能伪装成“未登录”。 + +## 6. Auth Method Catalog + +`GET /api/v1/auth/methods` 只从 Registry 的 `READY` Provider 和已协商能力投影: + +```text +Browser → OAUTH_REDIRECT +Credential → DIRECT_PASSWORD +Passive → SESSION_BOOTSTRAP +``` + +返回的 action URL 由核心按能力生成。Adapter 不能提供任意 URL,也不能向目录暴露 +Authority、endpoint、属性映射或上游错误。 + +`GET /api/v1/auth/providers` 继续只返回 Browser/OAuth 兼容目录,保持旧前端兼容。 + +## 7. 从旧 SPI 迁移 + +旧实现如果执行了以下操作,必须删除: + +- 按外部 UID 自行查询或创建平台用户。 +- 自行创建 Identity Binding。 +- 从 email、username 或 Provider login 推导平台 userId。 +- 构造 `PlatformPrincipal` 或授予角色。 +- 在 Adapter 中建立 Session。 + +迁移步骤: + +1. 把稳定 provider code、protocol、Authority、Subject 类型和属性映射写入 + `ProviderInstanceDefinition`。 +2. 把外部 UID 转成 `SubjectCandidate`。 +3. 把非敏感资料转成带 `ProviderAttributeTrust` 的属性。 +4. 返回协议一致的 `ProtocolAuthenticationEvidence`。 +5. 让统一身份核心按 `AUTO`、`APPROVAL` 或 `EXISTING_BINDING_ONLY` 完成账号和 Binding。 +6. 为 Adapter 增加 Conformance、稳定失败码、超时和无敏感日志测试。 + +具体实现示例见 +[私有 SSO 接入手册](./12-private-sso-integration-playbook.md)。 + +## 8. 前端与部署 + +前端运行时配置保持不变: - `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` - `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` @@ -132,24 +210,6 @@ public interface DirectAuthProvider { - `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` - `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` -使用方式: - -1. 若要做密码直连,后端启用 `skillhub.auth.direct.enabled=true` -2. 私有版提供 `DirectAuthProvider` 实现 -3. 前端设置 `SKILLHUB_WEB_AUTH_DIRECT_*` -4. 若要做被动会话,后端启用 `skillhub.auth.session-bootstrap.enabled=true` -5. 私有版提供 `PassiveSessionAuthenticator` 实现 -6. 前端设置 bootstrap provider 和开关 -7. 登录页显示兼容入口,或在配置允许时自动尝试一次 bootstrap - -## 5. 后续建议 - -- 私有版实现 `DirectAuthProvider` 和 / 或 `PassiveSessionAuthenticator` 时,只扩展 provider 层,不复制 session 建立逻辑 -- 私有版优先采用显式 bootstrap,而不是透明全局拦截器自动登录 -- 如后续需要登出联动,只通过 `LogoutPropagationHandler` 扩展,不改动现有主登出链路 - -## 6. 实施手册 - -更详细的私有 SSO 接入步骤、最佳实践、测试矩阵和给后续 coding agent 的执行约束,见: - -- [12-private-sso-integration-playbook.md](./12-private-sso-integration-playbook.md) +后端开关、Provider definition 的 `enabled` 和前端开关需要同时满足。建议先启用 +Credential,再人工验证 Passive Cookie/Header 的作用域、SameSite、Secure、CSRF 和代理 +信任边界,最后才评估自动 bootstrap。 diff --git a/docs/12-private-sso-integration-playbook.md b/docs/12-private-sso-integration-playbook.md index 9f6575a4..23231d28 100644 --- a/docs/12-private-sso-integration-playbook.md +++ b/docs/12-private-sso-integration-playbook.md @@ -1,285 +1,242 @@ -# 私有 SSO 接入兼容层实施手册 +# 私有 SSO 接入手册 -> 状态说明:本文是现有私有 SSO 兼容入口的历史实施手册。新接入应先遵循 -> [统一身份联邦设计](./21-unified-identity-federation-design.md),由 Provider 返回协议 -> 验证结果,再由统一核心归一化为 `IdentityAssertion`;不得直接构造 -> `PlatformPrincipal`。本文中相反的代码示例只用于理解当前兼容实现。 +本文给出 Provider Registry 架构下的私有 SSO 实施步骤。开始前先阅读: -## 1. 文档目的 +- [认证扩展与私有 SSO 兼容设计](./11-auth-extensibility-and-private-sso.md) +- [统一身份联邦设计](./21-unified-identity-federation-design.md) -本文档面向两类读者: +## 1. 先决定交互能力 -- 后续在私有仓库中接入企业 SSO 的开发者 -- 需要基于当前开源版兼容层继续开发的 coding agent +根据真实上游能力选择 Adapter,不要实现万能 Provider: -本文档不是认证架构总览,而是实施手册。目标是让后续执行者在不了解全部历史上下文的情况下,也能基于当前成果直接开始接入工作,并且尽量把私有仓库与开源仓库的差异控制在 provider 实现层和少量配置层。 +| 上游能力 | SkillHub Adapter | +|---|---| +| 浏览器 Redirect/Callback | `BrowserAuthenticationAdapter` | +| 用户名密码、LDAP bind、企业 RPC | `CredentialAuthenticationAdapter` | +| 可信 Cookie/Header/JWT、已有企业会话 | `PassiveAuthenticationAdapter` | -相关文档: +一个 Provider Instance 可以同时具备 Credential 和 Passive 能力。两种能力必须使用同一个 +provider code、Authority、Subject 语义和 `ProviderInstanceDefinition`。 -- [03-authentication-design.md](./03-authentication-design.md) -- [06-api-design.md](./06-api-design.md) -- [08-frontend-architecture.md](./08-frontend-architecture.md) -- [11-auth-extensibility-and-private-sso.md](./11-auth-extensibility-and-private-sso.md) +## 2. 固定 Provider Instance -## 2. 当前上下文与已确认约束 - -本轮改造的真实目标不是在开源版里实现私有 SSO,而是先把开源版前后端改造成一个稳定的兼容接入层。 - -已经确认的业务前提如下: - -- 私有 SSO 能返回稳定且唯一的 UID -- 用户名密码校验接口与基于 Cookie 的会话校验接口都返回同一个 UID -- SkillHub 私有版与私有 SSO 会部署在统一主域下,例如 `skill.xxx.com` 与 `sso.xxx.com` -- 私有版可以通过内部接口或 RPC 调用 SSO 的用户名密码校验能力 -- 首次 SSO 登录自动创建 SkillHub 账号 -- 不考虑账号合并 -- 不依赖 email 字段 -- 不要求联动登出,但可保留低优先级扩展点 - -这意味着后续私有 SSO 的正确接入方式是: - -- 把 SSO 建模为新的认证来源 `private-sso` -- 用 `providerCode + subject` 表示外部身份,其中 `subject` 就是 SSO UID -- 复用当前平台的统一 Session 建立逻辑,而不是再造一套登录态机制 - -## 3. 当前兼容层已经提供了什么 - -### 3.1 后端扩展点 - -当前开源版已经提供以下后端兼容能力: - -- `DirectAuthProvider` - - 用于“前端收集用户名密码,后端调用外部系统校验”的模式 -- `PassiveSessionAuthenticator` - - 用于“浏览器自动带上 SSO Cookie,后端读取请求并向 SSO 校验”的模式 -- `PlatformSessionService` - - 用于统一建立 SkillHub Web Session -- `LogoutPropagationHandler` - - 用于未来低优先级登出联动 - -关键代码位置: - -- [DirectAuthProvider.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java) -- [PassiveSessionAuthenticator.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java) -- [PlatformSessionService.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java) - -### 3.2 后端公共协议 - -当前开源版已经提供以下兼容协议: - -- `POST /api/v1/auth/direct/login` -- `POST /api/v1/auth/session/bootstrap` -- `GET /api/v1/auth/methods` - -这些协议的设计原则如下: - -- 默认关闭 -- 默认没有私有 SSO 实现 -- 启用后由 provider 扩展驱动 -- 成功后统一建立标准 Spring Security Session -- 不替换现有 `/api/v1/auth/local/login` -- 不替换现有 OAuth 登录 - -### 3.3 前端兼容层 - -当前开源版前端已经支持通过运行时配置开启兼容入口: - -- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` -- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` -- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` -- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` -- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` - -前端设计原则如下: - -- 默认不启用任何私有登录入口 -- 开启后通过兼容层切换,不破坏现有登录页默认行为 -- 优先走统一目录接口 `/api/v1/auth/methods` -- 被动会话登录优先使用显式 bootstrap,而不是页面加载时偷偷尝试多次 - -## 4. 私有 SSO 的推荐接入方案 - -### 4.1 推荐总策略 - -最佳实践不是只选一种方式,而是同时支持两条链路: - -1. 主路径:`DirectAuthProvider` - - 登录页展示企业 SSO 用户名密码表单 - - 后端通过内部接口或 RPC 调用私有 SSO 校验 - - 校验成功后给用户建立 SkillHub Session - -2. 补充路径:`PassiveSessionAuthenticator` - - 当用户已经在 SSO 系统登录过,并且浏览器会自动带上共享 Cookie 时 - - 登录页允许用户主动点击“从企业 SSO 登录” - - 或在非常谨慎的前提下自动尝试一次 bootstrap - -这样做的理由: - -- 覆盖“尚未登录 SSO”和“已登录 SSO”两种用户状态 -- 不依赖浏览器一定已持有 Cookie -- 不把所有登录成功率押在 Cookie 域、SameSite、过期策略等细节上 -- 不改变开源版原始登录逻辑 - -### 4.2 不推荐的做法 - -以下做法不建议在私有版采用: - -- 在全局 servlet filter 中对所有匿名请求自动尝试 SSO 登录 -- 直接在 controller、filter 或 provider 里手写 `HttpSession` 和 `SecurityContext` 逻辑 -- 把私有 SSO 的 UID 映射成临时整数 ID 再作为用户主标识 -- 按 email 自动合并账号 -- 让前端直接调用私有 SSO 的内部校验接口 -- 为私有版新增一整套与开源版平行的“私有登录 session 机制” - -## 5. 私有版最小差异实施方案 - -### 5.1 后端应新增什么 - -私有仓库建议只新增以下实现类,不改主链路: - -1. 一个 `DirectAuthProvider` 实现 -2. 一个 `PassiveSessionAuthenticator` 实现 -3. 可选的 `LogoutPropagationHandler` 实现 -4. 私有配置属性类或私有配置项 -5. 若 SSO 返回的是外部 UID 而不是现成平台用户,需要补充“根据 SSO UID 查询或创建平台用户”的私有服务 - -建议命名示例: - -- `PrivateSsoDirectAuthProvider` -- `PrivateSsoPassiveSessionAuthenticator` -- `PrivateSsoLogoutPropagationHandler` -- `PrivateSsoProperties` -- `PrivateSsoIdentityService` - -不建议修改这些公共类的职责: - -- `PlatformSessionService` -- `LocalAuthController` -- `AuthController` -- `SecurityConfig` - -### 5.2 后端建议实现步骤 - -#### 步骤 1:定义 provider code - -私有版统一使用稳定 provider code: +先确定稳定值: ```text -private-sso +providerCode: private-sso +protocol: private-sso +canonicalAuthority: https://sso.example +primarySubjectType: private_subject ``` -要求: +选择原则: -- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值 -- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code -- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射 +- `providerCode` 标识一个身份域,不标识某个登录按钮。 +- `canonicalAuthority` 必须能区分不同租户、目录或 SSO 集群。 +- Subject 必须来自不可变外部主键,例如 UID、entryUUID 或 OIDC `sub`。 +- username、display name 和 email 都不能作为默认 Subject。 +- 已产生 Binding 后不能静默改变 protocol、Authority 或 Subject 规范化。 -#### 步骤 2:封装 SSO 客户端 +建议由一个配置组件构造并复用定义: -不要在 provider 实现里直接散落 HTTP 或 RPC 调用。建议先抽一层私有客户端: +```java +@ConfigurationProperties("private-sso") +public class PrivateSsoProperties { + private boolean enabled; + private URI authority; + private Duration connectTimeout; + private Duration readTimeout; + // getters/setters +} + +@Component +public class PrivateSsoProviderDefinition { + private final PrivateSsoProperties properties; + + public ProviderInstanceDefinition get() { + return new ProviderInstanceDefinition( + "private-sso", + "private-sso", + properties.getAuthority().toString(), + "Enterprise SSO", + "private_subject", + "private_subject", + Map.of("private_subject", SubjectNormalization.EXACT), + List.of("display_name"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED, + properties.isEnabled() + ); + } +} +``` + +`provider()` 只读取已经绑定和校验的服务端配置,不连接上游。缺少 Authority、超时或 +凭证配置时,应让 Adapter 不注册、返回 disabled definition,或在启动校验中明确失败; +不能先进入登录目录,再在用户提交凭证后发现配置缺失。 + +## 3. 封装上游客户端 + +协议 I/O 与结果映射分开: ```java public interface PrivateSsoClient { PrivateSsoUser verifyPassword(String username, String password); - Optional verifySession(HttpServletRequest request); + Optional verifySession( + PassiveAuthenticationRequest request + ); } + +public record PrivateSsoUser( + String stableUid, + String displayName, + String email, + boolean emailVerified, + URI avatarUrl, + Instant authenticatedAt +) {} ``` -其中 `PrivateSsoUser` 至少应包含: +客户端要求: -- `uid` -- `username` -- `displayName` +- 明确 connect/read timeout;不得无限等待。 +- HTTPS 校验证书和主机名;不能提供“跳过 TLS 校验”开关。 +- 不记录密码、Cookie、Ticket、Authorization header、token 或完整上游响应。 +- 401/403、5xx、timeout、TLS 和响应格式错误转换为 + `ProviderAuthenticationException` 的稳定失败码;异常 message 和 cause 不进入用户响应 + 或普通业务日志。 +- 不在数据库事务中执行网络 I/O。 +- 上游返回的 provider code、Authority、角色和平台 userId 一律忽略。 -最佳实践: +## 4. 映射统一认证结果 -- 所有超时、重试、日志脱敏、错误码翻译都放在客户端层 -- provider 层只负责把外部结果映射成平台所需的身份对象 -- 禁止记录明文密码 - -#### 步骤 3:实现用户映射服务 - -私有 SSO 不依赖 email,也不做账号合并,因此建议私有版实现一个专用服务: +把上游用户映射为纯协议事实: ```java -public interface PrivateSsoIdentityService { - PlatformPrincipal resolveOrCreate(PrivateSsoUser ssoUser); +final class PrivateSsoResultMapper { + + ProviderAuthenticationResult map(PrivateSsoUser user) { + Map> attributes = + new LinkedHashMap<>(); + put(attributes, "display_name", user.displayName(), + ProviderAttributeTrust.ASSERTED); + put(attributes, "email", user.email(), + user.emailVerified() + ? ProviderAttributeTrust.VERIFIED + : ProviderAttributeTrust.UNVERIFIED); + put(attributes, "avatar_url", + user.avatarUrl() == null ? null : user.avatarUrl().toString(), + ProviderAttributeTrust.ASSERTED); + + return new ProviderAuthenticationResult( + new SubjectCandidate( + "private_subject", + user.stableUid() + ), + List.of(), + attributes, + new ProtocolAuthenticationEvidence( + "private-sso", + user.authenticatedAt(), + Set.of("password") + ) + ); + } } ``` -推荐逻辑: +结果中不能加入 token、密码、Cookie、Ticket、Authorization header、原始 JSON/XML、 +platform userId 或角色。统一核心会再次校验 protocol、Subject allowlist、载荷大小和 +email assurance 上限。 -1. 按 `providerCode=private-sso` 和 `subject=ssoUid` 查现有绑定 -2. 若已存在,加载对应平台用户 -3. 若不存在,则自动创建平台用户 -4. 创建新的身份绑定 -5. 返回 `PlatformPrincipal` - -要求: - -- 自动创建出的用户默认应是 `ACTIVE` -- 不要尝试和现有本地账号或 OAuth 账号按 email 合并 - -#### 步骤 4:实现 `DirectAuthProvider` - -伪代码如下: +## 5. 实现 Credential Adapter ```java @Component -public class PrivateSsoDirectAuthProvider implements DirectAuthProvider { +public class PrivateSsoCredentialAdapter + implements CredentialAuthenticationAdapter { + + private final PrivateSsoProviderDefinition definition; + private final PrivateSsoClient client; + private final PrivateSsoResultMapper mapper; @Override - public String providerCode() { - return "private-sso"; + public ProviderInstanceDefinition provider() { + return definition.get(); } @Override - public PlatformPrincipal authenticate(DirectAuthRequest request) { - PrivateSsoUser ssoUser = privateSsoClient.verifyPassword( + public ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request) { + PrivateSsoUser user = client.verifyPassword( request.username(), request.password() ); - return privateSsoIdentityService.resolveOrCreate(ssoUser); + return mapper.map(user); } } ``` -要求: +Adapter 不查询或创建 SkillHub 用户,不建立 Binding,不决定审批状态,也不建立 Session。 -- 只返回认证成功后的 `PlatformPrincipal` -- 不在这里建立 Session -- 不在这里写 `SecurityContext` - -#### 步骤 5:实现 `PassiveSessionAuthenticator` - -伪代码如下: +## 6. 实现 Passive Adapter ```java @Component -public class PrivateSsoPassiveSessionAuthenticator implements PassiveSessionAuthenticator { +public class PrivateSsoPassiveAdapter + implements PassiveAuthenticationAdapter { + + private final PrivateSsoProviderDefinition definition; + private final PrivateSsoClient client; + private final PrivateSsoResultMapper mapper; @Override - public String providerCode() { - return "private-sso"; + public ProviderInstanceDefinition provider() { + return definition.get(); } @Override - public Optional authenticate(HttpServletRequest request) { - return privateSsoClient.verifySession(request) - .map(privateSsoIdentityService::resolveOrCreate); + public Optional authenticate( + PassiveAuthenticationRequest request) { + return client.verifySession(request).map(mapper::map); } } ``` -要求: +Passive Adapter 只能读取 App 层生成的不可变请求快照;该值对象不提供 Servlet、 +Session 或 `SecurityContext` 能力。不要重定向或写 Cookie。无有效外部会话时返回 +`Optional.empty()`。 -- 只消费当前请求已带上的 Cookie 或其他被动凭证 -- 不主动重定向到 SSO -- 不在这里自行创建 Session +## 7. 配置 Provisioning 和资料同步 -#### 步骤 6:开启配置 +统一身份核心按 Provider 配置处理首次登录: -私有版部署时启用: +```yaml +skillhub: + auth: + identity: + providers: + private-sso: + provisioning-mode: APPROVAL + profile-sync: + display-name: INITIAL_ONLY + email: FILL_IF_EMPTY + avatar-url: PRESERVE_LOCAL +``` + +模式: + +- `AUTO`:首次登录自动创建账号和 Binding。 +- `APPROVAL`:创建 PENDING 账号,管理员批准后才可登录。 +- `EXISTING_BINDING_ONLY`:只允许预先建立的 Binding。 + +不要在 Adapter 中实现上述分支。email 碰撞只会得到 `LINK_REQUIRED`,不会自动绑定或 +合并账号。 + +## 8. 开启兼容入口 + +后端: ```yaml skillhub: @@ -290,154 +247,64 @@ skillhub: enabled: true ``` -建议: +前端: -- 预发环境先只开 direct auth -- passive bootstrap 在确认 Cookie 域和 SameSite 行为可靠后再开启 +```text +SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true +SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=private-sso +SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED=true +SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER=private-sso +``` -## 6. 前端最佳实践 +建议先只开启 Credential。Passive 需要确认代理信任、Cookie domain/path、SameSite、 +Secure 和 CSRF 后再开启;自动 bootstrap 最后评估。 -### 6.1 推荐的登录页策略 +## 9. Provider Conformance -私有版推荐保留当前开源登录页结构,但增加企业 SSO 入口: +Adapter 测试应复用 `ProviderConformanceKit`,并补充协议专属 fixture: -- 保留 OAuth 按钮 -- 本地账号登录是否保留,由私有版自行决定 -- 增加企业 SSO 用户名密码表单,或将现有密码表单切换到 direct auth 兼容接口 -- 增加“从企业 SSO 登录”按钮,对应 `session/bootstrap` +- definition 连续读取稳定且与预期 provider code/Authority 一致。 +- Subject 稳定、非空、类型在 allowlist。 +- evidence protocol 与 definition 一致。 +- email attribute 的 trust 不超过 definition 的 `emailAssuranceLimit`。 +- 认证结果不含 secret-bearing attribute。 +- 401/403、5xx、timeout、TLS、响应格式错误使用 + `ProviderAuthenticationFailureCode` 分类正确。 +- disabled/misconfigured 时 Registry 不返回路由,认证方法零调用。 +- 日志不含用户名密码、token、Cookie、Ticket 或完整响应。 +- Adapter class 不依赖账号、Binding、角色、Session 或 JPA Repository。 +- Credential 与 Passive 使用同一 Provider 时 definition 完全一致。 -推荐优先级: +至少准备以下测试: -1. 首先提供明确可见的企业用户名密码登录 -2. 其次提供“从企业 SSO 登录”按钮 -3. 最后才考虑自动 bootstrap +```text +valid credential +invalid credential +upstream timeout +TLS failure +malformed response +disabled provider +misconfigured provider +stable subject fixture +verified/unverified email +repeated login reuses binding +APPROVAL and EXISTING_BINDING_ONLY +``` -### 6.2 自动 bootstrap 的使用建议 +并发首次登录、唯一约束和 Authority pin 必须在 PostgreSQL 上验证,不能只依赖 H2。 -只有在以下条件同时满足时才建议开启 `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO=true`: +## 10. 部署验收 -- 已确认浏览器在 `skill.xxx.com` 下能稳定带上 SSO Cookie -- 失败时 UI 不会卡死或重复重试 -- 页面只会自动尝试一次 -- 前端不会因为自动尝试失败而阻断正常密码登录 +按 Expand → Contract → Profile/Provisioning → Provider Registry 的顺序部署。测试环境 +验收至少包括: -如果以上条件不满足,建议只显示一个显式按钮,让用户主动触发。 +1. 从旧版本升级,Flyway 成功且旧 OAuth/Binding 可继续登录。 +2. `/api/v1/auth/providers` 旧响应兼容。 +3. `/api/v1/auth/methods` 只展示 `READY` 且全局开关启用的能力。 +4. disabled/misconfigured/Authority mismatch Provider 不展示且不连接上游。 +5. Credential 和 Passive 成功后进入相同 Identity Binding 和 Session 链路。 +6. PENDING、DISABLED、MERGED、system account 均按核心策略 fail closed。 +7. 多 Pod + Redis Session 下刷新和切换实例仍保持登录态。 +8. 回滚到兼容版本时旧 Binding 和本地登录不受损。 -### 6.3 前端禁止事项 - -- 不要把密码提交给非 SkillHub 后端地址 -- 不要在浏览器里解析或操作私有 SSO 内部 Cookie 细节 -- 不要把 bootstrap 失败当成页面级致命错误 - -## 7. Spring Session Redis 相关约束 - -当前平台的统一 Web 登录态是 Spring Session。 - -后续私有版继续接入时,必须遵守以下规则: - -- 所有成功登录都必须通过 `PlatformSessionService` -- 所有 Web 会话都通过 `HttpSession` 持久化 -- 不要手动维护第二份“私有 SSO session” -- 不要在 Redis 中自行定义另一套认证缓存结构来替代 Session - -当前统一服务会做的事: - -- 写入 `platformPrincipal` -- 写入 `SPRING_SECURITY_CONTEXT` -- 在交互式登录流程中轮换 session id - -## 8. 安全最佳实践 - -### 8.1 用户名密码直连场景 - -- SkillHub 后端与私有 SSO 之间必须走内网或可信 RPC -- 明文密码只允许存在于浏览器提交和后端调用 SSO 的瞬时链路中 -- 日志、埋点、异常信息中禁止出现密码 -- 对下游 SSO 调用应设置超时和熔断策略 - -### 8.2 Cookie 被动会话场景 - -- 必须先确认 Cookie 域、路径、SameSite、Secure 策略能满足 `skill.xxx.com` 使用 -- bootstrap 接口应保留 CSRF 防护 -- 失败时只返回认证失败,不泄露过多 Cookie 校验细节 -- 除非有明确产品要求,否则不要做无感知的全站自动登录 filter - -### 8.3 身份映射场景 - -- 只信任稳定 UID,不信任显示名作为主身份依据 -- 不按 email 合并 -- 不按 username 合并 - -## 9. 建议测试矩阵 - -### 9.1 后端单元测试 - -- `DirectAuthProvider` 成功认证 -- `DirectAuthProvider` 认证失败 -- `PassiveSessionAuthenticator` 在有效 Cookie 下成功返回主体 -- `PassiveSessionAuthenticator` 在无效 Cookie 下返回空或失败 -- `PrivateSsoIdentityService` 首次登录自动建号 -- `PrivateSsoIdentityService` 再次登录复用已有绑定 - -### 9.2 后端集成测试 - -- `POST /api/v1/auth/direct/login` 在开启配置后能建立 Session -- `POST /api/v1/auth/session/bootstrap` 在开启配置后能建立 Session -- 成功登录后 `/api/v1/auth/me` 返回正确用户 -- direct auth 与现有 `/api/v1/auth/local/login` 不互相影响 -- bootstrap 关闭时仍返回 `403` -- direct auth 关闭时仍返回 `403` - -### 9.3 前端测试 - -- 未开启运行时开关时,登录页与开源版默认行为一致 -- 开启 direct auth 后,密码表单请求走 `/api/v1/auth/direct/login` -- 开启 bootstrap 按钮后,点击能触发 bootstrap 请求 -- 自动 bootstrap 失败后,用户仍可正常使用其它登录入口 - -### 9.4 手工验收 - -- 已登录 SSO 的浏览器中,bootstrap 能成功建立 SkillHub 登录态 -- 未登录 SSO 的浏览器中,bootstrap 失败但不影响密码登录 -- direct auth 登录成功后,刷新页面仍保持登录态 -- 多 Pod 环境下,借助 Spring Session Redis,切换实例后 session 仍有效 - -## 10. 推荐开发顺序 - -如果后续在私有仓库中真正开始接入,建议按下面顺序推进: - -1. 实现 `PrivateSsoClient` -2. 实现 `PrivateSsoIdentityService` -3. 实现 `PrivateSsoDirectAuthProvider` -4. 先启用 `skillhub.auth.direct.enabled=true` -5. 前端接通 direct auth 入口并完成测试 -6. 再实现 `PrivateSsoPassiveSessionAuthenticator` -7. 确认 Cookie 作用域和浏览器行为 -8. 启用 `session-bootstrap` -9. 视需要决定是否开启自动 bootstrap - -## 11. 给 coding agent 的执行指令 - -如果后续由 AI 继续在私有仓库上完成接入,建议严格遵守以下执行规则: - -- 先读 [11-auth-extensibility-and-private-sso.md](./11-auth-extensibility-and-private-sso.md) 和本文档 -- 不要重构现有公共认证主链路,除非发现明确 bug -- 私有 SSO 的具体实现优先写成 provider、authenticator、client、identity service -- 不要复制 `PlatformSessionService` 逻辑 -- 不要在多个 controller 或 filter 中重复写 Session 建立代码 -- 任何新增前端行为都必须保证运行时配置关闭时完全不影响开源版 -- 所有新增协议和运行时配置必须同步更新文档 -- 每完成一个阶段都跑后端测试;涉及前端改动时再补跑 `pnpm typecheck` 和 `pnpm build` - -## 12. 完成定义 - -当私有版 SSO 接入完成时,应满足以下标准: - -- 开源版默认登录方式仍然不变 -- 私有版只通过扩展点接入,没有复制一套独立登录架构 -- direct auth 可用 -- session bootstrap 可用 -- 首次 SSO 登录自动建号 -- 统一使用 Spring Session Redis 承载 Web 登录态 -- `/api/v1/auth/me`、RBAC、现有业务接口对登录来源无感知 -- 文档、配置、测试都完整 +通过后再决定是否合入 `main`;不要在未验证的情况下直接修改生产 Provider 配置。 diff --git a/docs/21-unified-identity-federation-design.md b/docs/21-unified-identity-federation-design.md index 407f9d09..657a998c 100644 --- a/docs/21-unified-identity-federation-design.md +++ b/docs/21-unified-identity-federation-design.md @@ -1846,6 +1846,10 @@ SAML IdP `PassiveSessionAuthenticator` 的替代 Adapter 目标输出改为 `ProviderAuthenticationResult`,再由核心构造 `IdentityAssertion`;不得返回 Principal。 +App 层必须先把 `HttpServletRequest` 转换成不可变、仅依赖 JDK 的 +`PassiveAuthenticationRequest`(method、request URI、query、remote address 和 Header +快照),再调用 Adapter。Passive SPI 和 Adapter 不得引用 Servlet、`HttpSession` 或 +`SecurityContext`;这保证 Adapter 即使被复用也没有建立平台 Session 的能力。 ### 14.7 Kerberos/SPNEGO diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 3e86289a..3c53b781 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -1,9 +1,8 @@ package com.iflytek.skillhub.service; -import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; -import com.iflytek.skillhub.auth.direct.DirectAuthProvider; -import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; import com.iflytek.skillhub.config.DirectAuthProperties; @@ -23,27 +22,21 @@ import org.springframework.stereotype.Service; @Service public class AuthMethodCatalog { - private final IdentityProviderCatalog identityProviderCatalog; + private final IdentityProviderRegistry identityProviderRegistry; private final DirectAuthProperties directAuthProperties; private final AuthSessionBootstrapProperties sessionBootstrapProperties; - private final List directAuthProviders; - private final List passiveSessionAuthenticators; - public AuthMethodCatalog(IdentityProviderCatalog identityProviderCatalog, + public AuthMethodCatalog(IdentityProviderRegistry identityProviderRegistry, DirectAuthProperties directAuthProperties, - AuthSessionBootstrapProperties sessionBootstrapProperties, - List directAuthProviders, - List passiveSessionAuthenticators) { - this.identityProviderCatalog = identityProviderCatalog; + AuthSessionBootstrapProperties sessionBootstrapProperties) { + this.identityProviderRegistry = identityProviderRegistry; this.directAuthProperties = directAuthProperties; this.sessionBootstrapProperties = sessionBootstrapProperties; - this.directAuthProviders = directAuthProviders; - this.passiveSessionAuthenticators = passiveSessionAuthenticators; } public List listOAuthProviders(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); - return new ArrayList<>(identityProviderCatalog.listReadyProviders().stream() + return new ArrayList<>(identityProviderRegistry.listReadyProviders().stream() .sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode)) .map(provider -> new AuthProviderResponse( provider.providerCode(), @@ -65,43 +58,66 @@ public class AuthMethodCatalog { "/api/v1/auth/local/login" )); - identityProviderCatalog.listReadyProviders().stream() - .sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode)) - .forEach(provider -> methods.add(new AuthMethodResponse( - "oauth-" + provider.providerCode(), - "OAUTH_REDIRECT", - provider.providerCode(), - provider.displayName(), - buildAuthorizationUrl(provider.providerCode(), sanitizedReturnTo) + if (directAuthProperties.isEnabled()) { + methods.add(new AuthMethodResponse( + "direct-local", + "DIRECT_PASSWORD", + "local", + "Local Account", + "/api/v1/auth/direct/login" + )); + } + + identityProviderRegistry.listReadyLoginMethods().stream() + .filter(this::isMethodEnabled) + .sorted(Comparator + .comparing(IdentityProviderLoginMethod::providerCode) + .thenComparing(IdentityProviderLoginMethod::methodType)) + .forEach(provider -> methods.add(toResponse( + provider, + sanitizedReturnTo ))); - if (directAuthProperties.isEnabled()) { - directAuthProviders.stream() - .sorted(Comparator.comparing(DirectAuthProvider::providerCode)) - .forEach(provider -> methods.add(new AuthMethodResponse( - "direct-" + provider.providerCode(), - "DIRECT_PASSWORD", - provider.providerCode(), - provider.displayName(), - "/api/v1/auth/direct/login" - ))); - } - - if (sessionBootstrapProperties.isEnabled()) { - passiveSessionAuthenticators.stream() - .sorted(Comparator.comparing(PassiveSessionAuthenticator::providerCode)) - .forEach(provider -> methods.add(new AuthMethodResponse( - "bootstrap-" + provider.providerCode(), - "SESSION_BOOTSTRAP", - provider.providerCode(), - provider.displayName(), - "/api/v1/auth/session/bootstrap" - ))); - } - return methods; } + private boolean isMethodEnabled(IdentityProviderLoginMethod method) { + return switch (method.methodType()) { + case OAUTH_REDIRECT -> true; + case DIRECT_PASSWORD -> directAuthProperties.isEnabled(); + case SESSION_BOOTSTRAP -> sessionBootstrapProperties.isEnabled(); + }; + } + + private AuthMethodResponse toResponse( + IdentityProviderLoginMethod method, + String returnTo) { + String providerCode = method.providerCode(); + return switch (method.methodType()) { + case OAUTH_REDIRECT -> new AuthMethodResponse( + "oauth-" + providerCode, + IdentityProviderLoginMethodType.OAUTH_REDIRECT.name(), + providerCode, + method.displayName(), + buildAuthorizationUrl(providerCode, returnTo) + ); + case DIRECT_PASSWORD -> new AuthMethodResponse( + "direct-" + providerCode, + IdentityProviderLoginMethodType.DIRECT_PASSWORD.name(), + providerCode, + method.displayName(), + "/api/v1/auth/direct/login" + ); + case SESSION_BOOTSTRAP -> new AuthMethodResponse( + "bootstrap-" + providerCode, + IdentityProviderLoginMethodType.SESSION_BOOTSTRAP.name(), + providerCode, + method.displayName(), + "/api/v1/auth/session/bootstrap" + ); + }; + } + private String buildAuthorizationUrl(String registrationId, String returnTo) { String baseUrl = "/oauth2/authorization/" + registrationId; if (returnTo == null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java index caf62901..f80fa635 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java @@ -1,15 +1,19 @@ package com.iflytek.skillhub.service; -import com.iflytek.skillhub.auth.direct.DirectAuthProvider; -import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.config.DirectAuthProperties; import com.iflytek.skillhub.exception.BadRequestException; import com.iflytek.skillhub.exception.ForbiddenException; import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Map; -import java.util.function.Function; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; /** @@ -20,18 +24,20 @@ import org.springframework.stereotype.Service; public class DirectAuthService { private final DirectAuthProperties properties; - private final Map providersByCode; + private final IdentityProviderRegistry providerRegistry; + private final LocalAuthService localAuthService; + private final ProviderLoginAppService providerLoginAppService; private final SessionBootstrapService sessionBootstrapService; public DirectAuthService(DirectAuthProperties properties, - List providers, + IdentityProviderRegistry providerRegistry, + LocalAuthService localAuthService, + ProviderLoginAppService providerLoginAppService, SessionBootstrapService sessionBootstrapService) { this.properties = properties; - this.providersByCode = providers.stream() - .collect(java.util.stream.Collectors.toUnmodifiableMap( - DirectAuthProvider::providerCode, - Function.identity() - )); + this.providerRegistry = providerRegistry; + this.localAuthService = localAuthService; + this.providerLoginAppService = providerLoginAppService; this.sessionBootstrapService = sessionBootstrapService; } @@ -43,13 +49,54 @@ public class DirectAuthService { throw new ForbiddenException("error.auth.direct.disabled"); } - DirectAuthProvider provider = providersByCode.get(providerCode); - if (provider == null) { - throw new BadRequestException("error.auth.direct.providerUnsupported", providerCode); + PlatformPrincipal principal; + if ("local".equals(providerCode)) { + principal = localAuthService.login(username, password); + } else { + IdentityProviderRegistry.CredentialRoute route; + try { + route = providerRegistry.requireCredentialRoute(providerCode); + } catch (IdentityCoreException exception) { + if (exception.getReasonCode() + == IdentityFailureCode.PROVIDER_DISABLED) { + throw new BadRequestException( + "error.auth.direct.providerUnsupported", + providerCode); + } + throw new AuthFlowException( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.external.providerUnavailable"); + } + var result = authenticate( + route, + username, + password); + if (result == null) { + throw new AuthFlowException( + HttpStatus.UNAUTHORIZED, + "error.auth.external.invalidAssertion"); + } + principal = providerLoginAppService.authenticate( + route.provider(), + result, + request); } - PlatformPrincipal principal = provider.authenticate(new DirectAuthRequest(username, password)); sessionBootstrapService.establishSession(principal, request); return principal; } + + private ProviderAuthenticationResult authenticate( + IdentityProviderRegistry.CredentialRoute route, + String username, + String password) { + try { + return route.adapter().authenticate( + new CredentialAuthenticationRequest( + username, + password)); + } catch (ProviderAuthenticationException exception) { + throw ProviderAuthenticationFailureMapper.map(exception); + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java new file mode 100644 index 00000000..4f66152f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import org.springframework.http.HttpStatus; + +/** + * Maps stable provider failures to public authentication responses without + * exposing upstream details. + */ +final class ProviderAuthenticationFailureMapper { + + private ProviderAuthenticationFailureMapper() { + } + + static AuthFlowException map( + ProviderAuthenticationException exception) { + return switch (exception.getReasonCode()) { + case UPSTREAM_INVALID_CREDENTIALS, + REPLAY_DETECTED -> failure( + HttpStatus.UNAUTHORIZED, + "error.auth.external.invalidAssertion"); + case UPSTREAM_ACCESS_DENIED -> failure( + HttpStatus.FORBIDDEN, + "error.auth.external.accessDenied"); + case UPSTREAM_UNAVAILABLE, + UPSTREAM_MISCONFIGURED, + TLS_VALIDATION_FAILED, + UPSTREAM_INVALID_RESPONSE -> failure( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.external.providerUnavailable"); + }; + } + + private static AuthFlowException failure( + HttpStatus status, + String messageCode) { + return new AuthFlowException(status, messageCode); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java new file mode 100644 index 00000000..30bebc7b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * Application boundary shared by credential and passive provider flows. + */ +@Service +class ProviderLoginAppService { + + private static final String REQUEST_ID_MDC_KEY = "requestId"; + + private final ExternalIdentityLoginService identityLoginService; + + ProviderLoginAppService( + ExternalIdentityLoginService identityLoginService) { + this.identityLoginService = identityLoginService; + } + + PlatformPrincipal authenticate( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result, + HttpServletRequest request) { + try { + IdentityLoginOutcome outcome = identityLoginService.authenticate( + provider, + result, + context(request)); + if (outcome + instanceof IdentityLoginOutcome.Authenticated authenticated) { + return authenticated.principal(); + } + if (outcome instanceof IdentityLoginOutcome.PendingApproval) { + throw failure( + HttpStatus.FORBIDDEN, + "error.auth.external.accountPending"); + } + throw failure( + HttpStatus.FORBIDDEN, + "error.auth.external.linkRequired"); + } catch (IdentityCoreException exception) { + throw mapFailure(exception); + } + } + + private IdentityLoginContext context(HttpServletRequest request) { + return new IdentityLoginContext( + bounded(MDC.get(REQUEST_ID_MDC_KEY), 64), + bounded(request.getRemoteAddr(), 64), + bounded(request.getHeader("User-Agent"), 512)); + } + + private String bounded(String value, int maximum) { + return value == null || value.length() > maximum + ? null + : value; + } + + private AuthFlowException mapFailure( + IdentityCoreException exception) { + return switch (exception.getReasonCode()) { + case PROVIDER_DISABLED -> failure( + HttpStatus.FORBIDDEN, + "error.auth.external.providerUnavailable"); + case PROVIDER_AUTHORITY_MISMATCH -> failure( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.external.providerUnavailable"); + case INVALID_IDENTITY_ASSERTION, + IDENTITY_SUBJECT_MISSING, + IDENTITY_IDENTIFIER_CONFLICT -> failure( + HttpStatus.UNAUTHORIZED, + "error.auth.external.invalidAssertion"); + case ACCESS_DENIED, + ACCOUNT_DISABLED, + ACCOUNT_MERGED, + SYSTEM_ACCOUNT_FORBIDDEN -> failure( + HttpStatus.FORBIDDEN, + "error.auth.external.accessDenied"); + case ACCOUNT_PENDING -> failure( + HttpStatus.FORBIDDEN, + "error.auth.external.accountPending"); + }; + } + + private AuthFlowException failure( + HttpStatus status, + String messageCode) { + return new AuthFlowException(status, messageCode); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java index 5a90e820..1311dd7f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java @@ -1,6 +1,12 @@ package com.iflytek.skillhub.service; -import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; @@ -8,9 +14,13 @@ import com.iflytek.skillhub.exception.BadRequestException; import com.iflytek.skillhub.exception.ForbiddenException; import com.iflytek.skillhub.exception.UnauthorizedException; import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.function.Function; +import java.util.Optional; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; /** @@ -21,18 +31,17 @@ import org.springframework.stereotype.Service; public class SessionBootstrapService { private final AuthSessionBootstrapProperties properties; - private final Map authenticatorsByProvider; + private final IdentityProviderRegistry providerRegistry; + private final ProviderLoginAppService providerLoginAppService; private final PlatformSessionService platformSessionService; public SessionBootstrapService(AuthSessionBootstrapProperties properties, - List authenticators, + IdentityProviderRegistry providerRegistry, + ProviderLoginAppService providerLoginAppService, PlatformSessionService platformSessionService) { this.properties = properties; - this.authenticatorsByProvider = authenticators.stream() - .collect(java.util.stream.Collectors.toUnmodifiableMap( - PassiveSessionAuthenticator::providerCode, - Function.identity() - )); + this.providerRegistry = providerRegistry; + this.providerLoginAppService = providerLoginAppService; this.platformSessionService = platformSessionService; } @@ -41,17 +50,73 @@ public class SessionBootstrapService { throw new ForbiddenException("error.auth.sessionBootstrap.disabled"); } - PassiveSessionAuthenticator authenticator = authenticatorsByProvider.get(providerCode); - if (authenticator == null) { - throw new BadRequestException("error.auth.sessionBootstrap.providerUnsupported", providerCode); + IdentityProviderRegistry.PassiveRoute route; + try { + route = providerRegistry.requirePassiveRoute(providerCode); + } catch (IdentityCoreException exception) { + if (exception.getReasonCode() + == IdentityFailureCode.PROVIDER_DISABLED) { + throw new BadRequestException( + "error.auth.sessionBootstrap.providerUnsupported", + providerCode); + } + throw new AuthFlowException( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.external.providerUnavailable"); } - PlatformPrincipal principal = authenticator.authenticate(request) - .orElseThrow(() -> new UnauthorizedException("error.auth.sessionBootstrap.notAuthenticated")); + var authentication = authenticate( + route, + toPassiveRequest(request)); + if (authentication == null) { + throw new AuthFlowException( + HttpStatus.UNAUTHORIZED, + "error.auth.external.invalidAssertion"); + } + var result = authentication + .orElseThrow(() -> new UnauthorizedException( + "error.auth.sessionBootstrap.notAuthenticated")); + PlatformPrincipal principal = providerLoginAppService.authenticate( + route.provider(), + result, + request); platformSessionService.establishSession(principal, request); return principal; } + private Optional authenticate( + IdentityProviderRegistry.PassiveRoute route, + PassiveAuthenticationRequest request) { + try { + return route.adapter().authenticate(request); + } catch (ProviderAuthenticationException exception) { + throw ProviderAuthenticationFailureMapper.map(exception); + } + } + + private PassiveAuthenticationRequest toPassiveRequest( + HttpServletRequest request) { + Map> headers = new LinkedHashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + Enumeration values = request.getHeaders(name); + headers.put( + name, + values == null + ? List.of() + : Collections.list(values)); + } + } + return new PassiveAuthenticationRequest( + request.getMethod(), + request.getRequestURI(), + request.getQueryString(), + request.getRemoteAddr(), + headers); + } + public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { platformSessionService.establishSession(principal, request); } diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index e00af5a4..39cb8630 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -47,6 +47,11 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found +error.auth.external.providerUnavailable=The identity provider is unavailable +error.auth.external.invalidAssertion=The external identity assertion was rejected +error.auth.external.accessDenied=External account access is denied +error.auth.external.accountPending=The external account is pending approval +error.auth.external.linkRequired=Additional account verification is required error.auth.merge.temporarilyUnavailable=Account merging is temporarily unavailable while the ownership verification flow is being secured error.badRequest=Invalid request error.forbidden=Forbidden diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 73898cf0..eea360fc 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -47,6 +47,11 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 +error.auth.external.providerUnavailable=身份提供方当前不可用 +error.auth.external.invalidAssertion=外部身份断言未通过校验 +error.auth.external.accessDenied=外部账号无权访问 +error.auth.external.accountPending=外部账号正在等待审批 +error.auth.external.linkRequired=需要完成额外的账号验证 error.auth.merge.temporarilyUnavailable=账号合并功能正在进行安全升级,暂时不可用 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index b43ee18c..fa7d30fa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,14 +1,15 @@ package com.iflytek.skillhub.controller; -import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; -import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.dto.AuthMethodResponse; +import com.iflytek.skillhub.dto.AuthProviderResponse; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import com.iflytek.skillhub.service.AuthMethodCatalog; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -72,15 +73,41 @@ class AuthControllerTest { private LocalCredentialRepository localCredentialRepository; @MockBean - private IdentityProviderCatalog identityProviderCatalog; + private AuthMethodCatalog authMethodCatalog; @BeforeEach void setUpReadyIdentityProviders() { - given(identityProviderCatalog.listReadyProviders()) - .willReturn(List.of(new IdentityProviderLoginMethod( + given(authMethodCatalog.listOAuthProviders(null)) + .willReturn(List.of(new AuthProviderResponse( "github", - "GitHub" + "GitHub", + "/oauth2/authorization/github" ))); + given(authMethodCatalog.listOAuthProviders( + "/dashboard/publish" + )).willReturn(List.of(new AuthProviderResponse( + "github", + "GitHub", + "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish" + ))); + given(authMethodCatalog.listMethods( + "/dashboard/publish" + )).willReturn(List.of( + new AuthMethodResponse( + "local-password", + "PASSWORD", + "local", + "Local Account", + "/api/v1/auth/local/login" + ), + new AuthMethodResponse( + "oauth-github", + "OAUTH_REDIRECT", + "github", + "GitHub", + "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish" + ) + )); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index 36fd80bf..c56fb480 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -1,12 +1,13 @@ package com.iflytek.skillhub.controller; -import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.exception.BadRequestException; +import com.iflytek.skillhub.service.SessionBootstrapService; import java.util.List; import java.util.Optional; import java.util.Set; @@ -14,15 +15,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -52,15 +51,30 @@ class SessionBootstrapControllerTest { @MockBean private LocalCredentialRepository localCredentialRepository; + @MockBean + private SessionBootstrapService sessionBootstrapService; + @Test void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "sso-user-1", + "Private SSO User", + null, + null, + "private-sso", + Set.of("USER") + ); + given(sessionBootstrapService.bootstrap( + org.mockito.ArgumentMatchers.eq("private-sso"), + any() + )).willReturn(principal); given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); given(userAccountRepository.findById("sso-user-1")) .willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null))); given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of()); given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false); - MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") + mockMvc.perform(post("/api/v1/auth/session/bootstrap") .with(csrf()) .contentType("application/json") .content(""" @@ -70,21 +84,19 @@ class SessionBootstrapControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) .andExpect(jsonPath("$.data.displayName").value("Private SSO User")) - .andExpect(jsonPath("$.data.canChangePassword").value(false)) - .andReturn() - .getRequest() - .getSession(false); - - mockMvc.perform(get("/api/v1/auth/me").session(session)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userId").value("sso-user-1")) - .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")) .andExpect(jsonPath("$.data.canChangePassword").value(false)); } @Test void sessionBootstrapShouldRejectUnsupportedProvider() throws Exception { + given(sessionBootstrapService.bootstrap( + org.mockito.ArgumentMatchers.eq("unknown"), + any() + )).willThrow(new BadRequestException( + "error.auth.sessionBootstrap.providerUnsupported", + "unknown" + )); + mockMvc.perform(post("/api/v1/auth/session/bootstrap") .with(csrf()) .contentType("application/json") @@ -94,30 +106,4 @@ class SessionBootstrapControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); } - - @TestConfiguration - static class SessionBootstrapTestConfig { - - @Bean - PassiveSessionAuthenticator privateSsoAuthenticator() { - return new PassiveSessionAuthenticator() { - @Override - public String providerCode() { - return "private-sso"; - } - - @Override - public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { - return Optional.of(new PlatformPrincipal( - "sso-user-1", - "Private SSO User", - null, - null, - "private-sso", - Set.of("USER") - )); - } - }; - } - } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index 4b22db46..60cf5d86 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -2,32 +2,33 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; -import com.iflytek.skillhub.auth.direct.DirectAuthProvider; -import com.iflytek.skillhub.auth.direct.DirectAuthRequest; -import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; import com.iflytek.skillhub.config.DirectAuthProperties; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.Test; class AuthMethodCatalogTest { @Test void catalogsOnlyProvidersApprovedByTheIdentityCore() { - IdentityProviderCatalog identityProviderCatalog = - () -> List.of(new IdentityProviderLoginMethod("valid", "Valid")); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + when(registry.listReadyProviders()).thenReturn(List.of( + new IdentityProviderLoginMethod("valid", "Valid") + )); + when(registry.listReadyLoginMethods()).thenReturn(List.of( + new IdentityProviderLoginMethod("valid", "Valid") + )); AuthMethodCatalog catalog = new AuthMethodCatalog( - identityProviderCatalog, + registry, new DirectAuthProperties(), - new AuthSessionBootstrapProperties(), - List.of(), - List.of() + new AuthSessionBootstrapProperties() ); assertThat(catalog.listOAuthProviders(null)) @@ -45,102 +46,69 @@ class AuthMethodCatalogTest { AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); bootstrapProperties.setEnabled(true); - DirectAuthProvider directProvider = new DirectAuthProvider() { - @Override - public String providerCode() { - return "private-sso"; - } - - @Override - public String displayName() { - return "Enterprise Password"; - } - - @Override - public PlatformPrincipal authenticate(DirectAuthRequest request) { - throw new UnsupportedOperationException("not used in catalog test"); - } - }; - - PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { - @Override - public String providerCode() { - return "private-sso"; - } - - @Override - public String displayName() { - return "Enterprise SSO"; - } - - @Override - public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { - return Optional.empty(); - } - }; + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + when(registry.listReadyProviders()).thenReturn(List.of()); + when(registry.listReadyLoginMethods()).thenReturn(List.of( + new IdentityProviderLoginMethod( + "private-sso", + "Enterprise Password", + IdentityProviderLoginMethodType.DIRECT_PASSWORD + ), + new IdentityProviderLoginMethod( + "private-sso", + "Enterprise SSO", + IdentityProviderLoginMethodType.SESSION_BOOTSTRAP + ) + )); AuthMethodCatalog catalog = new AuthMethodCatalog( - List::of, + registry, directAuthProperties, - bootstrapProperties, - List.of(directProvider), - List.of(bootstrapProvider) + bootstrapProperties ); assertThat(catalog.listMethods(null)) .extracting(method -> method.id() + ":" + method.displayName()) .contains( "local-password:Local Account", + "direct-local:Local Account", "direct-private-sso:Enterprise Password", "bootstrap-private-sso:Enterprise SSO" ); } @Test - void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() { - DirectAuthProperties directAuthProperties = new DirectAuthProperties(); - directAuthProperties.setEnabled(true); - AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); - bootstrapProperties.setEnabled(true); - - DirectAuthProvider directProvider = new DirectAuthProvider() { - @Override - public String providerCode() { - return "private-sso"; - } - - @Override - public PlatformPrincipal authenticate(DirectAuthRequest request) { - return mock(PlatformPrincipal.class); - } - }; - - PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { - @Override - public String providerCode() { - return "private-sso"; - } - - @Override - public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { - return Optional.empty(); - } - }; + void globalCompatibilityFlagsFilterRegistryCapabilities() { + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + when(registry.listReadyLoginMethods()).thenReturn(List.of( + new IdentityProviderLoginMethod( + "github", + "GitHub", + IdentityProviderLoginMethodType.OAUTH_REDIRECT + ), + new IdentityProviderLoginMethod( + "private-sso", + "Enterprise Password", + IdentityProviderLoginMethodType.DIRECT_PASSWORD + ), + new IdentityProviderLoginMethod( + "private-sso", + "Enterprise SSO", + IdentityProviderLoginMethodType.SESSION_BOOTSTRAP + ) + )); AuthMethodCatalog catalog = new AuthMethodCatalog( - List::of, - directAuthProperties, - bootstrapProperties, - List.of(directProvider), - List.of(bootstrapProvider) + registry, + new DirectAuthProperties(), + new AuthSessionBootstrapProperties() ); assertThat(catalog.listMethods(null)) - .extracting(method -> method.id() + ":" + method.displayName()) - .contains( - "direct-private-sso:private-sso", - "bootstrap-private-sso:private-sso" - ); + .extracting(method -> method.id()) + .containsExactly("local-password", "oauth-github"); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java new file mode 100644 index 00000000..9b630ddd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java @@ -0,0 +1,244 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.DirectAuthProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +class DirectAuthServiceTest { + + @Test + void resolvesReadyRouteBeforeSendingCredentialsToAdapter() { + DirectAuthProperties properties = new DirectAuthProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + LocalAuthService localAuth = mock(LocalAuthService.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + SessionBootstrapService sessions = + mock(SessionBootstrapService.class); + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + IdentityProviderRegistry.CredentialRoute route = + mock(IdentityProviderRegistry.CredentialRoute.class); + ProviderAuthenticationResult result = result(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_directory", + "Directory User", + null, + null, + "directory", + Set.of("USER")); + HttpServletRequest request = + mock(HttpServletRequest.class); + + when(registry.requireCredentialRoute("directory")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any())) + .thenReturn(result); + when(providerLogin.authenticate( + isNull(), + org.mockito.ArgumentMatchers.eq(result), + org.mockito.ArgumentMatchers.eq(request))) + .thenReturn(principal); + DirectAuthService service = new DirectAuthService( + properties, + registry, + localAuth, + providerLogin, + sessions); + + assertThat(service.authenticate( + "directory", + "alice", + "secret", + request)).isSameAs(principal); + ArgumentCaptor credentials = + ArgumentCaptor.forClass( + CredentialAuthenticationRequest.class); + InOrder order = inOrder( + registry, + adapter, + providerLogin, + sessions); + order.verify(registry) + .requireCredentialRoute("directory"); + order.verify(adapter) + .authenticate(credentials.capture()); + order.verify(providerLogin).authenticate( + isNull(), + org.mockito.ArgumentMatchers.eq(result), + org.mockito.ArgumentMatchers.eq(request)); + order.verify(sessions) + .establishSession(principal, request); + assertThat(credentials.getValue()) + .isEqualTo(new CredentialAuthenticationRequest( + "alice", + "secret")); + verifyNoInteractions(localAuth); + } + + @Test + void unavailableProviderCannotReceiveCredentials() { + DirectAuthProperties properties = new DirectAuthProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + LocalAuthService localAuth = mock(LocalAuthService.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + SessionBootstrapService sessions = + mock(SessionBootstrapService.class); + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + HttpServletRequest request = + mock(HttpServletRequest.class); + when(registry.requireCredentialRoute("disabled")) + .thenThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED)); + DirectAuthService service = new DirectAuthService( + properties, + registry, + localAuth, + providerLogin, + sessions); + + assertThatThrownBy(() -> service.authenticate( + "disabled", + "alice", + "secret", + request)).isInstanceOf(BadRequestException.class); + verifyNoInteractions( + adapter, + localAuth, + providerLogin, + sessions); + } + + @Test + void nullAdapterResultIsRejectedBeforeCoreOrSession() { + DirectAuthProperties properties = new DirectAuthProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + LocalAuthService localAuth = mock(LocalAuthService.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + SessionBootstrapService sessions = + mock(SessionBootstrapService.class); + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + IdentityProviderRegistry.CredentialRoute route = + mock(IdentityProviderRegistry.CredentialRoute.class); + HttpServletRequest request = + mock(HttpServletRequest.class); + when(registry.requireCredentialRoute("broken")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any())).thenReturn(null); + DirectAuthService service = new DirectAuthService( + properties, + registry, + localAuth, + providerLogin, + sessions); + + assertThatThrownBy(() -> service.authenticate( + "broken", + "alice", + "secret", + request)).isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED); + verifyNoInteractions( + localAuth, + providerLogin, + sessions); + } + + @Test + void stableAdapterFailureIsMappedBeforeCoreOrSession() { + DirectAuthProperties properties = new DirectAuthProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + LocalAuthService localAuth = mock(LocalAuthService.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + SessionBootstrapService sessions = + mock(SessionBootstrapService.class); + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + IdentityProviderRegistry.CredentialRoute route = + mock(IdentityProviderRegistry.CredentialRoute.class); + HttpServletRequest request = + mock(HttpServletRequest.class); + when(registry.requireCredentialRoute("directory")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any())).thenThrow( + new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE)); + DirectAuthService service = new DirectAuthService( + properties, + registry, + localAuth, + providerLogin, + sessions); + + assertThatThrownBy(() -> service.authenticate( + "directory", + "alice", + "secret", + request)).isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo( + org.springframework.http.HttpStatus + .SERVICE_UNAVAILABLE); + verifyNoInteractions(localAuth, providerLogin, sessions); + } + + private static ProviderAuthenticationResult result() { + return new ProviderAuthenticationResult( + new SubjectCandidate( + "directory_subject", + "subject-1"), + List.of(), + Map.of(), + new ProtocolAuthenticationEvidence( + "ldap", + Instant.parse("2026-07-30T00:00:00Z"), + Set.of("password"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java new file mode 100644 index 00000000..5abbf3ff --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class ProviderAuthenticationFailureMapperTest { + + @Test + void mapsStableProviderFailuresWithoutExposingUpstreamDetails() { + assertMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS, + HttpStatus.UNAUTHORIZED); + assertMapping( + ProviderAuthenticationFailureCode.REPLAY_DETECTED, + HttpStatus.UNAUTHORIZED); + assertMapping( + ProviderAuthenticationFailureCode.UPSTREAM_ACCESS_DENIED, + HttpStatus.FORBIDDEN); + assertMapping( + ProviderAuthenticationFailureCode.UPSTREAM_UNAVAILABLE, + HttpStatus.SERVICE_UNAVAILABLE); + assertMapping( + ProviderAuthenticationFailureCode.UPSTREAM_MISCONFIGURED, + HttpStatus.SERVICE_UNAVAILABLE); + assertMapping( + ProviderAuthenticationFailureCode.TLS_VALIDATION_FAILED, + HttpStatus.SERVICE_UNAVAILABLE); + assertMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE, + HttpStatus.SERVICE_UNAVAILABLE); + } + + private void assertMapping( + ProviderAuthenticationFailureCode reasonCode, + HttpStatus status) { + AuthFlowException mapped = + ProviderAuthenticationFailureMapper.map( + new ProviderAuthenticationException( + reasonCode, + new IllegalStateException( + "private upstream detail"))); + + assertThat(mapped.getStatus()).isEqualTo(status); + assertThat(mapped.getMessage()) + .doesNotContain("private upstream detail"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SessionBootstrapServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SessionBootstrapServiceTest.java new file mode 100644 index 00000000..492982fe --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SessionBootstrapServiceTest.java @@ -0,0 +1,243 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.springframework.mock.web.MockHttpServletRequest; + +class SessionBootstrapServiceTest { + + @Test + void resolvesReadyRouteBeforeInvokingAdapterAndCore() { + AuthSessionBootstrapProperties properties = + new AuthSessionBootstrapProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + PlatformSessionService sessions = + mock(PlatformSessionService.class); + PassiveAuthenticationAdapter adapter = + mock(PassiveAuthenticationAdapter.class); + IdentityProviderRegistry.PassiveRoute route = + mock(IdentityProviderRegistry.PassiveRoute.class); + ProviderAuthenticationResult result = result(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_private", + "Private User", + null, + null, + "private-sso", + Set.of("USER")); + HttpServletRequest request = request(); + + when(registry.requirePassiveRoute("private-sso")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any(PassiveAuthenticationRequest.class))) + .thenReturn(Optional.of(result)); + when(providerLogin.authenticate( + isNull(), + org.mockito.ArgumentMatchers.eq(result), + org.mockito.ArgumentMatchers.eq(request))) + .thenReturn(principal); + + SessionBootstrapService service = + new SessionBootstrapService( + properties, + registry, + providerLogin, + sessions); + + assertThat(service.bootstrap("private-sso", request)) + .isSameAs(principal); + InOrder order = inOrder( + registry, + adapter, + providerLogin, + sessions); + order.verify(registry) + .requirePassiveRoute("private-sso"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass( + PassiveAuthenticationRequest.class); + order.verify(adapter).authenticate(requestCaptor.capture()); + order.verify(providerLogin).authenticate( + isNull(), + org.mockito.ArgumentMatchers.eq(result), + org.mockito.ArgumentMatchers.eq(request)); + order.verify(sessions) + .establishSession(principal, request); + PassiveAuthenticationRequest captured = requestCaptor.getValue(); + assertThat(captured.method()).isEqualTo("POST"); + assertThat(captured.requestUri()) + .isEqualTo("/api/v1/auth/session/bootstrap"); + assertThat(captured.remoteAddress()).isEqualTo("203.0.113.9"); + assertThat(captured.firstHeader("x-private-assertion")) + .isEqualTo("fixture-assertion"); + } + + @Test + void unavailableProviderCannotInvokeAdapter() { + AuthSessionBootstrapProperties properties = + new AuthSessionBootstrapProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + PlatformSessionService sessions = + mock(PlatformSessionService.class); + PassiveAuthenticationAdapter adapter = + mock(PassiveAuthenticationAdapter.class); + HttpServletRequest request = request(); + when(registry.requirePassiveRoute("disabled")) + .thenThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED)); + SessionBootstrapService service = + new SessionBootstrapService( + properties, + registry, + providerLogin, + sessions); + + assertThatThrownBy( + () -> service.bootstrap("disabled", request)) + .isInstanceOf(BadRequestException.class); + verifyNoInteractions( + adapter, + providerLogin, + sessions); + } + + @Test + void nullOptionalFromAdapterIsRejectedBeforeCoreOrSession() { + AuthSessionBootstrapProperties properties = + new AuthSessionBootstrapProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + PlatformSessionService sessions = + mock(PlatformSessionService.class); + PassiveAuthenticationAdapter adapter = + mock(PassiveAuthenticationAdapter.class); + IdentityProviderRegistry.PassiveRoute route = + mock(IdentityProviderRegistry.PassiveRoute.class); + HttpServletRequest request = request(); + when(registry.requirePassiveRoute("broken")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any(PassiveAuthenticationRequest.class))) + .thenReturn(null); + SessionBootstrapService service = + new SessionBootstrapService( + properties, + registry, + providerLogin, + sessions); + + assertThatThrownBy( + () -> service.bootstrap("broken", request)) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED); + verifyNoInteractions(providerLogin, sessions); + } + + @Test + void stableAdapterFailureIsMappedBeforeCoreOrSession() { + AuthSessionBootstrapProperties properties = + new AuthSessionBootstrapProperties(); + properties.setEnabled(true); + IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + ProviderLoginAppService providerLogin = + mock(ProviderLoginAppService.class); + PlatformSessionService sessions = + mock(PlatformSessionService.class); + PassiveAuthenticationAdapter adapter = + mock(PassiveAuthenticationAdapter.class); + IdentityProviderRegistry.PassiveRoute route = + mock(IdentityProviderRegistry.PassiveRoute.class); + HttpServletRequest request = request(); + when(registry.requirePassiveRoute("private-sso")) + .thenReturn(route); + when(route.adapter()).thenReturn(adapter); + when(adapter.authenticate(any(PassiveAuthenticationRequest.class))) + .thenThrow( + new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .REPLAY_DETECTED)); + SessionBootstrapService service = + new SessionBootstrapService( + properties, + registry, + providerLogin, + sessions); + + assertThatThrownBy( + () -> service.bootstrap("private-sso", request)) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo( + org.springframework.http.HttpStatus.UNAUTHORIZED); + verifyNoInteractions(providerLogin, sessions); + } + + private static ProviderAuthenticationResult result() { + return new ProviderAuthenticationResult( + new SubjectCandidate( + "private_subject", + "subject-1"), + List.of(), + Map.of(), + new ProtocolAuthenticationEvidence( + "private-sso", + Instant.parse("2026-07-30T00:00:00Z"), + Set.of("sso"))); + } + + private static MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/api/v1/auth/session/bootstrap"); + request.setRemoteAddr("203.0.113.9"); + request.addHeader( + "X-Private-Assertion", + "fixture-assertion"); + return request; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java index ef00284b..5de4713e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java @@ -1,20 +1,13 @@ package com.iflytek.skillhub.auth.bootstrap; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter; /** - * Extension point for establishing a SkillHub session from an external passive session, - * such as an SSO cookie already present on the request. + * Compatibility name for passive request adapters. + * + * @deprecated implement {@link PassiveAuthenticationAdapter} directly. */ -public interface PassiveSessionAuthenticator { - - String providerCode(); - - default String displayName() { - return providerCode(); - } - - Optional authenticate(HttpServletRequest request); +@Deprecated(forRemoval = true) +public interface PassiveSessionAuthenticator + extends PassiveAuthenticationAdapter { } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java index 85f528ac..685a7859 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/package-info.java @@ -1,5 +1,5 @@ /** - * Authentication bootstrap helpers that restore session state from existing - * request context without forcing an explicit login step. + * Deprecated compatibility names for passive authentication. New adapters + * live in {@code com.iflytek.skillhub.auth.provider}. */ package com.iflytek.skillhub.auth.bootstrap; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java index eb9b4331..08b2bef6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java @@ -1,17 +1,13 @@ package com.iflytek.skillhub.auth.direct; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; /** - * Extension point for username/password style direct authentication sources. + * Compatibility name for credential adapters. + * + * @deprecated implement {@link CredentialAuthenticationAdapter} directly. */ -public interface DirectAuthProvider { - - String providerCode(); - - default String displayName() { - return providerCode(); - } - - PlatformPrincipal authenticate(DirectAuthRequest request); +@Deprecated(forRemoval = true) +public interface DirectAuthProvider + extends CredentialAuthenticationAdapter { } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java index 83d3c65e..7f9e2b49 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java @@ -1,5 +1,10 @@ package com.iflytek.skillhub.auth.direct; +/** + * @deprecated use + * {@link com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest}. + */ +@Deprecated(forRemoval = true) public record DirectAuthRequest( String username, String password diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java deleted file mode 100644 index f0406c99..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.iflytek.skillhub.auth.direct; - -import com.iflytek.skillhub.auth.local.LocalAuthService; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import org.springframework.stereotype.Component; - -/** - * Direct-auth provider that delegates username and password verification to the local auth flow. - */ -@Component -public class LocalDirectAuthProvider implements DirectAuthProvider { - - private final LocalAuthService localAuthService; - - public LocalDirectAuthProvider(LocalAuthService localAuthService) { - this.localAuthService = localAuthService; - } - - @Override - public String providerCode() { - return "local"; - } - - @Override - public String displayName() { - return "Local Account"; - } - - @Override - public PlatformPrincipal authenticate(DirectAuthRequest request) { - return localAuthService.login(request.username(), request.password()); - } -} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java index 9905d8cb..e2420d71 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/package-info.java @@ -1,5 +1,5 @@ /** - * Pluggable direct-login abstractions used by local or enterprise login - * experiences that bypass OAuth browser redirects. + * Deprecated compatibility names for direct authentication. New adapters live + * in {@code com.iflytek.skillhub.auth.provider}. */ package com.iflytek.skillhub.auth.direct; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ConfiguredProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ConfiguredProviderDescriptorSource.java new file mode 100644 index 00000000..c7bc117b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ConfiguredProviderDescriptorSource.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +/** + * Server-owned static provider configuration consumed by the runtime registry. + */ +interface ConfiguredProviderDescriptorSource { + + List configuredDescriptors(); + + String resolveBrowserProviderCode(ClientRegistration registration); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java index ae6b435e..8fa56fc7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.identity; +import com.iflytek.skillhub.auth.provider.ProviderCapability; import java.util.Objects; /** @@ -8,14 +9,33 @@ import java.util.Objects; */ public record IdentityProviderLoginMethod( String providerCode, - String displayName + String displayName, + IdentityProviderLoginMethodType methodType ) { public IdentityProviderLoginMethod { Objects.requireNonNull(providerCode, "providerCode"); Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(methodType, "methodType"); if (providerCode.isBlank() || displayName.isBlank()) { throw new IllegalArgumentException( "Provider code and display name are required"); } } + + public IdentityProviderLoginMethod( + String providerCode, + String displayName) { + this( + providerCode, + displayName, + IdentityProviderLoginMethodType.OAUTH_REDIRECT); + } + + public ProviderCapability capability() { + return switch (methodType) { + case OAUTH_REDIRECT -> ProviderCapability.BROWSER; + case DIRECT_PASSWORD -> ProviderCapability.CREDENTIAL; + case SESSION_BOOTSTRAP -> ProviderCapability.PASSIVE; + }; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java new file mode 100644 index 00000000..7cd2b4e4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Presentation-safe interaction type for one provider login method. + */ +public enum IdentityProviderLoginMethodType { + OAUTH_REDIRECT, + DIRECT_PASSWORD, + SESSION_BOOTSTRAP +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java new file mode 100644 index 00000000..30c279ee --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java @@ -0,0 +1,67 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter; +import java.util.List; +import java.util.Objects; + +/** + * Unified, fail-closed query and routing surface for configured identity + * providers. + */ +public interface IdentityProviderRegistry extends IdentityProviderCatalog { + + List listReadyLoginMethods(); + + CredentialRoute requireCredentialRoute(String providerCode); + + PassiveRoute requirePassiveRoute(String providerCode); + + class CredentialRoute { + private final ResolvedProviderHandle provider; + private final CredentialAuthenticationAdapter adapter; + + public CredentialRoute( + ResolvedProviderHandle provider, + CredentialAuthenticationAdapter adapter) { + this.provider = Objects.requireNonNull( + provider, + "provider"); + this.adapter = Objects.requireNonNull( + adapter, + "adapter"); + } + + public ResolvedProviderHandle provider() { + return provider; + } + + public CredentialAuthenticationAdapter adapter() { + return adapter; + } + } + + class PassiveRoute { + private final ResolvedProviderHandle provider; + private final PassiveAuthenticationAdapter adapter; + + public PassiveRoute( + ResolvedProviderHandle provider, + PassiveAuthenticationAdapter adapter) { + this.provider = Objects.requireNonNull( + provider, + "provider"); + this.adapter = Objects.requireNonNull( + adapter, + "adapter"); + } + + public ResolvedProviderHandle provider() { + return provider; + } + + public PassiveAuthenticationAdapter adapter() { + return adapter; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java index 4b537bf2..24dd661d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java @@ -2,8 +2,10 @@ package com.iflytek.skillhub.auth.identity; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; /** @@ -21,6 +23,30 @@ public record ProviderAuthenticationResult( ) { private static final Pattern ATTRIBUTE_KEY_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_.:-]{0,127}"); + private static final Set SENSITIVE_ATTRIBUTE_NAMES = Set.of( + "token", + "password", + "passwd", + "pwd", + "cookie", + "ticket", + "authorization", + "credential", + "secret"); + private static final Set SENSITIVE_COMPACT_NAMES = Set.of( + "accesstoken", + "refreshtoken", + "idtoken", + "authtoken", + "bearertoken", + "clientsecret", + "apikey", + "privatekey", + "sessioncookie", + "rawresponse", + "clientassertion"); + private static final Pattern LOWER_TO_UPPER_BOUNDARY = + Pattern.compile("([a-z0-9])([A-Z])"); public ProviderAuthenticationResult { Objects.requireNonNull(primarySubject, "primarySubject"); @@ -34,6 +60,10 @@ public record ProviderAuthenticationResult( if (key == null || !ATTRIBUTE_KEY_PATTERN.matcher(key).matches()) { throw new IllegalArgumentException("Invalid provider attribute key"); } + if (isSensitiveAttribute(key)) { + throw new IllegalArgumentException( + "Sensitive provider attributes are forbidden"); + } Objects.requireNonNull(values, "Provider attribute values"); List copiedValues = List.copyOf(values); if (copiedValues.stream().anyMatch(Objects::isNull)) { @@ -43,4 +73,20 @@ public record ProviderAuthenticationResult( }); attributes = Map.copyOf(copied); } + + private static boolean isSensitiveAttribute(String key) { + String separated = LOWER_TO_UPPER_BOUNDARY + .matcher(key) + .replaceAll("$1_$2"); + String normalized = separated.toLowerCase(Locale.ROOT); + for (String segment : normalized.split("[._:-]")) { + if (SENSITIVE_ATTRIBUTE_NAMES.contains(segment)) { + return true; + } + } + String compact = key.replaceAll("[^A-Za-z0-9]", "") + .toLowerCase(Locale.ROOT); + return SENSITIVE_COMPACT_NAMES.stream() + .anyMatch(compact::contains); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java index 9c87cdb9..8de8fcf0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java @@ -1,15 +1,27 @@ package com.iflytek.skillhub.auth.identity; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition; +import com.iflytek.skillhub.auth.provider.SubjectNormalization; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.stereotype.Component; /** - * Startup projection for the transitional static provider registry. + * Runtime provider registry assembled from trusted static browser + * configuration and built-in credential/passive adapters. * *

Startup reconciliation performs the authority compare-and-set. Every * catalog read then filters the configured descriptor snapshot against the @@ -18,24 +30,36 @@ import org.springframework.stereotype.Component; */ @Component class ReconciledIdentityProviderCatalog - implements IdentityProviderCatalog, ApplicationRunner { + implements IdentityProviderRegistry, + TrustedProviderDescriptorSource, + TrustedProviderRouteResolver, + ApplicationRunner { private static final Logger log = LoggerFactory.getLogger( ReconciledIdentityProviderCatalog.class); - private final TrustedProviderDescriptorSource descriptorSource; + private final ConfiguredProviderDescriptorSource descriptorSource; private final ProviderAuthorityLockService authorityLockService; private final IdentityBindingPreflightService bindingPreflightService; - private final AtomicReference> - configuredProviders = new AtomicReference<>(List.of()); + private final IdentityProviderPolicyProperties policyProperties; + private final List credentialAdapters; + private final List passiveAdapters; + private final AtomicReference snapshot = + new AtomicReference<>(RegistrySnapshot.empty()); ReconciledIdentityProviderCatalog( - TrustedProviderDescriptorSource descriptorSource, + ConfiguredProviderDescriptorSource descriptorSource, ProviderAuthorityLockService authorityLockService, - IdentityBindingPreflightService bindingPreflightService) { + IdentityBindingPreflightService bindingPreflightService, + IdentityProviderPolicyProperties policyProperties, + List credentialAdapters, + List passiveAdapters) { this.descriptorSource = descriptorSource; this.authorityLockService = authorityLockService; this.bindingPreflightService = bindingPreflightService; + this.policyProperties = policyProperties; + this.credentialAdapters = List.copyOf(credentialAdapters); + this.passiveAdapters = List.copyOf(passiveAdapters); } @Override @@ -44,18 +68,14 @@ class ReconciledIdentityProviderCatalog } synchronized void reconcile() { - List descriptors; - try { - descriptors = List.copyOf( - descriptorSource.enabledDescriptors()); - } catch (RuntimeException exception) { - configuredProviders.set(List.of()); - log.error( - "Identity provider descriptor reconciliation failed", - exception); - return; - } - configuredProviders.set(descriptors); + RegistrySnapshot reconciled = assembleSnapshot(); + snapshot.set(reconciled); + List descriptors = reconciled.descriptors() + .values() + .stream() + .sorted(Comparator.comparing( + ProviderDescriptor::providerCode)) + .toList(); reportUnconfiguredBindingProviders(descriptors); for (ProviderDescriptor descriptor : descriptors) { try { @@ -74,6 +94,158 @@ class ReconciledIdentityProviderCatalog } } + private RegistrySnapshot assembleSnapshot() { + Map descriptors = + new LinkedHashMap<>(); + Set browserProviders = new HashSet<>(); + Map credentials = + new LinkedHashMap<>(); + Map passives = + new LinkedHashMap<>(); + Set invalidProviders = new HashSet<>(); + + try { + for (ProviderDescriptor descriptor + : descriptorSource.configuredDescriptors()) { + registerDescriptor( + descriptors, + invalidProviders, + descriptor); + browserProviders.add(descriptor.providerCode()); + } + } catch (RuntimeException exception) { + log.error( + "Configured browser provider discovery failed"); + log.debug( + "Configured browser provider discovery failure type: {}", + exception.getClass().getSimpleName()); + } + + for (CredentialAuthenticationAdapter adapter + : credentialAdapters) { + try { + ProviderInstanceDefinition definition = + adapter.provider(); + if (!definition.enabled()) { + continue; + } + ProviderDescriptor descriptor = + descriptorFrom(definition); + registerDescriptor( + descriptors, + invalidProviders, + descriptor); + CredentialRegistration previous = credentials.putIfAbsent( + descriptor.providerCode(), + new CredentialRegistration( + adapter, + definition.displayName())); + if (previous != null) { + invalidProviders.add(descriptor.providerCode()); + } + } catch (RuntimeException exception) { + log.warn( + "Credential provider adapter is hidden because its trusted definition is invalid"); + log.debug( + "Credential provider definition failure type: {}", + exception.getClass().getSimpleName()); + } + } + + for (PassiveAuthenticationAdapter adapter : passiveAdapters) { + try { + ProviderInstanceDefinition definition = + adapter.provider(); + if (!definition.enabled()) { + continue; + } + ProviderDescriptor descriptor = + descriptorFrom(definition); + registerDescriptor( + descriptors, + invalidProviders, + descriptor); + PassiveRegistration previous = passives.putIfAbsent( + descriptor.providerCode(), + new PassiveRegistration( + adapter, + definition.displayName())); + if (previous != null) { + invalidProviders.add(descriptor.providerCode()); + } + } catch (RuntimeException exception) { + log.warn( + "Passive provider adapter is hidden because its trusted definition is invalid"); + log.debug( + "Passive provider definition failure type: {}", + exception.getClass().getSimpleName()); + } + } + + for (String providerCode : invalidProviders) { + descriptors.remove(providerCode); + browserProviders.remove(providerCode); + credentials.remove(providerCode); + passives.remove(providerCode); + log.error( + "Identity provider '{}' is hidden because trusted definitions or capabilities conflict", + providerCode); + } + + return new RegistrySnapshot( + Map.copyOf(descriptors), + Set.copyOf(browserProviders), + Map.copyOf(credentials), + Map.copyOf(passives)); + } + + private void registerDescriptor( + Map descriptors, + Set invalidProviders, + ProviderDescriptor descriptor) { + ProviderDescriptor existing = descriptors.putIfAbsent( + descriptor.providerCode(), + descriptor); + if (existing != null && !existing.equals(descriptor)) { + invalidProviders.add(descriptor.providerCode()); + } + } + + private ProviderDescriptor descriptorFrom( + ProviderInstanceDefinition definition) { + Map canonicalizers = + new LinkedHashMap<>(); + definition.subjectNormalizations().forEach( + (subjectType, normalization) -> + canonicalizers.put( + subjectType, + canonicalizer(normalization))); + IdentityProviderPolicyProperties.ProviderIdentityPolicy policy = + policyProperties.resolve(definition.providerCode()); + return new ProviderDescriptor( + definition.providerCode(), + definition.protocol(), + definition.canonicalAuthority(), + definition.displayName(), + definition.primarySubjectType(), + definition.legacyPrimarySubjectType(), + canonicalizers, + definition.displayNameAttributes(), + definition.emailAttributes(), + definition.avatarAttributes(), + definition.emailAssuranceLimit(), + policy.provisioningMode(), + policy.profileSyncPolicy()); + } + + private SubjectCanonicalizer canonicalizer( + SubjectNormalization normalization) { + return switch (normalization) { + case EXACT -> SubjectCanonicalizer.EXACT; + case DECIMAL -> SubjectCanonicalizer.DECIMAL; + }; + } + private void reportUnconfiguredBindingProviders( List descriptors) { try { @@ -93,14 +265,159 @@ class ReconciledIdentityProviderCatalog @Override public List listReadyProviders() { - return configuredProviders.get().stream() + RegistrySnapshot current = snapshot.get(); + return current.descriptors().values().stream() + .sorted(Comparator.comparing( + ProviderDescriptor::providerCode)) + .filter(descriptor -> current.browserProviders() + .contains(descriptor.providerCode())) .filter(this::isCurrentlyReady) - .map(descriptor -> new IdentityProviderLoginMethod( + .map(descriptor -> loginMethod( descriptor.providerCode(), - descriptor.displayName())) + descriptor.displayName(), + IdentityProviderLoginMethodType.OAUTH_REDIRECT)) .toList(); } + @Override + public List listReadyLoginMethods() { + RegistrySnapshot current = snapshot.get(); + List methods = new ArrayList<>(); + List descriptors = current.descriptors() + .values() + .stream() + .sorted(Comparator.comparing( + ProviderDescriptor::providerCode)) + .toList(); + for (ProviderDescriptor descriptor : descriptors) { + if (!isCurrentlyReady(descriptor)) { + continue; + } + String providerCode = descriptor.providerCode(); + if (current.browserProviders().contains(providerCode)) { + methods.add(loginMethod( + providerCode, + descriptor.displayName(), + IdentityProviderLoginMethodType.OAUTH_REDIRECT)); + } + CredentialRegistration credential = + current.credentials().get(providerCode); + if (credential != null) { + methods.add(loginMethod( + providerCode, + credential.displayName(), + IdentityProviderLoginMethodType.DIRECT_PASSWORD)); + } + PassiveRegistration passive = + current.passives().get(providerCode); + if (passive != null) { + methods.add(loginMethod( + providerCode, + passive.displayName(), + IdentityProviderLoginMethodType.SESSION_BOOTSTRAP)); + } + } + return List.copyOf(methods); + } + + @Override + public CredentialRoute requireCredentialRoute( + String providerCode) { + RegistrySnapshot current = snapshot.get(); + ProviderDescriptor descriptor = + requireReadyDescriptor(current, providerCode); + CredentialRegistration registration = + current.credentials().get(providerCode); + if (registration == null) { + throw providerDisabled(); + } + return new CredentialRoute( + new DefaultResolvedProviderHandle( + descriptor.providerCode()), + registration.adapter()); + } + + @Override + public PassiveRoute requirePassiveRoute(String providerCode) { + RegistrySnapshot current = snapshot.get(); + ProviderDescriptor descriptor = + requireReadyDescriptor(current, providerCode); + PassiveRegistration registration = + current.passives().get(providerCode); + if (registration == null) { + throw providerDisabled(); + } + return new PassiveRoute( + new DefaultResolvedProviderHandle( + descriptor.providerCode()), + registration.adapter()); + } + + @Override + public ResolvedProviderHandle resolve( + ClientRegistration registration) { + String providerCode = + descriptorSource.resolveBrowserProviderCode(registration); + RegistrySnapshot current = snapshot.get(); + if (!current.browserProviders().contains(providerCode)) { + throw providerDisabled(); + } + ProviderDescriptor descriptor = + requireReadyDescriptor(current, providerCode); + return new DefaultResolvedProviderHandle( + descriptor.providerCode()); + } + + @Override + public ProviderDescriptor require(ResolvedProviderHandle provider) { + if (!(provider instanceof DefaultResolvedProviderHandle handle)) { + throw providerDisabled(); + } + ProviderDescriptor descriptor = snapshot.get() + .descriptors() + .get(handle.providerCode()); + if (descriptor == null) { + throw providerDisabled(); + } + return descriptor; + } + + @Override + public List enabledDescriptors() { + return snapshot.get().descriptors().values().stream() + .sorted(Comparator.comparing( + ProviderDescriptor::providerCode)) + .toList(); + } + + private ProviderDescriptor requireReadyDescriptor( + RegistrySnapshot current, + String providerCode) { + if (providerCode == null) { + throw providerDisabled(); + } + ProviderDescriptor descriptor = + current.descriptors().get(providerCode); + if (descriptor == null) { + throw providerDisabled(); + } + authorityLockService.requirePinnedAuthority(descriptor); + if (!authorityLockService.isReady(descriptor)) { + throw providerDisabled(); + } + return descriptor; + } + + private IdentityProviderLoginMethod loginMethod( + String providerCode, + String displayName, + IdentityProviderLoginMethodType methodType) { + return new IdentityProviderLoginMethod( + providerCode, + displayName, + methodType); + } + private boolean isCurrentlyReady(ProviderDescriptor descriptor) { try { authorityLockService.requirePinnedAuthority(descriptor); @@ -113,4 +430,34 @@ class ReconciledIdentityProviderCatalog return false; } } + + private IdentityCoreException providerDisabled() { + return new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED); + } + + private record CredentialRegistration( + CredentialAuthenticationAdapter adapter, + String displayName) { + } + + private record PassiveRegistration( + PassiveAuthenticationAdapter adapter, + String displayName) { + } + + private record RegistrySnapshot( + Map descriptors, + Set browserProviders, + Map credentials, + Map passives + ) { + private static RegistrySnapshot empty() { + return new RegistrySnapshot( + Map.of(), + Set.of(), + Map.of(), + Map.of()); + } + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java index 2ea73ca0..38cdcc5b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java @@ -27,8 +27,7 @@ import org.springframework.stereotype.Component; */ @Component class StaticTrustedProviderDescriptorSource - implements TrustedProviderDescriptorSource, - TrustedProviderRouteResolver { + implements ConfiguredProviderDescriptorSource { private static final Logger log = LoggerFactory.getLogger( StaticTrustedProviderDescriptorSource.class); @@ -93,7 +92,7 @@ class StaticTrustedProviderDescriptorSource } @Override - public ResolvedProviderHandle resolve( + public String resolveBrowserProviderCode( ClientRegistration registration) { if (registration == null) { throw providerDisabled(); @@ -107,24 +106,11 @@ class StaticTrustedProviderDescriptorSource if (trusted == null || trusted != registration) { throw providerDisabled(); } - return new DefaultResolvedProviderHandle(providerCode); + return providerCode; } @Override - public ProviderDescriptor require(ResolvedProviderHandle provider) { - if (!(provider instanceof DefaultResolvedProviderHandle handle)) { - throw providerDisabled(); - } - ProviderDescriptor descriptor = - descriptors.get(handle.providerCode()); - if (descriptor == null) { - throw providerDisabled(); - } - return descriptor; - } - - @Override - public List enabledDescriptors() { + public List configuredDescriptors() { return descriptors.values().stream() .sorted(Comparator.comparing( ProviderDescriptor::providerCode)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java index dc9e6804..3330459d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; import com.iflytek.skillhub.auth.identity.SubjectCandidate; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.util.HashMap; @@ -62,6 +63,9 @@ public class CustomOidcUserService implements OAuth2UserService> attributes = new LinkedHashMap<>(); @@ -149,7 +155,10 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { .findFirst() .orElse(null); } catch (Exception e) { - log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage()); + log.warn("Failed to fetch verified email from GitLab API"); + log.debug( + "GitLab email lookup failure type: {}", + e.getClass().getSimpleName()); return null; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthAuthenticationExchange.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthAuthenticationExchange.java new file mode 100644 index 00000000..8b768fb3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthAuthenticationExchange.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Objects; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Verified Spring Security OAuth exchange consumed by an OAuth claims adapter. + */ +public record OAuthAuthenticationExchange( + OAuth2UserRequest request, + OAuth2User user +) { + public OAuthAuthenticationExchange { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(user, "user"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java index dec2bb07..b32c3678 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java @@ -1,15 +1,23 @@ package com.iflytek.skillhub.auth.oauth; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; /** * Strategy interface for converting provider-specific OAuth user payloads into normalized claims. */ -public interface OAuthClaimsExtractor { +public interface OAuthClaimsExtractor + extends BrowserAuthenticationAdapter { String getProvider(); ProviderAuthenticationResult extract( OAuth2UserRequest request, OAuth2User oAuth2User); + + @Override + default ProviderAuthenticationResult authenticate( + OAuthAuthenticationExchange exchange) { + return extract(exchange.request(), exchange.user()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index c8de5dae..2940d02e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -15,12 +15,15 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -34,26 +37,40 @@ import org.springframework.stereotype.Service; @Service public class OAuthLoginFlowService { - private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final OAuth2UserService delegate; private final Map extractors; private final TrustedProviderRouteResolver providerRouteResolver; private final ExternalIdentityLoginService identityLoginService; + @Autowired public OAuthLoginFlowService(List extractorList, TrustedProviderRouteResolver providerRouteResolver, ExternalIdentityLoginService identityLoginService) { + this( + extractorList, + providerRouteResolver, + identityLoginService, + new DefaultOAuth2UserService()); + } + + OAuthLoginFlowService( + List extractorList, + TrustedProviderRouteResolver providerRouteResolver, + ExternalIdentityLoginService identityLoginService, + OAuth2UserService delegate) { this.extractors = extractorList.stream() - .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + .collect(Collectors.toMap( + OAuthClaimsExtractor::getProvider, + Function.identity())); this.providerRouteResolver = providerRouteResolver; this.identityLoginService = identityLoginService; + this.delegate = Objects.requireNonNull(delegate, "delegate"); } public AuthenticatedLoginContext loadLoginContext( OAuth2UserRequest request, IdentityLoginContext context) { - OAuth2User upstreamUser = delegate.loadUser(request); String registrationId = request.getClientRegistration().getRegistrationId(); - OAuthClaimsExtractor extractor = extractors.get(registrationId); if (extractor == null) { throw new OAuth2AuthenticationException( @@ -61,22 +78,44 @@ public class OAuthLoginFlowService { ); } + ResolvedProviderHandle provider = + requireReadyProvider(request.getClientRegistration()); + OAuth2User upstreamUser = delegate.loadUser(request); ProviderAuthenticationResult result = - extractor.extract(request, upstreamUser); + extractor.authenticate(new OAuthAuthenticationExchange( + request, + upstreamUser)); PlatformPrincipal principal = authenticate( - request.getClientRegistration(), + provider, result, context); return new AuthenticatedLoginContext(upstreamUser, principal); } + public ResolvedProviderHandle requireReadyProvider( + ClientRegistration registration) { + try { + return providerRouteResolver.resolve(registration); + } catch (IdentityCoreException exception) { + throw mapIdentityFailure(exception); + } + } + public PlatformPrincipal authenticate( ClientRegistration registration, ProviderAuthenticationResult result, IdentityLoginContext context) { + return authenticate( + requireReadyProvider(registration), + result, + context); + } + + PlatformPrincipal authenticate( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result, + IdentityLoginContext context) { try { - ResolvedProviderHandle provider = - providerRouteResolver.resolve(registration); IdentityLoginOutcome outcome = identityLoginService.authenticate( provider, result, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java new file mode 100644 index 00000000..ed72c081 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.provider; + +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; + +/** + * Converts one protocol-specific, already verified browser exchange into + * provider facts. The transport flow retains ownership of redirects, + * callbacks, state and session handling. + * + * @param protocol-specific verified exchange type + */ +public interface BrowserAuthenticationAdapter { + + /** + * @throws ProviderAuthenticationException when the verified exchange + * cannot be accepted or its upstream is unavailable + */ + ProviderAuthenticationResult authenticate(T exchange); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationAdapter.java new file mode 100644 index 00000000..5acb1712 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationAdapter.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.provider; + +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; + +/** + * Active credential authentication capability, for example LDAP bind. + */ +public interface CredentialAuthenticationAdapter { + + ProviderInstanceDefinition provider(); + + /** + * @throws ProviderAuthenticationException for classified authentication + * or upstream failures + */ + ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationRequest.java new file mode 100644 index 00000000..40d5b74b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/CredentialAuthenticationRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.provider; + +/** + * Credentials supplied to an active credential adapter. + */ +public record CredentialAuthenticationRequest( + String username, + String password +) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationAdapter.java new file mode 100644 index 00000000..4d9016f4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationAdapter.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.auth.provider; + +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import java.util.Optional; + +/** + * Passive request authentication capability, for example a trusted gateway + * assertion. Implementations receive an immutable request snapshot without + * access to the servlet session or security context. + */ +public interface PassiveAuthenticationAdapter { + + ProviderInstanceDefinition provider(); + + /** + * @return an authenticated identity, or empty when the request carries no + * external authentication + * @throws ProviderAuthenticationException when an assertion is present + * but invalid, replayed, or cannot be verified + */ + Optional authenticate( + PassiveAuthenticationRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequest.java new file mode 100644 index 00000000..c7223c10 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequest.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.auth.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable, servlet-independent input for passive authentication adapters. + * + *

Header and query values can contain credentials and must not be logged or + * retained. The application layer creates this snapshot without exposing the + * HTTP session or Spring Security context to an adapter.

+ */ +public record PassiveAuthenticationRequest( + String method, + String requestUri, + String queryString, + String remoteAddress, + Map> headers +) { + + public PassiveAuthenticationRequest { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(requestUri, "requestUri"); + Objects.requireNonNull(headers, "headers"); + if (method.isBlank()) { + throw new IllegalArgumentException("HTTP method is required"); + } + if (requestUri.isBlank()) { + throw new IllegalArgumentException("Request URI is required"); + } + + method = method.toUpperCase(Locale.ROOT); + LinkedHashMap> copied = + new LinkedHashMap<>(); + headers.forEach((name, values) -> { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException( + "HTTP header name is required"); + } + Objects.requireNonNull(values, "HTTP header values"); + List copiedValues = List.copyOf(values); + String normalizedName = name.toLowerCase(Locale.ROOT); + copied.merge( + normalizedName, + copiedValues, + PassiveAuthenticationRequest::merge); + }); + headers = Collections.unmodifiableMap(copied); + } + + /** + * Returns an immutable, case-insensitive header lookup result. + */ + public List headerValues(String name) { + Objects.requireNonNull(name, "name"); + return headers.getOrDefault( + name.toLowerCase(Locale.ROOT), + List.of()); + } + + /** + * Returns the first header value, or {@code null} when absent. + */ + public String firstHeader(String name) { + List values = headerValues(name); + return values.isEmpty() ? null : values.getFirst(); + } + + private static List merge( + List existing, + List additional) { + ArrayList merged = new ArrayList<>( + existing.size() + additional.size()); + merged.addAll(existing); + merged.addAll(additional); + return List.copyOf(merged); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationException.java new file mode 100644 index 00000000..f53f1242 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationException.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.auth.provider; + +import java.util.Objects; + +/** + * Authentication-adapter failure carrying only a stable reason code. + * + *

Adapters must not put credentials, tokens, tickets, cookies, upstream + * payloads or user identifiers in the exception message.

+ */ +public final class ProviderAuthenticationException + extends RuntimeException { + + private final ProviderAuthenticationFailureCode reasonCode; + + public ProviderAuthenticationException( + ProviderAuthenticationFailureCode reasonCode) { + super(Objects.requireNonNull(reasonCode, "reasonCode").name()); + this.reasonCode = reasonCode; + } + + public ProviderAuthenticationException( + ProviderAuthenticationFailureCode reasonCode, + Throwable cause) { + super( + Objects.requireNonNull(reasonCode, "reasonCode").name(), + cause); + this.reasonCode = reasonCode; + } + + public ProviderAuthenticationFailureCode getReasonCode() { + return reasonCode; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java new file mode 100644 index 00000000..a1dce2af --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.provider; + +/** + * Stable, non-sensitive failure classification reported by an authentication + * adapter. + */ +public enum ProviderAuthenticationFailureCode { + UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_ACCESS_DENIED, + UPSTREAM_UNAVAILABLE, + UPSTREAM_MISCONFIGURED, + TLS_VALIDATION_FAILED, + UPSTREAM_INVALID_RESPONSE, + REPLAY_DETECTED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderCapability.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderCapability.java new file mode 100644 index 00000000..435929e3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderCapability.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.provider; + +/** + * Interaction capabilities negotiated independently for one provider instance. + */ +public enum ProviderCapability { + BROWSER, + CREDENTIAL, + PASSIVE +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java new file mode 100644 index 00000000..081d5c5a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.auth.provider; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Server-owned identity-domain metadata contributed by a trusted adapter. + * + *

The definition is read during registry reconciliation. It must not be + * assembled from an authentication response or other caller-controlled data.

+ */ +public record ProviderInstanceDefinition( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + String legacyPrimarySubjectType, + Map subjectNormalizations, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit, + boolean enabled +) { + private static final Pattern PROVIDER_CODE_PATTERN = + Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); + private static final Pattern PROTOCOL_PATTERN = + Pattern.compile("[a-z][a-z0-9_-]{0,31}"); + private static final Pattern SUBJECT_TYPE_PATTERN = + Pattern.compile("[a-z][a-z0-9_]{0,63}"); + private static final Pattern ATTRIBUTE_KEY_PATTERN = + Pattern.compile("[A-Za-z][A-Za-z0-9_.:-]{0,127}"); + + public ProviderInstanceDefinition { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(canonicalAuthority, "canonicalAuthority"); + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(primarySubjectType, "primarySubjectType"); + Objects.requireNonNull( + legacyPrimarySubjectType, + "legacyPrimarySubjectType"); + Objects.requireNonNull( + subjectNormalizations, + "subjectNormalizations"); + Objects.requireNonNull(displayNameAttributes, "displayNameAttributes"); + Objects.requireNonNull(emailAttributes, "emailAttributes"); + Objects.requireNonNull(avatarAttributes, "avatarAttributes"); + Objects.requireNonNull(emailAssuranceLimit, "emailAssuranceLimit"); + + if (!PROVIDER_CODE_PATTERN.matcher(providerCode).matches()) { + throw new IllegalArgumentException("Invalid provider code"); + } + if (!PROTOCOL_PATTERN.matcher(protocol).matches()) { + throw new IllegalArgumentException("Invalid protocol code"); + } + if (canonicalAuthority.isBlank() + || canonicalAuthority.length() > 512) { + throw new IllegalArgumentException("Invalid provider authority"); + } + if (displayName.isBlank() || displayName.length() > 128) { + throw new IllegalArgumentException( + "Invalid provider display name"); + } + if (!SUBJECT_TYPE_PATTERN.matcher(primarySubjectType).matches() + || !SUBJECT_TYPE_PATTERN + .matcher(legacyPrimarySubjectType) + .matches()) { + throw new IllegalArgumentException("Invalid subject type"); + } + + subjectNormalizations = Map.copyOf(subjectNormalizations); + if (!subjectNormalizations.containsKey(primarySubjectType) + || !subjectNormalizations + .containsKey(legacyPrimarySubjectType)) { + throw new IllegalArgumentException( + "Primary subject types must have normalization rules"); + } + if (subjectNormalizations.entrySet().stream() + .anyMatch(entry -> entry.getKey() == null + || !SUBJECT_TYPE_PATTERN + .matcher(entry.getKey()) + .matches() + || entry.getValue() == null)) { + throw new IllegalArgumentException( + "Invalid subject normalization rule"); + } + + displayNameAttributes = copyAttributeKeys( + displayNameAttributes, + "displayNameAttributes"); + emailAttributes = copyAttributeKeys( + emailAttributes, + "emailAttributes"); + avatarAttributes = copyAttributeKeys( + avatarAttributes, + "avatarAttributes"); + } + + public ProviderInstanceDefinition( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + String legacyPrimarySubjectType, + Map subjectNormalizations, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit) { + this( + providerCode, + protocol, + canonicalAuthority, + displayName, + primarySubjectType, + legacyPrimarySubjectType, + subjectNormalizations, + displayNameAttributes, + emailAttributes, + avatarAttributes, + emailAssuranceLimit, + true); + } + + private static List copyAttributeKeys( + List values, + String field) { + List copied = List.copyOf(values); + if (copied.stream().anyMatch(value -> value == null + || !ATTRIBUTE_KEY_PATTERN.matcher(value).matches())) { + throw new IllegalArgumentException( + "Invalid provider attribute key in " + field); + } + return copied; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/SubjectNormalization.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/SubjectNormalization.java new file mode 100644 index 00000000..e52ec404 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/SubjectNormalization.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.provider; + +/** + * Trusted normalization strategy for a provider subject. + */ +public enum SubjectNormalization { + EXACT, + DECIMAL +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/package-info.java new file mode 100644 index 00000000..c301b7ed --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/package-info.java @@ -0,0 +1,6 @@ +/** + * Trusted authentication-adapter contracts. Adapters produce protocol facts + * or stable failure codes; the identity core owns provider routing, account + * binding and sessions. + */ +package com.iflytek.skillhub.auth.provider; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResultTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResultTest.java new file mode 100644 index 00000000..b66c59d5 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResultTest.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ProviderAuthenticationResultTest { + + @Test + void rejectsSecretBearingAttributesIncludingNamespacedKeys() { + for (String key : List.of( + "token", + "access_token", + "oauth.auth_token", + "oidc.refresh_token", + "upstream:password", + "session.cookie", + "cas:ticket", + "raw_response", + "clientSecret", + "oauth.clientSecret", + "clientsecret", + "apiKey", + "oauthApiKey", + "accessToken", + "refreshToken", + "idToken", + "privateKey", + "clientAssertion", + "rawResponse")) { + assertThatThrownBy(() -> new ProviderAuthenticationResult( + new SubjectCandidate("oidc_sub", "subject-1"), + List.of(), + Map.of( + key, + List.of(new ProviderAttributeValue( + "secret", + ProviderAttributeTrust.ASSERTED))), + new ProtocolAuthenticationEvidence( + "oidc", + Instant.parse("2026-07-30T00:00:00Z"), + Set.of("authorization_code")))) + .as("attribute key %s", key) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Sensitive provider attributes"); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java index 647925b4..9d7af3a8 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java @@ -1,15 +1,26 @@ package com.iflytek.skillhub.auth.identity; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition; +import com.iflytek.skillhub.auth.provider.SubjectNormalization; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,14 +28,15 @@ import org.mockito.InOrder; class ReconciledIdentityProviderCatalogTest { - private TrustedProviderDescriptorSource descriptorSource; + private ConfiguredProviderDescriptorSource descriptorSource; private ProviderAuthorityLockService authorityLockService; private IdentityBindingPreflightService bindingPreflightService; private ReconciledIdentityProviderCatalog catalog; @BeforeEach void setUp() { - descriptorSource = mock(TrustedProviderDescriptorSource.class); + descriptorSource = mock( + ConfiguredProviderDescriptorSource.class); authorityLockService = mock(ProviderAuthorityLockService.class); bindingPreflightService = mock( IdentityBindingPreflightService.class); @@ -34,13 +46,16 @@ class ReconciledIdentityProviderCatalogTest { catalog = new ReconciledIdentityProviderCatalog( descriptorSource, authorityLockService, - bindingPreflightService); + bindingPreflightService, + new IdentityProviderPolicyProperties(), + List.of(), + List.of()); } @Test void publishesProviderOnlyAfterPinAndPersistedStateReread() { ProviderDescriptor github = descriptor("github", "GitHub"); - when(descriptorSource.enabledDescriptors()) + when(descriptorSource.configuredDescriptors()) .thenReturn(List.of(github)); when(authorityLockService.isReady(github)).thenReturn(true); @@ -64,7 +79,7 @@ class ReconciledIdentityProviderCatalogTest { void hidesProvidersWhosePinOrPersistedStateCheckFails() { ProviderDescriptor github = descriptor("github", "GitHub"); ProviderDescriptor gitlab = descriptor("gitlab", "GitLab"); - when(descriptorSource.enabledDescriptors()) + when(descriptorSource.configuredDescriptors()) .thenReturn(List.of(github, gitlab)); doThrow(new IdentityCoreException( IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH)) @@ -81,7 +96,7 @@ class ReconciledIdentityProviderCatalogTest { @Test void persistedStateReadFailureCannotExposePreviouslyReadyProvider() { ProviderDescriptor github = descriptor("github", "GitHub"); - when(descriptorSource.enabledDescriptors()) + when(descriptorSource.configuredDescriptors()) .thenReturn(List.of(github)); when(authorityLockService.isReady(github)).thenReturn(true); catalog.reconcile(); @@ -102,7 +117,7 @@ class ReconciledIdentityProviderCatalogTest { @Test void reflectsSameAuthorityRecoveryWithoutApplicationRestart() { ProviderDescriptor github = descriptor("github", "GitHub"); - when(descriptorSource.enabledDescriptors()) + when(descriptorSource.configuredDescriptors()) .thenReturn(List.of(github)); when(authorityLockService.isReady(github)) .thenReturn(false, true); @@ -115,6 +130,166 @@ class ReconciledIdentityProviderCatalogTest { "GitHub")); } + @Test + void projectsCredentialAndPassiveCapabilitiesFromOneProvider() { + CredentialAuthenticationAdapter credential = + new CredentialAuthenticationAdapter() { + @Override + public ProviderInstanceDefinition provider() { + return definition( + "private-sso", + "https://sso.example"); + } + + @Override + public ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request) { + throw new UnsupportedOperationException( + "not called"); + } + }; + PassiveAuthenticationAdapter passive = + new PassiveAuthenticationAdapter() { + @Override + public ProviderInstanceDefinition provider() { + return definition( + "private-sso", + "https://sso.example"); + } + + @Override + public Optional authenticate( + PassiveAuthenticationRequest request) { + throw new UnsupportedOperationException( + "not called"); + } + }; + when(descriptorSource.configuredDescriptors()) + .thenReturn(List.of()); + when(authorityLockService.isReady(any())) + .thenReturn(true); + catalog = new ReconciledIdentityProviderCatalog( + descriptorSource, + authorityLockService, + bindingPreflightService, + new IdentityProviderPolicyProperties(), + List.of(credential), + List.of(passive)); + + catalog.reconcile(); + + assertThat(catalog.listReadyLoginMethods()) + .containsExactly( + new IdentityProviderLoginMethod( + "private-sso", + "Private SSO", + IdentityProviderLoginMethodType + .DIRECT_PASSWORD), + new IdentityProviderLoginMethod( + "private-sso", + "Private SSO", + IdentityProviderLoginMethodType + .SESSION_BOOTSTRAP)); + assertThat(catalog.requireCredentialRoute("private-sso") + .adapter()).isSameAs(credential); + assertThat(catalog.requirePassiveRoute("private-sso") + .adapter()).isSameAs(passive); + } + + @Test + void disabledAdapterIsNotRoutableOrInvoked() { + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + ProviderInstanceDefinition disabled = new ProviderInstanceDefinition( + "disabled", + "ldap", + "ldap://directory.example", + "Disabled Directory", + "ldap_entry_uuid", + "ldap_entry_uuid", + Map.of( + "ldap_entry_uuid", + SubjectNormalization.EXACT), + List.of("displayName"), + List.of("mail"), + List.of(), + EmailAssurance.VERIFIED, + false); + when(adapter.provider()).thenReturn(disabled); + when(descriptorSource.configuredDescriptors()) + .thenReturn(List.of()); + catalog = new ReconciledIdentityProviderCatalog( + descriptorSource, + authorityLockService, + bindingPreflightService, + new IdentityProviderPolicyProperties(), + List.of(adapter), + List.of()); + + catalog.reconcile(); + + assertThat(catalog.listReadyLoginMethods()).isEmpty(); + assertThatThrownBy( + () -> catalog.requireCredentialRoute("disabled")) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); + verify(adapter, never()).authenticate(any()); + } + + @Test + void conflictingTrustedDefinitionsFailClosed() { + CredentialAuthenticationAdapter credential = + mock(CredentialAuthenticationAdapter.class); + PassiveAuthenticationAdapter passive = + mock(PassiveAuthenticationAdapter.class); + when(credential.provider()).thenReturn(definition( + "private-sso", + "https://one.example")); + when(passive.provider()).thenReturn(definition( + "private-sso", + "https://two.example")); + when(descriptorSource.configuredDescriptors()) + .thenReturn(List.of()); + catalog = new ReconciledIdentityProviderCatalog( + descriptorSource, + authorityLockService, + bindingPreflightService, + new IdentityProviderPolicyProperties(), + List.of(credential), + List.of(passive)); + + catalog.reconcile(); + + assertThat(catalog.listReadyLoginMethods()).isEmpty(); + assertThat(catalog.enabledDescriptors()).isEmpty(); + verify(credential, never()).authenticate(any()); + verify(passive, never()).authenticate(any()); + } + + @Test + void misconfiguredAdapterCannotBecomeRoutable() { + CredentialAuthenticationAdapter adapter = + mock(CredentialAuthenticationAdapter.class); + when(adapter.provider()).thenThrow( + new IllegalArgumentException( + "invalid trusted configuration")); + when(descriptorSource.configuredDescriptors()) + .thenReturn(List.of()); + catalog = new ReconciledIdentityProviderCatalog( + descriptorSource, + authorityLockService, + bindingPreflightService, + new IdentityProviderPolicyProperties(), + List.of(adapter), + List.of()); + + catalog.reconcile(); + + assertThat(catalog.listReadyLoginMethods()).isEmpty(); + verify(adapter, never()).authenticate(any()); + } + private static ProviderDescriptor descriptor( String providerCode, String displayName) { @@ -133,4 +308,23 @@ class ReconciledIdentityProviderCatalogTest { List.of("picture"), EmailAssurance.VERIFIED); } + + private static ProviderInstanceDefinition definition( + String providerCode, + String authority) { + return new ProviderInstanceDefinition( + providerCode, + "private-sso", + authority, + "Private SSO", + "private_subject", + "private_subject", + Map.of( + "private_subject", + SubjectNormalization.EXACT), + List.of("display_name"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandleTestFixture.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandleTestFixture.java new file mode 100644 index 00000000..f8ac527a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandleTestFixture.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Test-only factory for the sealed provider handle. + */ +public final class ResolvedProviderHandleTestFixture { + + private ResolvedProviderHandleTestFixture() { + } + + public static ResolvedProviderHandle handle( + String providerCode) { + return new DefaultResolvedProviderHandle(providerCode); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java index 48c29e3a..d321f32b 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java @@ -21,10 +21,11 @@ class StaticTrustedProviderDescriptorSourceTest { Set.of("github"), github); - ResolvedProviderHandle handle = source.resolve(github); - ProviderDescriptor descriptor = source.require(handle); + String providerCode = + source.resolveBrowserProviderCode(github); + ProviderDescriptor descriptor = descriptor(source, providerCode); - assertThat(handle.providerCode()).isEqualTo("github"); + assertThat(providerCode).isEqualTo("github"); assertThat(descriptor.protocol()).isEqualTo("oauth2-github"); assertThat(descriptor.canonicalAuthority()) .isEqualTo("https://github.com"); @@ -63,8 +64,7 @@ class StaticTrustedProviderDescriptorSourceTest { Set.of("github"), policies); - ProviderDescriptor descriptor = - source.require(source.resolve(github)); + ProviderDescriptor descriptor = descriptor(source, "github"); assertThat(descriptor.provisioningMode()) .isEqualTo(ProvisioningMode.APPROVAL); @@ -83,7 +83,8 @@ class StaticTrustedProviderDescriptorSourceTest { Set.of("github"), trustedGithub); - assertThatThrownBy(() -> source.resolve(github())) + assertThatThrownBy( + () -> source.resolveBrowserProviderCode(github())) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); @@ -98,8 +99,7 @@ class StaticTrustedProviderDescriptorSourceTest { Set.of("gitlab"), gitlab); - ProviderDescriptor descriptor = - source.require(source.resolve(gitlab)); + ProviderDescriptor descriptor = descriptor(source, "gitlab"); assertThat(descriptor.protocol()).isEqualTo("oauth2-gitlab"); assertThat(descriptor.canonicalAuthority()) @@ -119,7 +119,7 @@ class StaticTrustedProviderDescriptorSourceTest { oidc); ProviderDescriptor descriptor = - source.require(source.resolve(oidc)); + descriptor(source, "corp-oidc"); assertThat(descriptor.protocol()).isEqualTo("oidc"); assertThat(descriptor.canonicalAuthority()) @@ -146,12 +146,14 @@ class StaticTrustedProviderDescriptorSourceTest { github, unsupported); - assertThat(source.enabledDescriptors()).isEmpty(); - assertThatThrownBy(() -> source.resolve(github)) + assertThat(source.configuredDescriptors()).isEmpty(); + assertThatThrownBy( + () -> source.resolveBrowserProviderCode(github)) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); - assertThatThrownBy(() -> source.resolve(unsupported)) + assertThatThrownBy( + () -> source.resolveBrowserProviderCode(unsupported)) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); @@ -167,8 +169,9 @@ class StaticTrustedProviderDescriptorSourceTest { Set.of("custom"), ambiguous); - assertThat(source.enabledDescriptors()).isEmpty(); - assertThatThrownBy(() -> source.resolve(ambiguous)) + assertThat(source.configuredDescriptors()).isEmpty(); + assertThatThrownBy( + () -> source.resolveBrowserProviderCode(ambiguous)) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); @@ -186,6 +189,16 @@ class StaticTrustedProviderDescriptorSourceTest { extractorCodes); } + private static ProviderDescriptor descriptor( + StaticTrustedProviderDescriptorSource source, + String providerCode) { + return source.configuredDescriptors().stream() + .filter(candidate -> candidate.providerCode() + .equals(providerCode)) + .findFirst() + .orElseThrow(); + } + private static OAuth2ClientProperties.Registration properties( String clientId, String clientName) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java index 8a0df79c..2f7a27d6 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -6,11 +6,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; -import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.time.Instant; import java.util.List; @@ -61,10 +64,15 @@ class CustomOidcUserServiceTest { "https://idp.example/avatar.png", "okta", Set.of("USER", "SUPER_ADMIN")); + ResolvedProviderHandle provider = + ResolvedProviderHandleTestFixture.handle("okta"); + when(loginFlowService.requireReadyProvider( + request.getClientRegistration())) + .thenReturn(provider); when(delegate.loadUser(request)).thenReturn(upstreamUser); when(contextResolver.current()).thenReturn(loginContext); when(loginFlowService.authenticate( - eq(request.getClientRegistration()), + eq(provider), any(ProviderAuthenticationResult.class), eq(loginContext))) .thenReturn(platformPrincipal); @@ -75,7 +83,7 @@ class CustomOidcUserServiceTest { ArgumentCaptor.forClass( ProviderAuthenticationResult.class); verify(loginFlowService).authenticate( - eq(request.getClientRegistration()), + eq(provider), resultCaptor.capture(), eq(loginContext)); ProviderAuthenticationResult result = resultCaptor.getValue(); @@ -101,6 +109,30 @@ class CustomOidcUserServiceTest { .contains("ROLE_USER", "ROLE_SUPER_ADMIN"); } + @Test + void unavailableRouteCannotInvokeOidcUpstream() { + OAuthLoginFlowService loginFlowService = + mock(OAuthLoginFlowService.class); + OAuth2UserService delegate = mock(); + OAuthIdentityLoginContextResolver contextResolver = mock(); + CustomOidcUserService service = + new CustomOidcUserService( + loginFlowService, + delegate, + contextResolver); + OidcUserRequest request = oidcRequest(); + when(loginFlowService.requireReadyProvider( + request.getClientRegistration())) + .thenThrow(new OAuth2AuthenticationException( + new org.springframework.security.oauth2.core.OAuth2Error( + "provider_disabled"))); + + assertThatThrownBy(() -> service.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class); + + verifyNoInteractions(delegate, contextResolver); + } + @Test void conversionPreservesUnverifiedEmailAsUntrustedFact() { ProviderAuthenticationResult result = @@ -198,6 +230,11 @@ class CustomOidcUserServiceTest { "email", "user@company.com", "email_verified", false, "preferred_username", "unverified-user")); + ResolvedProviderHandle provider = + ResolvedProviderHandleTestFixture.handle("okta"); + when(loginFlowService.requireReadyProvider( + request.getClientRegistration())) + .thenReturn(provider); when(delegate.loadUser(request)).thenReturn(upstreamUser); when(contextResolver.current()).thenReturn(loginContext); @@ -205,7 +242,7 @@ class CustomOidcUserServiceTest { ArgumentCaptor.forClass( ProviderAuthenticationResult.class); when(loginFlowService.authenticate( - eq(request.getClientRegistration()), + eq(provider), resultCaptor.capture(), eq(loginContext))) .thenThrow(new OAuth2AuthenticationException( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 99f8ffad..e65a5206 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -3,8 +3,13 @@ package com.iflytek.skillhub.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; @@ -14,6 +19,8 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture; import com.iflytek.skillhub.auth.identity.SubjectCandidate; import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; @@ -23,14 +30,105 @@ import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; class OAuthLoginFlowServiceTest { + @Test + void resolvesReadyRouteBeforeOAuthUpstreamAndAdapterCalls() { + OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class); + when(extractor.getProvider()).thenReturn("github"); + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuth2UserService delegate = + mock(); + OAuth2UserRequest request = mock(OAuth2UserRequest.class); + OAuth2User upstreamUser = mock(OAuth2User.class); + ResolvedProviderHandle provider = + ResolvedProviderHandleTestFixture.handle("github"); + ClientRegistration registration = registration(); + when(request.getClientRegistration()).thenReturn(registration); + when(resolver.resolve(registration)).thenReturn(provider); + when(delegate.loadUser(request)).thenReturn(upstreamUser); + when(extractor.authenticate(any())).thenReturn(result()); + when(identityLoginService.authenticate( + eq(provider), + any(), + eq(context()))) + .thenReturn(new IdentityLoginOutcome.Authenticated( + principal(), + false, + false)); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(extractor), + resolver, + identityLoginService, + delegate); + clearInvocations(extractor); + + service.loadLoginContext(request, context()); + + InOrder order = inOrder( + resolver, + delegate, + extractor, + identityLoginService); + order.verify(resolver).resolve(registration); + order.verify(delegate).loadUser(request); + order.verify(extractor).authenticate(any()); + order.verify(identityLoginService).authenticate( + eq(provider), + any(), + eq(context())); + } + + @Test + void unavailableRouteCannotInvokeOAuthUpstreamOrAdapter() { + OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class); + when(extractor.getProvider()).thenReturn("github"); + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuth2UserService delegate = + mock(); + OAuth2UserRequest request = mock(OAuth2UserRequest.class); + ClientRegistration registration = registration(); + when(request.getClientRegistration()).thenReturn(registration); + when(resolver.resolve(registration)).thenThrow( + new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED)); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(extractor), + resolver, + identityLoginService, + delegate); + clearInvocations(extractor); + + assertThatThrownBy(() -> + service.loadLoginContext(request, context())) + .isInstanceOfSatisfying( + OAuth2AuthenticationException.class, + exception -> assertThat( + exception.getError().getErrorCode()) + .isEqualTo("provider_disabled")); + + verifyNoInteractions(delegate, identityLoginService); + verify(extractor, never()).authenticate(any()); + } + @Test void authenticateReturnsPrincipalOnlyForAuthenticatedOutcome() { TrustedProviderRouteResolver resolver = diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequestTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequestTest.java new file mode 100644 index 00000000..585eaeb1 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/PassiveAuthenticationRequestTest.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.auth.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PassiveAuthenticationRequestTest { + + @Test + void copiesAndNormalizesHeadersWithoutExposingMutableState() { + ArrayList values = new ArrayList<>( + List.of("first")); + Map> headers = + new LinkedHashMap<>(); + headers.put("X-Identity-Assertion", values); + headers.put( + "x-identity-assertion", + List.of("second")); + + PassiveAuthenticationRequest request = + new PassiveAuthenticationRequest( + "post", + "/api/v1/auth/session/bootstrap", + "source=portal", + "203.0.113.9", + headers); + values.add("mutated"); + headers.clear(); + + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headerValues("X-IDENTITY-ASSERTION")) + .containsExactly("first", "second"); + assertThat(request.headers()) + .containsOnlyKeys("x-identity-assertion"); + assertThatThrownBy(() -> + request.headers().put("other", List.of("value"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsMissingTransportIdentity() { + assertThatThrownBy(() -> + new PassiveAuthenticationRequest( + " ", + "/bootstrap", + null, + null, + Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("method"); + assertThatThrownBy(() -> + new PassiveAuthenticationRequest( + "POST", + " ", + null, + null, + Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("URI"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java new file mode 100644 index 00000000..6a564cd4 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java @@ -0,0 +1,190 @@ +package com.iflytek.skillhub.auth.provider; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +/** + * Reusable assertions for trusted in-tree provider adapters. + * + *

Each adapter test supplies deterministic protocol fixtures and invokes + * the relevant capability assertion. Network error classification and timeout + * cases remain protocol-specific tests beside the adapter.

+ */ +public final class ProviderConformanceKit { + + private static final List FORBIDDEN_DEPENDENCIES = List.of( + "com/iflytek/skillhub/auth/rbac/PlatformPrincipal", + "com/iflytek/skillhub/auth/identity/IdentityAssertionFactory", + "com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory", + "com/iflytek/skillhub/auth/session/PlatformSessionService", + "com/iflytek/skillhub/auth/repository/", + "com/iflytek/skillhub/domain/user/UserAccount", + "com/iflytek/skillhub/domain/namespace/", + "jakarta/persistence/", + "jakarta/servlet/", + "javax/servlet/", + "org/springframework/data/jpa/", + "org/springframework/security/core/context/", + "org/springframework/security/web/context/"); + + private ProviderConformanceKit() { + } + + public static ProviderAuthenticationResult verifyBrowser( + ProviderInstanceDefinition provider, + BrowserAuthenticationAdapter adapter, + T fixture) { + assertThat(provider).isNotNull(); + ProviderAuthenticationResult result = + adapter.authenticate(fixture); + verifyResult(provider, result); + return result; + } + + public static ProviderAuthenticationResult verifyCredential( + CredentialAuthenticationAdapter adapter, + CredentialAuthenticationRequest fixture) { + ProviderInstanceDefinition provider = + verifyDefinition(adapter.provider(), adapter.provider()); + ProviderAuthenticationResult result = + adapter.authenticate(fixture); + verifyResult(provider, result); + return result; + } + + public static ProviderAuthenticationResult verifyPassive( + PassiveAuthenticationAdapter adapter, + PassiveAuthenticationRequest fixture) { + ProviderInstanceDefinition provider = + verifyDefinition(adapter.provider(), adapter.provider()); + Optional authentication = + adapter.authenticate(fixture); + assertThat(authentication) + .as("positive passive fixture must return an Optional") + .isNotNull() + .isPresent(); + ProviderAuthenticationResult result = authentication.orElseThrow(); + verifyResult(provider, result); + return result; + } + + /** + * Verifies stable subjects from two equivalent, independently valid + * protocol fixtures. Passive adapters should use distinct nonces or + * assertions so this check does not conflict with replay protection. + */ + public static void verifyStableSubjects( + ProviderInstanceDefinition provider, + ProviderAuthenticationResult first, + ProviderAuthenticationResult second) { + verifyResult(provider, first); + verifyResult(provider, second); + assertThat(second.primarySubject()) + .as("equivalent fixtures must produce a stable primary subject") + .isEqualTo(first.primarySubject()); + assertThat(second.alternateSubjects()) + .as("equivalent fixtures must produce stable alternate subjects") + .isEqualTo(first.alternateSubjects()); + } + + public static void verifyResult( + ProviderInstanceDefinition provider, + ProviderAuthenticationResult result) { + assertThat(result).isNotNull(); + assertThat(result.evidence().protocol()) + .isEqualTo(provider.protocol()); + assertAllowedSubject(provider, result.primarySubject()); + for (SubjectCandidate alternate : result.alternateSubjects()) { + assertAllowedSubject(provider, alternate); + } + assertEmailAssurance(provider, result); + } + + public static void verifyAdapterBoundary(Class... adapterClasses) + throws IOException { + for (Class adapterClass : adapterClasses) { + String bytecode = classFileConstants(adapterClass); + assertThat(FORBIDDEN_DEPENDENCIES) + .as( + "forbidden dependencies of %s", + adapterClass.getName()) + .noneMatch(bytecode::contains); + } + } + + private static ProviderInstanceDefinition verifyDefinition( + ProviderInstanceDefinition first, + ProviderInstanceDefinition second) { + assertThat(first) + .as("provider definition is required") + .isNotNull(); + assertThat(second) + .as("provider definition must be deterministic") + .isEqualTo(first); + return first; + } + + private static void assertAllowedSubject( + ProviderInstanceDefinition provider, + SubjectCandidate subject) { + assertThat(provider.subjectNormalizations()) + .as("subject type must be declared by the provider") + .containsKey(subject.type()); + assertThat(subject.value()).isNotBlank(); + } + + private static void assertEmailAssurance( + ProviderInstanceDefinition provider, + ProviderAuthenticationResult result) { + for (String attribute : provider.emailAttributes()) { + for (ProviderAttributeValue value : result.attributes() + .getOrDefault(attribute, List.of())) { + EmailAssurance asserted = + assurance(value.trust()); + assertThat(asserted.ordinal()) + .as( + "email assurance for attribute %s", + attribute) + .isLessThanOrEqualTo( + provider.emailAssuranceLimit().ordinal()); + } + } + } + + private static EmailAssurance assurance( + ProviderAttributeTrust trust) { + return switch (trust) { + case UNVERIFIED -> EmailAssurance.UNVERIFIED; + case ASSERTED -> EmailAssurance.PROVIDER_ASSERTED; + case VERIFIED -> EmailAssurance.VERIFIED; + }; + } + + private static String classFileConstants(Class type) + throws IOException { + String resource = "/" + + type.getName().replace('.', '/') + + ".class"; + try (InputStream input = + type.getResourceAsStream(resource)) { + assertThat(input) + .as( + "compiled class resource for %s", + type.getName()) + .isNotNull(); + return new String( + input.readAllBytes(), + StandardCharsets.ISO_8859_1); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java new file mode 100644 index 00000000..78c15ebc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java @@ -0,0 +1,169 @@ +package com.iflytek.skillhub.auth.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import com.iflytek.skillhub.auth.oauth.GitHubClaimsExtractor; +import com.iflytek.skillhub.auth.oauth.GitLabClaimsExtractor; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ProviderConformanceKitTest { + + @Test + void verifiesDeterministicDefinitionAndProtocolFacts() { + AtomicReference observed = + new AtomicReference<>(); + CredentialAuthenticationAdapter adapter = + new CredentialAuthenticationAdapter() { + @Override + public ProviderInstanceDefinition provider() { + return providerDefinition(); + } + + @Override + public ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request) { + observed.set(request); + return authenticationResult(); + } + }; + CredentialAuthenticationRequest fixture = + new CredentialAuthenticationRequest( + "alice", + "fixture-password"); + + ProviderAuthenticationResult result = + ProviderConformanceKit.verifyCredential( + adapter, + fixture); + ProviderConformanceKit.verifyStableSubjects( + adapter.provider(), + result, + adapter.authenticate(fixture)); + + assertThat(result.primarySubject().value()) + .isEqualTo("entry-123"); + assertThat(observed.get()).isEqualTo(fixture); + } + + @Test + void providerAdaptersCannotReachAccountsRolesSessionsOrPersistence() + throws IOException { + ProviderConformanceKit.verifyAdapterBoundary( + BrowserAuthenticationAdapter.class, + CredentialAuthenticationAdapter.class, + PassiveAuthenticationAdapter.class, + GitHubClaimsExtractor.class, + GitLabClaimsExtractor.class); + } + + @Test + void verifiesBrowserAndPassivePositiveFixtures() throws IOException { + ProviderInstanceDefinition provider = providerDefinition(); + ProviderAuthenticationResult result = authenticationResult(); + PassiveAuthenticationRequest fixture = + new PassiveAuthenticationRequest( + "POST", + "/api/v1/auth/session/bootstrap", + null, + "127.0.0.1", + Map.of( + "X-Private-Assertion", + List.of("fixture-assertion"))); + BrowserAuthenticationAdapter browser = + exchange -> result; + PassiveAuthenticationAdapter passive = + new PassiveAuthenticationAdapter() { + @Override + public ProviderInstanceDefinition provider() { + return provider; + } + + @Override + public Optional authenticate( + PassiveAuthenticationRequest request) { + assertThat(request).isEqualTo(fixture); + return Optional.of(result); + } + }; + + assertThat(ProviderConformanceKit.verifyBrowser( + provider, + browser, + "verified-exchange")).isSameAs(result); + assertThat(ProviderConformanceKit.verifyPassive( + passive, + fixture)) + .isSameAs(result); + ProviderConformanceKit.verifyAdapterBoundary( + passive.getClass()); + } + + @Test + void rejectsEmailTrustAboveProviderLimit() { + ProviderInstanceDefinition provider = + providerDefinition(EmailAssurance.UNVERIFIED); + + assertThatThrownBy(() -> ProviderConformanceKit.verifyResult( + provider, + authenticationResult())) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("email assurance"); + } + + private static ProviderInstanceDefinition providerDefinition() { + return providerDefinition(EmailAssurance.VERIFIED); + } + + private static ProviderInstanceDefinition providerDefinition( + EmailAssurance emailAssuranceLimit) { + return new ProviderInstanceDefinition( + "directory", + "ldap", + "ldaps://directory.example", + "Corporate Directory", + "ldap_entry_uuid", + "ldap_entry_uuid", + Map.of( + "ldap_entry_uuid", + SubjectNormalization.EXACT), + List.of("displayName"), + List.of("mail"), + List.of("jpegPhoto"), + emailAssuranceLimit); + } + + private static ProviderAuthenticationResult authenticationResult() { + return new ProviderAuthenticationResult( + new SubjectCandidate( + "ldap_entry_uuid", + "entry-123"), + List.of(), + Map.of( + "displayName", + List.of(new ProviderAttributeValue( + "Alice", + ProviderAttributeTrust.ASSERTED)), + "mail", + List.of(new ProviderAttributeValue( + "alice@example.com", + ProviderAttributeTrust.VERIFIED))), + new ProtocolAuthenticationEvidence( + "ldap", + Instant.parse("2026-07-30T00:00:00Z"), + Set.of("password"))); + } +}