# 开放平台接入与协议细则

接入前由平台管理员登记应用类型、精确回调和最小 scope。先完成 OIDC 用户授权，再按实际需要启用签名 API 或通知。三种凭据的区别与数据边界见[技术概览](https://document.thestarline.cn/open-platform/overview.md)；本文依据 `identity/server/protocols.ts`、`oidc.ts`、`open-api.ts` 说明当前契约。

## OIDC 授权次序

1. 读取发行者 `https://account.thestarline.cn/oidc` 的发现文档，保存并验证 issuer，读取协议端点与 JWKS。
2. 为每次登录生成新的 `state`、`nonce` 和 PKCE verifier，保存到发起浏览器或服务端受保护事务。使用 S256 challenge，授权请求带 `response_type=code`、client_id、已登记 redirect_uri 与 scope。
3. 用户在 Account 完成认证和授权后，接入方回调先匹配本地 state，再用 code、原 redirect_uri 和 verifier 换票。机密客户端按发现配置使用 `client_secret_basic`；公开客户端不发送伪造密钥。
4. 验证 ID Token 的 JWKS 签名、issuer、audience、nonce、过期时间，以 `sub` 建立本地账户关联。消费并清除本次事务，拒绝回调重放。

授权码只有 60 秒，交互为 300 秒。丢失 verifier、回调不一致或代码已使用时应重新开始登录，不能尝试另一回调或省略 PKCE。具体协议错误由 OIDC 实现返回，不套用普通 `/api` 的所有 JSON 错误形状。当前没有文档依据可承诺刷新令牌或无限离线授权。

## 管理接口与权限

下列接口均为受保护的管理操作，需平台管理员身份、允许的管理认证方式以及浏览器 CSRF；不能把示例做成接入方公开接口。

| 方法与路径 | 主要字段 | 返回或行为 |
| --- | --- | --- |
| `POST /api/admin/applications` | `name,type,redirectUris,scopes` | `id,clientId`，机密类型另含一次性返回的 `clientSecret` |
| `POST /api/admin/applications/:id/status` | `enabled` 布尔值 | 更新状态，停用撤销授权门禁 |
| `POST /api/admin/applications/:id/api-credentials` | `apiScopes` | 启用并返回 `apiSecret,apiScopes` |
| `POST /api/admin/applications/:id/api-credentials/rotate` | JSON 对象 | 返回新 `apiSecret,previousExpiresAt` |
| `POST /api/admin/applications/:id/api-credentials/disable` | JSON 对象 | 清除 API 密钥并禁用 |
| `POST /api/admin/applications/:id/webhook` | `enabled`，启用时 `webhookUrl` | 配置通知，启用生成密钥 |
| `POST /api/admin/applications/:id/webhook/rotate` | JSON 对象 | 返回新 `webhookSecret`，立即生效 |
| `POST /api/admin/applications/:id/webhook/test` | JSON 对象 | 返回 `delivered`，不等于后续事件保证送达 |

API 与通知只对已启用的机密客户端开放。API scope 必须含 `users.read`，可加 `users.email`、`users.phone`；这些名称与 OIDC 的 `email`、`phone` 是两层权限。用户也可在 Account 撤销应用授权，管理员不应通过重新开 API 开关绕过用户的撤销。

应用内部 id 用于管理路由，clientId 用于协议和签名请求头，二者不能互换。列表只显示配置摘要，不提供取回原始密钥的通用接口。接入方遗失密钥时应按受控轮换流程更新，并核对所有调用实例是否已切换；不要从浏览器缓存、客户端安装包或普通日志寻找和分发旧密钥。创建、轮换响应只在受保护的服务端配置链路接收，避免将其写入业务审计正文。

## HMAC 请求签名

请求头为 `x-starline-key`（clientId）、`x-starline-timestamp`（十进制 Unix 秒）、`x-starline-nonce`（16–128 个字符）和 `x-starline-signature`（64 位十六进制 HMAC-SHA256）。canonical 字符串按以下五行拼接，行间只有一个换行，正文后不额外补换行：

```text
METHOD
原始路径与查询串
timestamp
nonce
原始正文
```

METHOD 使用大写；GET 正文为空。路径不含 scheme 或 host，但保留查询参数原顺序、百分号编码和原始 URL 表达。JSON 签名前后必须发送完全相同的 UTF-8 字节，空格或换行变化也会改变签名。以下为可移植的合成示例，不包含真实应用凭据：

```js
import { createHmac, randomBytes } from 'node:crypto';
const path = '/api/open/v1/ping';
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(24).toString('base64url');
const body = '{}';
const canonical = ['POST', path, timestamp, nonce, body].join('\n');
const signature = createHmac('sha256', process.env.OPEN_API_SECRET)
  .update(canonical, 'utf8').digest('hex');
const response = await fetch(`https://account.thestarline.cn${path}`, {
  method: 'POST', body,
  headers: {
    'content-type': 'application/json',
    'x-starline-key': process.env.OPEN_CLIENT_ID,
    'x-starline-timestamp': timestamp,
    'x-starline-nonce': nonce,
    'x-starline-signature': signature
  }
});
```

ping 成功返回 `{status:"ok",appId:clientId}`。读取用户改用 `GET /api/open/v1/users/:id`，签名正文为空；id 是已完成该应用授权的 Account 用户主键。返回 `{user:{id,banned,email?,phone?}}`。不要用聊天用户 UUID 替换 Account ID，也不要把字段缺省当成空字符串覆盖已保存资料。

服务端允许时间偏差正负 300 秒；时间戳必须为安全整数。nonce 在每个应用范围内持久化去重，其保存期限与签名窗口相关。同一请求因网络超时需再次发送时，应生成新 nonce、时间戳和签名；这样只更新防重放证明，不意味着可以重复执行未来的非幂等业务接口。当前公开业务路由只含 ping 与读取用户。

## Webhook 验证的特殊约定

通知正文为 `{event,id,createdAt,appId,data}`，createdAt 为毫秒，签名头 timestamp 为秒。接收端必须保存原始正文后再验签，不能先 JSON parse、重排键后重新序列化。

当前 `deliverSignedWebhook` 调用签名函数时，方法固定 `POST`、canonical 路径固定为字面值 `/`。即使配置的接收 URL 是 `https://receiver.example.invalid/events/account`，验签第二行仍应使用 `/`，不是实际请求路径。这是当前源码的协议行为，升级时需与平台同步确认，不能套用普通开放 API 的路径规则。

验签后检查 appId、事件类型与业务目标，按事件 id 去重，再快速返回 2xx。接收端的时间窗、nonce 保存与幂等策略由自己实现，平台不会替接收端建立防重放存储。API 密钥旧值有 5 分钟轮换宽限；Webhook 没有该宽限，双方需要协调切换，或接收端在约定窗口内自行保留旧验签值。

## 失败定位与验证范围

| 结果 | 当前常见原因 | 首要检查 |
| --- | --- | --- |
| 401 `UNAUTHORIZED` | 头缺失、签名错误、时间过偏、nonce 重复、应用或 API 未启用 | 原始字节、时钟、密钥种类与开关 |
| 403 `FORBIDDEN` | 缺少 `users.read` 等 scope | API scope 与用户 OIDC 同意分别核对 |
| 404 `NOT_FOUND` | 用户不存在或授权门禁、关联会话已无效 | 用户主键、会话和用户撤销状态 |
| 400 `INVALID_INPUT` | 非法回调、通知 URL、应用参数，或开放 API 正文超过 16,384 字节 | 精确 URL、类型、数组范围与正文大小 |
| 限额拒绝 | 当前来源、目标或应用限额桶命中 | 降低频率、有界退避，不并发轰炸 |
| `delivered:false` | 网络、非 2xx、目标被阻止或超时 | 接收日志与审计结果，不能假定已自动重投 |

联调至少包含 PKCE 缺失、state/nonce 不匹配、授权码重放、同意范围缩减、会话撤销、签名正文变化、重复 nonce、时钟边界、API 轮换窗口、Webhook 特殊路径、停用与恢复。`identity/tests/open-api.test.ts` 等测试能支持契约复核；真实接入还需接入方回调、通知与密钥轮换的独立验收记录。
