# 其他系统接入 pms-agent 单点登录说明

## 1. 接入目标

本文档面向需要从其它业务系统跳转进入 `pms-agent` 的系统开发方。

接入完成后，用户在来源系统已登录的前提下，点击来源系统中的入口，即可通过一次性加密 Cookie 登录到 `pms-agent`。

当前 `pms-agent` 只作为单点登录接收方，不复用来源系统的登录 Cookie、Session 或 Redis key。

## 2. 标准跳转流程

1. 用户已登录来源系统。
2. 用户点击来源系统中的“进入智能体系统”入口。
3. 浏览器访问来源系统自己的出站 SSO 接口，例如：

```text
/{source-context}/auth/out-sso/pms-agent
```

4. 来源系统后端读取当前登录用户的 `username`。
5. 来源系统生成短期 AES-GCM 加密 Token。
6. 来源系统把 Token 写入 Cookie `pms_agent_sso_token`。
7. 来源系统重定向浏览器到：

```text
/pms-agent/auth/external-sso
```

8. `pms-agent` 校验 Cookie 中的 Token，匹配本系统用户。
9. 校验成功后，`pms-agent` 写入自己的登录 Cookie `pms_agent_session`。
10. `pms-agent` 清理一次性 Cookie `pms_agent_sso_token`，再跳转到首页或指定地址。

## 3. 来源系统需要实现什么

来源系统只需要实现一个出站 SSO 接口。

建议路径：

```text
/{source-context}/auth/out-sso/pms-agent
```

接口职责：

- 校验当前用户是否已登录。
- 获取当前用户的 `username`。
- 使用和 `pms-agent` 约定的 `issuer`、`kid`、AES 密钥生成 Token。
- 写入 Cookie `pms_agent_sso_token`。
- 重定向到 `/pms-agent/auth/external-sso`。

前端入口不要直接跳 `/pms-agent`，应跳来源系统自己的出站 SSO 接口。

示例：

```html
<a href="/union-js/auth/out-sso/pms-agent">进入智能体系统</a>
```

## 4. Cookie 要求

来源系统写入的 Cookie 名称固定为：

```text
pms_agent_sso_token
```

建议属性：

```text
Path=/
Max-Age=60 到 120
HttpOnly
SameSite=Lax
```

如果部署在 HTTPS 环境，还应增加：

```text
Secure
```

注意：

- `Path=/` 必须设置，否则浏览器访问 `/pms-agent` 时可能不会携带 Cookie。
- 不要设置为 `Path=/{source-context}`。
- Cookie 值必须是加密 Token，不能放明文 JSON。
- `HttpOnly` 不是加密，只是防止前端脚本读取。

## 5. Token 格式

Cookie 值格式固定为：

```text
v1.{issuer}.{kid}.{iv}.{ciphertext}
```

字段说明：

- `v1`：协议版本。
- `issuer`：来源系统编码，例如 `union-js`、`portal`、`oa`。
- `kid`：密钥版本，例如 `2026-04`。
- `iv`：12 字节随机 IV，Base64URL 编码，无 padding。
- `ciphertext`：AES-GCM 加密结果，Base64URL 编码，无 padding。

AES-GCM 必须使用 AAD：

```text
v1.{issuer}.{kid}.pms-agent
```

## 6. Token 加密载荷

AES-GCM 加密前的 JSON 只放最小登录字段：

```json
{
  "iss": "union-js",
  "kid": "2026-04",
  "aud": "pms-agent",
  "username": "zhangsan",
  "iat": 1776900000000,
  "exp": 1776900120000,
  "jti": "c0b3c8e5-3d3d-4e59-bc92-01c8d72c6f7a"
}
```

字段要求：

- `iss` 必须和外层 `issuer` 一致。
- `kid` 必须和外层 `kid` 一致。
- `aud` 固定为 `pms-agent`。
- `username` 是 `pms-agent` 用于匹配本系统用户的登录账号。
- `iat` 是签发时间，毫秒时间戳。
- `exp` 是过期时间，毫秒时间戳。
- `jti` 是一次性随机串，同一个 `jti` 只能被消费一次。

不要放入：

- 姓名。
- 手机号。
- 邮箱。
- 组织信息。
- 来源系统完整用户对象。

## 7. Java 生成 Token 示例

```java
public String createToken(String username) {
    try {
        String issuer = "union-js";
        String kid = "2026-04";
        String audience = "pms-agent";
        String aesKey = System.getenv("PMS_AGENT_SSO_UNION_JS_AES_KEY_2026_04");
        long now = System.currentTimeMillis();

        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("iss", issuer);
        payload.put("kid", kid);
        payload.put("aud", audience);
        payload.put("username", username);
        payload.put("iat", now);
        payload.put("exp", now + 120_000);
        payload.put("jti", UUID.randomUUID().toString());

        byte[] iv = new byte[12];
        SecureRandom secureRandom = new SecureRandom();
        secureRandom.nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        SecretKeySpec keySpec = new SecretKeySpec(Base64.getDecoder().decode(aesKey), "AES");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(128, iv));
        cipher.updateAAD(("v1." + issuer + "." + kid + "." + audience).getBytes(StandardCharsets.UTF_8));

        ObjectMapper objectMapper = new ObjectMapper();
        byte[] encrypted = cipher.doFinal(objectMapper.writeValueAsBytes(payload));

        Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
        return "v1." + issuer + "." + kid + "."
                + encoder.encodeToString(iv) + "."
                + encoder.encodeToString(encrypted);
    } catch (Exception ex) {
        throw new IllegalStateException("生成 pms-agent SSO Token 失败", ex);
    }
}
```

AES 密钥要求：

- Base64 编码。
- 解码后必须是 32 字节随机值。
- 不要使用短口令直接作为密钥。
- 不要写入前端代码。
- 不要打印到日志。

生成 32 字节密钥示例：

```powershell
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
[Convert]::ToBase64String($bytes)
```

## 8. Java 出站接口示例

```java
@RestController
@RequestMapping("/auth")
public class OutSsoController {

    private static final String SSO_COOKIE_NAME = "pms_agent_sso_token";

    private final CurrentUserService currentUserService;
    private final OutSsoTokenService outSsoTokenService;

    public OutSsoController(CurrentUserService currentUserService,
                            OutSsoTokenService outSsoTokenService) {
        this.currentUserService = currentUserService;
        this.outSsoTokenService = outSsoTokenService;
    }

    @GetMapping("/out-sso/pms-agent")
    public void outSsoToPmsAgent(@RequestParam(required = false) String redirect,
                                 HttpServletResponse response) throws IOException {
        String username = currentUserService.getRequiredCurrentUsername();
        String token = outSsoTokenService.createToken(username);

        response.addHeader("Set-Cookie", SSO_COOKIE_NAME + "=" + token
                + "; Path=/"
                + "; Max-Age=120"
                + "; HttpOnly"
                + "; SameSite=Lax");

        String target = "/pms-agent/auth/external-sso";
        if (redirect != null && redirect.startsWith("/pms-agent/")) {
            target = target + "?redirect=" + URLEncoder.encode(redirect, StandardCharsets.UTF_8);
        }
        response.sendRedirect(target);
    }
}
```

如果当前环境是 HTTPS，`Set-Cookie` 还应追加 `; Secure`。

## 9. pms-agent 侧配置

接入前，需要 `pms-agent` 为来源系统配置白名单和密钥。

示例：

```yaml
pms:
  sso:
    trusted-issuers:
      union-js:
        active-kid: 2026-04
        keys:
          2026-04: ${PMS_AGENT_SSO_UNION_JS_AES_KEY_2026_04}
```

来源系统需要和 `pms-agent` 运维或后端负责人确认：

- `issuer`。
- `kid`。
- AES 密钥环境变量。
- Token 有效期，建议 60 到 120 秒。

## 10. pms-agent 校验规则

`pms-agent` 会执行以下校验：

- Cookie 是否存在。
- Token 是否为 `v1.{issuer}.{kid}.{iv}.{ciphertext}` 格式。
- `issuer` 是否在白名单内。
- `kid` 是否在对应 `issuer` 下配置。
- AES-GCM 是否可成功解密。
- AAD 是否匹配 `v1.{issuer}.{kid}.pms-agent`。
- 载荷 `iss` 是否等于外层 `issuer`。
- 载荷 `kid` 是否等于外层 `kid`。
- `aud` 是否等于 `pms-agent`。
- `username` 是否为空。
- `iat` 是否晚于当前时间过多。
- `exp` 是否已过期。
- `exp - iat` 是否超过允许的最大 Token 生命周期。
- `issuer + jti` 是否已被使用。
- `username` 是否能匹配到 `pms-agent` 本系统未禁用用户。

任一校验失败都会拒绝登录。

## 11. redirect 参数要求

来源系统可以把用户最终要去的 `pms-agent` 地址放在 `redirect` 参数中：

```text
/pms-agent/auth/external-sso?redirect=/pms-agent/chat
```

`redirect` 必须是 `/pms-agent` 路径树内的相对路径。

以下值会被 `pms-agent` 拒绝并回退到 `/pms-agent/`：

```text
//evil.example
https://evil.example
/pms-agent/../other
/pms-agent/%2e%2e/other
```

## 12. 联调清单

来源系统侧检查：

- 出站 SSO 接口必须由后端生成 Token。
- 前端入口必须先跳来源系统出站 SSO 接口，不要直接跳 `/pms-agent`。
- Cookie 名称为 `pms_agent_sso_token`。
- Cookie 设置 `Path=/`。
- Cookie 设置 `HttpOnly`。
- Cookie 设置 `SameSite=Lax`。
- HTTPS 环境 Cookie 设置 `Secure`。
- Cookie 值中看不到明文 `username`。
- Token 载荷中 `aud` 为 `pms-agent`。
- Token 有效期不超过 120 秒。
- 每次签发 Token 都生成新的 12 字节 IV。
- 每次签发 Token 都生成新的 `jti`。

`pms-agent` 侧检查：

- 已配置来源系统 `issuer`。
- 已配置对应 `kid` 和 AES 密钥。
- AES 密钥和来源系统一致。
- `username` 能匹配到 `pms-agent` 本系统未禁用用户。
- Redis 可用，用于记录已消费 `jti` 和登录会话。

浏览器检查：

- 来源系统响应包含 `Set-Cookie: pms_agent_sso_token=...`。
- 浏览器访问 `/pms-agent/auth/external-sso` 时携带该 Cookie。
- 登录成功后响应包含 `Set-Cookie: pms_agent_session=...`。
- 登录成功后 `pms_agent_sso_token` 被清理。
- 同一个 SSO Token 第二次访问会失败。

## 13. 常见问题

### 直接访问 `/pms-agent` 为什么不能登录？

直接访问目标系统时，来源系统没有机会生成 `pms_agent_sso_token`，`pms-agent` 无法知道当前用户是谁。必须先访问来源系统出站 SSO 接口。

### 可以把来源系统登录 Cookie 给 pms-agent 用吗？

不可以。`pms-agent` 不复用来源系统登录态，只消费一次性 SSO Token，然后建立自己的登录态。

### 可以把 username 明文放 Cookie 吗？

不可以。Cookie 值必须是 AES-GCM 加密后的 Token，不能是明文 JSON，也不能只是 Base64 编码。

### Token 过期时间建议多长？

建议 60 到 120 秒。该 Token 只用于一次跳转登录，不应该长期有效。

### 密钥如何轮换？

新增一个 `kid` 和 AES 密钥后，先让 `pms-agent` 同时保留新旧 `kid`，再让来源系统切到新 `kid` 签发。确认过渡期结束后，再删除旧 `kid`。
