# Caelus Star ID 接入文档（OAuth 2.0）

第三方网站可以用标准 **OAuth 2.0 授权码模式 + PKCE** 接入 Caelus Studio 账号，让用户用 Star ID 登录你的站点，拿到 Star ID / 昵称 / 邮箱（按授权范围）。

- 服务地址：`https://caelus-terminal.app.workbuddy.host`
- 授权页：`/oauth/authorize`
- 令牌：`/oauth/token`
- 用户资料：`/oauth/userinfo`
- 撤销：`/oauth/revoke`
- 自动发现：`/oauth/metadata`（也挂在 `/.well-known/oauth-authorization-server`）

## 1. 登记应用（拿 client_id）

由 Caelus Studio 管理员在终端控制台 → **接入应用 / OAuth 2.0** 页登记，或用管理接口：

```bash
curl -X POST https://caelus-terminal.app.workbuddy.host/api/admin/clients \
  -H 'Content-Type: application/json' \
  -d '{"key":"<管理密钥>","action":"create","name":"我的论坛","owner":"Caelus Studio",
       "redirectUris":["https://bbs.example.com/oauth/callback"],"public":true}'
```

返回：

```json
{ "ok": true, "client": { "clientId": "cs-6f1a...", "clientSecret": "", "public": true, "redirectUris": ["..."], "scopes": ["star_id","profile","email"] } }
```

两种类型：

| 类型 | 适用场景 | 凭据 |
| --- | --- | --- |
| 公开客户端（`public: true`） | 纯前端页面、SPA、静态站 | 无 secret，**必须走 PKCE** |
| 机密客户端（`public: false`） | 有自己的后端服务器 | 签发了 `client_secret`，放后端用 |

回调地址必须与登记的地址**完全一致**（含协议、路径、大小写），否则授权页直接报错，不会跳转。

## 2. 最快接入：浏览器 SDK

```html
<script src="https://caelus-terminal.app.workbuddy.host/sdk/caelus-auth.js?v=20261004"></script>
<script>
  const auth = CaelusAuth.init({
    clientId: '你的 client_id',
    redirectUri: 'https://bbs.example.com/oauth/callback', // 必须与登记一致
    scope: 'star_id profile'      // 可选：star_id / profile / email
  });

  // 一行搞定：未登录跳授权，回来自动换 token 并返回用户资料
  auth.requireLogin().then(user => {
    if (!user) return;            // 正在跳转中
    console.log(user.star_id, user.nickname);
  });
</script>
```

SDK 方法：

| 方法 | 说明 |
| --- | --- |
| `login()` | 跳转授权页（自动生成 PKCE + state） |
| `handleRedirect()` | 回调页用 `?code=` 换 token，自动清理 URL |
| `token()` / `isLoggedIn()` | 读取本地令牌 |
| `user()` | 取用户资料，token 过期自动用 refresh_token 续期 |
| `refresh()` | 手动续期 |
| `logout()` | 本地清除 + 服务端撤销 |
| `requireLogin()` | 上面的一行式封装 |

令牌存在 `localStorage` 的 `caelus_oauth_token`。

在线演示：https://caelus-terminal.app.workbuddy.host/examples/oauth-demo.html

## 3. 自己写后端（机密客户端）

### 3.1 跳授权

```
GET https://caelus-terminal.app.workbuddy.host/oauth/authorize
  ?client_id=cs-xxxx
  &redirect_uri=https%3A%2F%2Fbbs.example.com%2Foauth%2Fcallback
  &response_type=code
  &scope=star_id+profile+email
  &state=<随机串，防 CSRF>
  &code_challenge=<base64url(sha256(verifier))>
  &code_challenge_method=S256
```

用户登录 + 同意后，浏览器被 302 到 `redirect_uri?code=...&state=...`；拒绝则回 `error=access_denied`。
同一用户、同一应用、同一 scope 已授权过时会**静默放行**（不再弹确认）；加 `prompt=consent` 可强制重新确认。

### 3.2 换令牌

```bash
curl -X POST https://caelus-terminal.app.workbuddy.host/oauth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"authorization_code","code":"<code>","client_id":"cs-xxxx",
       "client_secret":"<secret>","code_verifier":"<verifier>",
       "redirect_uri":"https://bbs.example.com/oauth/callback"}'
```

```json
{
  "access_token": "...", "token_type": "Bearer", "expires_in": 3600,
  "refresh_token": "...", "scope": "email profile star_id", "star_id": "CS-A1B2C3D4"
}
```

- 授权码 **10 分钟、单次使用**；`code_verifier` 校验失败会被拒绝。
- 支持 `application/x-www-form-urlencoded`（RFC 6749 默认）与 JSON 两种请求体。
- ~~机密客户端也可用 `Authorization: Basic base64(client_id:client_secret)`~~ →
  **本部署请一律把 `client_id` / `client_secret` 放在请求体**（原因见第 8 节）。

### 3.3 续期

```bash
curl -X POST .../oauth/token -H 'Content-Type: application/json' \
  -d '{"grant_type":"refresh_token","refresh_token":"...","client_id":"cs-xxxx","client_secret":"..."}'
```

refresh_token 有效期 30 天，**每次续期都会轮换**（旧的立刻失效）。

### 3.4 取资料

**推荐写法（令牌放请求体）**：

```bash
curl -X POST https://caelus-terminal.app.workbuddy.host/oauth/userinfo \
  -H 'Content-Type: application/json' \
  -d '{"access_token":"<access_token>"}'
```

也支持查询参数 `GET /oauth/userinfo?access_token=<token>`、自定义头 `X-Access-Token`，
以及 `Authorization: Bearer <access_token>`（仅在网关不剥头的环境下有效，**不要依赖它**）。

```json
{ "sub": "CS-A1B2C3D4", "star_id": "CS-A1B2C3D4", "nickname": "Orange",
  "created_at": 1790000000000, "email": "you@example.com", "email_verified": true }
```

返回字段取决于 scope：`star_id` / `profile`（昵称、注册时间）/ `email`。
无效或过期返回 `401` 带 `WWW-Authenticate`。

### 3.5 撤销

```bash
curl -X POST .../oauth/revoke -H 'Content-Type: application/json' -d '{"token":"<access 或 refresh>"}'
```

## 4. Scope

| scope | 内容 |
| --- | --- |
| `star_id` | Star ID 唯一编号（默认授予，不可取消） |
| `profile` | 昵称、账号创建时间 |
| `email` | 邮箱地址 |

## 5. 用户侧管理

`POST /api/auth/apps`（带主站会话 `token`）列出该用户授权过的应用；
`POST /api/auth/revoke-app`（带 `clientId`）撤销某个应用的全部令牌，撤销后 access_token 立即失效。

## 6. 安全说明

- 只支持 `response_type=code`，不支持隐式模式（implicit）。
- `redirect_uri` 严格白名单匹配，未登记的回调不会跳转，避免开放重定向。
- 公开客户端强制 PKCE（S256）。
- 授权页会话 Cookie：`HttpOnly + SameSite=Lax + Secure`。
- 每次第三方登录都会给用户发一封安全提醒邮件（含时间、IP、大致位置、系统/浏览器）。

## 7. 错误提示怎么写（面向终端用户）

**不要把 HTTP 状态码直接抛给用户。**「登录失败：HTTP 401」对普通用户没有意义，应翻译成具体动作。

用 SDK 时这一层已经做好了：`caelus-auth.js` 内部会把状态码映射成中文提示，你 catch 到的 `e.message` 直接展示即可。

自己写后端 / 自己 fetch 时，建议按这张表映射：

| 状态码 / 情况 | 给用户的提示 |
| --- | --- |
| `401`（userinfo / 换令牌） | Star ID 登录已失效，请重新登录 |
| `403` / scope 不足 | 没有权限访问这项内容，请重新授权 |
| `429` | 操作太频繁，请稍后再试 |
| `5xx` | Caelus 服务暂时不可用，请稍后再试 |
| 网络不可达 / 超时 | 网络连接失败，请检查网络后重试 |
| 其他 4xx | 登录失败（错误码 xxx），请稍后重试 |

两个容易踩的点：

1. **`401` 不等于「密码错」。** 在 OAuth 流程里它几乎总是「令牌过期/无效」，正确动作是**重新走一遍授权**（或先 `refresh`，失败再重登），不是让用户重填密码。
2. **先续期再报错。** `/oauth/userinfo` 返回 401 时，先用 `refresh_token` 换一次新 token；续期成功就静默继续，别打断用户。只有续期也失败（refresh_token 也过期了，30 天）才提示重新登录。

## 8. ⚠️ 本部署的网关会剥离 Authorization 头

**务必先读这一节，否则会莫名其妙一直 401。**

线上 `caelus-terminal.app.workbuddy.host` 前面有一层网关，**所有请求的 `Authorization` 头都会被丢掉**，服务端收不到它。
后果与表现：

- 只带 `Authorization: Bearer <access_token>` 调 `/oauth/userinfo` → 服务端认为你没带令牌 → 恒返回 `401 invalid_token`；
  示例页 SDK 会「401 → 续期 → 再 401」，页面上就出现裸的 `401`（2026-10-04 同事反馈的正是这个）。
- 用 `Authorization: Basic base64(client_id:client_secret)` 做令牌端点鉴权 → 服务端收不到 `client_id` → `invalid_client`。

**正确做法：令牌与客户端凭据一律放进请求体（或查询参数）。**

| 用途 | 别这样写 | 这样写 |
| --- | --- | --- |
| 取资料 | `-H 'Authorization: Bearer <token>'` | `-d '{"access_token":"<token>"}'`（POST）或 `?access_token=<token>`（GET） |
| 取资料（自定义头） | —— | `-H 'X-Access-Token: <token>'` |
| 换/续令牌 | `-H 'Authorization: Basic ...'` | 请求体带 `client_id`（+ 机密客户端的 `client_secret`） |
| 撤销 | —— | 请求体带 `token` |

服务端读取顺序：**请求体 → 查询参数 → `X-Access-Token` → `Authorization`**（最后一项只为兼容其它部署）。
`/oauth/metadata` 因此不再声明 `client_secret_basic`。

**SDK 用户无需改动**：`caelus-auth.js` 已经把 `access_token` 同时放进请求体，换令牌/撤销本来就走请求体。

⚠️ **服务端修好后仍然 401？先强制刷新页面（Cmd+Shift+R）再试。**
浏览器或中间层可能还缓存着旧版 SDK（旧版只走 Bearer 头）。指纹很好认：**续期接口调用成功、但取资料依旧 401**，
就是旧脚本还在跑。示例页已用 `caelus-auth.js?v=20261004` 这样的版本号参数强制穿透缓存，自己接入时也建议带上版本号。

**自检方法**（不改任何代码就能判断是不是这个坑）：

```bash
# 只靠 Basic 头 → 报 invalid_client 就是被剥了；把 client_id 放请求体 → 报 invalid_grant 说明正常
curl -s -X POST .../oauth/token -H "Authorization: Basic $(printf 'demo:' | base64)" \
  -d '{"grant_type":"authorization_code","code":"x","redirect_uri":"<已登记回调>"}'
```

## 9. 反向：用 OK 账号（okteam.cn）登录 Star ID

以上几节讲的是「别人接入我们的 Star ID」。这一节是反过来：**用户可以用 OK 平台账号注册 / 登录 Star ID**。

### 涉及的端点

| 端点 | 作用 |
| --- | --- |
| `GET /api/auth/ok/start?next=<跳回地址>` | 发起授权，302 跳到 OK 授权页 |
| `GET /api/auth/ok/start?bind=<你的会话 token>&next=…` | 已登录用户在账号中心「绑定 OK 账号」 |
| `GET /api/auth/ok/callback` | OK 回跳端点（必须与 OK 控制台登记的回调地址一字不差） |
| `POST /api/auth/ok/session` `{ code }` | 用一次性码换登录令牌 `{ ok, token, user }` |

### 登录态怎么交给页面

回调 **不把会话令牌放进 URL**（避免留在浏览器历史里），而是签发一个 3 分钟有效的一次性码：

```
OK 授权页 → /api/auth/ok/callback → 302 <next>?ok_code=<一次性码>&ok_new=0|1
                                      ↓ 页面 POST
        POST /api/auth/ok/session { code } → { ok, token, user }
```

失败时回跳到 `<next>?ok_error=<中文原因>`，页面直接展示即可。`next` 走白名单（终端控制台与账号中心），其余一律回落到默认页，避免开放重定向。

### 账号怎么匹配（重要）

回调里按这个顺序判定，顺序不能反：

1. **带 `bind` 的绑定请求** → 绑到当前登录的 Star ID；若该 OK 账号已属另一个 Star ID，**拒绝并提示**（不会静默换号）。
2. 该 OK 账号已绑定过 → 直接登录对应 Star ID。
3. OK 资料里的邮箱与已有 Star ID 一致 → 自动关联绑定。
4. 都不匹配 → 新建 Star ID（`viaOk: true`，无密码；昵称/头像取自 OK，邮箱取不到时用 `ok_<okId>@ok.okteam.cn` 占位）。

`publicUser()` 会多返回 `okBound`（是否已绑定 OK 账号）、`viaOk`（是否由 OK 创建）、`avatar`，账号中心据此显示绑定状态。

### 密钥与配置

Client ID / Secret 在 OK 控制台「OAuth 应用」创建，服务端已内置这套值，可用环境变量覆盖：
`OK_CLIENT_ID`、`OK_CLIENT_SECRET`、`OK_AUTHORIZE`、`OK_TOKEN`、`OK_USERINFO`、`OK_SCOPE`（默认 `user:read`）、`OK_REDIRECT_URI`。

⚠️ 注意方向：**是「OK 是我们的身份源」，不要把它和 `/oauth/*`（我们是别人的身份源）混在一起。**
