Merge pull request #654 from iflytek/feature/identity-provider-registry

feat(auth): add unified provider registry and adapter contracts
This commit is contained in:
XiaoSeS 2026-07-31 02:25:38 +08:00 • committed by GitHub
commit 6e444cb19f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 3316 additions and 837 deletions

View file

@ -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<PlatformPrincipal> authenticate(HttpServletRequest request);
public interface PassiveAuthenticationAdapter {
ProviderInstanceDefinition provider();
Optional<ProviderAuthenticationResult> 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` 保持不变,兼容层只是新增可选入口

View file

@ -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<PlatformPrincipal> 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<T> {
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<ProviderAuthenticationResult> 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。

View file

@ -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<T>` |
| 用户名密码、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<PrivateSsoUser> verifySession(HttpServletRequest request);
Optional<PrivateSsoUser> 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<String, List<ProviderAttributeValue>> 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<PlatformPrincipal> authenticate(HttpServletRequest request) {
return privateSsoClient.verifySession(request)
.map(privateSsoIdentityService::resolveOrCreate);
public Optional<ProviderAuthenticationResult> 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 配置。

View file

@ -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

View file

@ -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<DirectAuthProvider> directAuthProviders;
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
public AuthMethodCatalog(IdentityProviderCatalog identityProviderCatalog,
public AuthMethodCatalog(IdentityProviderRegistry identityProviderRegistry,
DirectAuthProperties directAuthProperties,
AuthSessionBootstrapProperties sessionBootstrapProperties,
List<DirectAuthProvider> directAuthProviders,
List<PassiveSessionAuthenticator> passiveSessionAuthenticators) {
this.identityProviderCatalog = identityProviderCatalog;
AuthSessionBootstrapProperties sessionBootstrapProperties) {
this.identityProviderRegistry = identityProviderRegistry;
this.directAuthProperties = directAuthProperties;
this.sessionBootstrapProperties = sessionBootstrapProperties;
this.directAuthProviders = directAuthProviders;
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
}
public List<AuthProviderResponse> 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) {

View file

@ -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<String, DirectAuthProvider> providersByCode;
private final IdentityProviderRegistry providerRegistry;
private final LocalAuthService localAuthService;
private final ProviderLoginAppService providerLoginAppService;
private final SessionBootstrapService sessionBootstrapService;
public DirectAuthService(DirectAuthProperties properties,
List<DirectAuthProvider> 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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<String, PassiveSessionAuthenticator> authenticatorsByProvider;
private final IdentityProviderRegistry providerRegistry;
private final ProviderLoginAppService providerLoginAppService;
private final PlatformSessionService platformSessionService;
public SessionBootstrapService(AuthSessionBootstrapProperties properties,
List<PassiveSessionAuthenticator> 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<ProviderAuthenticationResult> 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<String, List<String>> headers = new LinkedHashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
Enumeration<String> 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);
}

View file

@ -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

View file

@ -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=没有权限执行该操作

View file

@ -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

View file

@ -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<PlatformPrincipal> authenticate(jakarta.servlet.http.HttpServletRequest request) {
return Optional.of(new PlatformPrincipal(
"sso-user-1",
"Private SSO User",
null,
null,
"private-sso",
Set.of("USER")
));
}
};
}
}
}

View file

@ -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<PlatformPrincipal> 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<PlatformPrincipal> 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");
}
}

View file

@ -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<CredentialAuthenticationRequest> 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")));
}
}

View file

@ -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");
}
}

View file

@ -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<PassiveAuthenticationRequest> 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;
}
}

View file

@ -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<PlatformPrincipal> authenticate(HttpServletRequest request);
@Deprecated(forRemoval = true)
public interface PassiveSessionAuthenticator
extends PassiveAuthenticationAdapter {
}

View file

@ -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;

View file

@ -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 {
}

View file

@ -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

View file

@ -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());
}
}

View file

@ -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;

View file

@ -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<ProviderDescriptor> configuredDescriptors();
String resolveBrowserProviderCode(ClientRegistration registration);
}

View file

@ -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;
};
}
}

View file

@ -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
}

View file

@ -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<IdentityProviderLoginMethod> 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;
}
}
}

View file

@ -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<String> SENSITIVE_ATTRIBUTE_NAMES = Set.of(
"token",
"password",
"passwd",
"pwd",
"cookie",
"ticket",
"authorization",
"credential",
"secret");
private static final Set<String> 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<ProviderAttributeValue> 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);
}
}

View file

@ -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.
*
* <p>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<List<ProviderDescriptor>>
configuredProviders = new AtomicReference<>(List.of());
private final IdentityProviderPolicyProperties policyProperties;
private final List<CredentialAuthenticationAdapter> credentialAdapters;
private final List<PassiveAuthenticationAdapter> passiveAdapters;
private final AtomicReference<RegistrySnapshot> snapshot =
new AtomicReference<>(RegistrySnapshot.empty());
ReconciledIdentityProviderCatalog(
TrustedProviderDescriptorSource descriptorSource,
ConfiguredProviderDescriptorSource descriptorSource,
ProviderAuthorityLockService authorityLockService,
IdentityBindingPreflightService bindingPreflightService) {
IdentityBindingPreflightService bindingPreflightService,
IdentityProviderPolicyProperties policyProperties,
List<CredentialAuthenticationAdapter> credentialAdapters,
List<PassiveAuthenticationAdapter> 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<ProviderDescriptor> 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<ProviderDescriptor> 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<String, ProviderDescriptor> descriptors =
new LinkedHashMap<>();
Set<String> browserProviders = new HashSet<>();
Map<String, CredentialRegistration> credentials =
new LinkedHashMap<>();
Map<String, PassiveRegistration> passives =
new LinkedHashMap<>();
Set<String> 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<String, ProviderDescriptor> descriptors,
Set<String> 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<String, SubjectCanonicalizer> 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<ProviderDescriptor> descriptors) {
try {
@ -93,14 +265,159 @@ class ReconciledIdentityProviderCatalog
@Override
public List<IdentityProviderLoginMethod> 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<IdentityProviderLoginMethod> listReadyLoginMethods() {
RegistrySnapshot current = snapshot.get();
List<IdentityProviderLoginMethod> methods = new ArrayList<>();
List<ProviderDescriptor> 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<ProviderDescriptor> 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<String, ProviderDescriptor> descriptors,
Set<String> browserProviders,
Map<String, CredentialRegistration> credentials,
Map<String, PassiveRegistration> passives
) {
private static RegistrySnapshot empty() {
return new RegistrySnapshot(
Map.of(),
Set.of(),
Map.of(),
Map.of());
}
}
}

View file

@ -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<ProviderDescriptor> enabledDescriptors() {
public List<ProviderDescriptor> configuredDescriptors() {
return descriptors.values().stream()
.sorted(Comparator.comparing(
ProviderDescriptor::providerCode))

View file

@ -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<OidcUserRequest,
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
String registrationId = request.getClientRegistration().getRegistrationId();
log.debug("OIDC login initiated for registration '{}'", registrationId);
ResolvedProviderHandle provider =
oauthLoginFlowService.requireReadyProvider(
request.getClientRegistration());
var loginContext = contextResolver.current();
OidcUser upstreamUser = delegate.loadUser(request);
@ -75,7 +79,7 @@ public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest,
PlatformPrincipal principal;
try {
principal = oauthLoginFlowService.authenticate(
request.getClientRegistration(),
provider,
result,
loginContext);
} catch (OAuth2AuthenticationException e) {

View file

@ -57,7 +57,10 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
log.debug(
"GitLab email claim present: {}, verified: {}",
email != null,
emailVerified);
// If email is not verified or not present, try to fetch from emails API
if (email == null || !emailVerified) {
@ -66,7 +69,7 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
if (primaryEmail != null) {
email = primaryEmail.email();
emailVerified = true;
log.debug("Found verified email from GitLab API: {}", email);
log.debug("Found a verified email from GitLab API");
} else {
log.debug("No verified email found from GitLab emails API");
}
@ -79,8 +82,11 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
}
String subject = String.valueOf(attrs.get("id"));
log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
subject, username, email, emailVerified);
log.debug(
"GitLab OAuth claims extracted: usernamePresent={}, emailPresent={}, emailVerified={}",
username != null,
email != null,
emailVerified);
Map<String, List<ProviderAttributeValue>> 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;
}
}

View file

@ -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");
}
}

View file

@ -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<OAuthAuthenticationExchange> {
String getProvider();
ProviderAuthenticationResult extract(
OAuth2UserRequest request,
OAuth2User oAuth2User);
@Override
default ProviderAuthenticationResult authenticate(
OAuthAuthenticationExchange exchange) {
return extract(exchange.request(), exchange.user());
}
}

View file

@ -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<OAuth2UserRequest, OAuth2User> delegate;
private final Map<String, OAuthClaimsExtractor> extractors;
private final TrustedProviderRouteResolver providerRouteResolver;
private final ExternalIdentityLoginService identityLoginService;
@Autowired
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService) {
this(
extractorList,
providerRouteResolver,
identityLoginService,
new DefaultOAuth2UserService());
}
OAuthLoginFlowService(
List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
OAuth2UserService<OAuth2UserRequest, OAuth2User> 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,

View file

@ -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 <T> protocol-specific verified exchange type
*/
public interface BrowserAuthenticationAdapter<T> {
/**
* @throws ProviderAuthenticationException when the verified exchange
* cannot be accepted or its upstream is unavailable
*/
ProviderAuthenticationResult authenticate(T exchange);
}

View file

@ -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);
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.provider;
/**
* Credentials supplied to an active credential adapter.
*/
public record CredentialAuthenticationRequest(
String username,
String password
) {
}

View file

@ -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<ProviderAuthenticationResult> authenticate(
PassiveAuthenticationRequest request);
}

View file

@ -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.
*
* <p>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.</p>
*/
public record PassiveAuthenticationRequest(
String method,
String requestUri,
String queryString,
String remoteAddress,
Map<String, List<String>> 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<String, List<String>> 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<String> 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<String> 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<String> values = headerValues(name);
return values.isEmpty() ? null : values.getFirst();
}
private static List<String> merge(
List<String> existing,
List<String> additional) {
ArrayList<String> merged = new ArrayList<>(
existing.size() + additional.size());
merged.addAll(existing);
merged.addAll(additional);
return List.copyOf(merged);
}
}

View file

@ -0,0 +1,34 @@
package com.iflytek.skillhub.auth.provider;
import java.util.Objects;
/**
* Authentication-adapter failure carrying only a stable reason code.
*
* <p>Adapters must not put credentials, tokens, tickets, cookies, upstream
* payloads or user identifiers in the exception message.</p>
*/
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;
}
}

View file

@ -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
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.provider;
/**
* Interaction capabilities negotiated independently for one provider instance.
*/
public enum ProviderCapability {
BROWSER,
CREDENTIAL,
PASSIVE
}

View file

@ -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.
*
* <p>The definition is read during registry reconciliation. It must not be
* assembled from an authentication response or other caller-controlled data.</p>
*/
public record ProviderInstanceDefinition(
String providerCode,
String protocol,
String canonicalAuthority,
String displayName,
String primarySubjectType,
String legacyPrimarySubjectType,
Map<String, SubjectNormalization> subjectNormalizations,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> 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<String, SubjectNormalization> subjectNormalizations,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> avatarAttributes,
EmailAssurance emailAssuranceLimit) {
this(
providerCode,
protocol,
canonicalAuthority,
displayName,
primarySubjectType,
legacyPrimarySubjectType,
subjectNormalizations,
displayNameAttributes,
emailAttributes,
avatarAttributes,
emailAssuranceLimit,
true);
}
private static List<String> copyAttributeKeys(
List<String> values,
String field) {
List<String> 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;
}
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.auth.provider;
/**
* Trusted normalization strategy for a provider subject.
*/
public enum SubjectNormalization {
EXACT,
DECIMAL
}

View file

@ -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;

View file

@ -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");
}
}
}

View file

@ -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<ProviderAuthenticationResult> 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);
}
}

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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<OidcUserRequest, OidcUser> 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(

View file

@ -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<OAuth2UserRequest, OAuth2User> 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<OAuth2UserRequest, OAuth2User> 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 =

View file

@ -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<String> values = new ArrayList<>(
List.of("first"));
Map<String, List<String>> 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");
}
}

View file

@ -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.
*
* <p>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.</p>
*/
public final class ProviderConformanceKit {
private static final List<String> 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 <T> ProviderAuthenticationResult verifyBrowser(
ProviderInstanceDefinition provider,
BrowserAuthenticationAdapter<T> 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<ProviderAuthenticationResult> 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);
}
}
}

View file

@ -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<CredentialAuthenticationRequest> 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<String> browser =
exchange -> result;
PassiveAuthenticationAdapter passive =
new PassiveAuthenticationAdapter() {
@Override
public ProviderInstanceDefinition provider() {
return provider;
}
@Override
public Optional<ProviderAuthenticationResult> 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")));
}
}