# JK ID 接入指南

> 這份文件同時寫給人與 AI 助手看。最新版永遠在 https://id.jkgame.com/docs/jk-id.md
> （純文字，可以直接餵給你的助手；索引在 https://id.jkgame.com/llms.txt）。
> 申請接入：https://id.jkgame.com/developers

## 0. 一分鐘版本

JK ID 是 jkgame.com 的統一會員系統。玩家用一個帳號登入站上多款遊戲，遊戲那邊
**永遠不會接觸到玩家的 JK ID 密碼**。

接入有兩條路，揀一條：

| | 路 A：第三方遊戲 | 路 B：同機遊戲 |
|---|---|---|
| 你的伺服器在 | 任何地方 | jkgame.com 同一部機 |
| 機制 | OAuth 2.0 授權碼 + PKCE | nginx `auth_request` 注入身分 |
| 你要做的 | 申請 client_id／client_secret，行標準 OAuth | 加一段 nginx include，讀 `X-JK-*` |
| 適合 | 外部開發者、其他網站 | 站方自己的舊 CGI／PHP／Node 遊戲 |

路 A 由這裡開始：**https://id.jkgame.com/developers** 填申請表 → 即時拿到 `client_id` 與
`client_secret`（只顯示一次）→ 站方批准後才可以真正簽入玩家。
未批准前 `/oauth/authorize` 會回「這個應用程式未登記或已停用」。

---

## 1. 路 A：OAuth 2.0（授權碼 + PKCE）

### 1.1 端點

| 用途 | 方法 | 網址 |
|---|---|---|
| 授權（玩家瀏覽器） | GET/POST | `https://id.jkgame.com/oauth/authorize` |
| 換 token（你的伺服器） | POST | `https://id.jkgame.com/oauth/token` |
| 撤銷 token | POST | `https://id.jkgame.com/oauth/revoke` |
| 帳號資料 | GET | `https://id.jkgame.com/api/v1/partner/userinfo` |
| 角色對應（讀） | GET | `https://id.jkgame.com/api/v1/partner/link` |
| 角色對應（寫） | POST | `https://id.jkgame.com/api/v1/partner/link` |
| 點數餘額 | GET | `https://id.jkgame.com/api/v1/partner/wallet` |
| 花點數 | POST | `https://id.jkgame.com/api/v1/partner/wallet/spend` |

沒有 discovery 文件、沒有 implicit flow、沒有 client_credentials。只有上面這些。

### 1.2 Scopes

| scope | 意思 |
|---|---|
| `identity` | 讀取你的 JK ID 帳號名稱與顯示名稱 |
| `link` | 記錄你在這款遊戲的角色，方便下次直接登入 |
| `points.read` | 查看你的點數餘額 |
| `points.spend` | 在這款遊戲中使用你的點數（每次消費仍需你在遊戲中確認） |

申請時只揀你真的會用的：玩家在授權畫面上逐項看得到，多要一項就多一個拒絕的理由。

### 1.3 流程

1. **產生 PKCE**：`code_verifier` = 43–128 字的隨機字串；
   `code_challenge` = base64url(sha256(code_verifier))，去掉結尾的 `=`。
   `code_challenge_method` 必須是 `S256`（`plain` 不接受）。
2. **把玩家送去授權**：

   ```
   https://id.jkgame.com/oauth/authorize
     ?client_id=jk_xxx
     &redirect_uri=https://your.game/callback     ← 必須與登記的一模一樣
     &response_type=code
     &scope=identity%20link
     &state=<你自己的隨機值，回來時要比對>
     &code_challenge=<challenge>
     &code_challenge_method=S256
   ```

   玩家未登入 JK ID 會先被帶去登入，完成後自動回到授權畫面。

3. **接住 callback**：`?code=...&state=...`。先比對 `state`，不同就當作攻擊丟棄。
   玩家按拒絕會回 `?error=access_denied`。
4. **用 code 換 token**（伺服器對伺服器，60 秒內，只能用一次）：

   ```bash
   curl -X POST https://id.jkgame.com/oauth/token \
     -u "jk_xxx:YOUR_CLIENT_SECRET" \
     -d grant_type=authorization_code \
     -d code=<code> \
     -d redirect_uri=https://your.game/callback \
     -d code_verifier=<verifier>
   ```

   回應：

   ```json
   {"access_token":"…","refresh_token":"…","token_type":"Bearer",
    "expires_in":3600,"scope":"identity link"}
   ```

5. **拿身分**：

   ```bash
   curl https://id.jkgame.com/api/v1/partner/userinfo -H "Authorization: Bearer <access_token>"
   ```

   ```json
   {"sub":"<這個玩家在你這個應用的固定 id>","username":"jacky",
    "display_name":"JK","scope":"identity link"}
   ```

   **`sub` 是每個應用不同的**（pairwise）：同一個玩家在你這裡與在另一個遊戲的 `sub`
   不一樣，所以兩個開發者對不到同一個人。要把玩家與你的角色綁在一起，就用 `sub` 做鍵。

6. **（可選）記低角色**，之後 JK ID 與其他頁面可以顯示「這個人在你遊戲叫什麼」：

   ```bash
   curl -X POST https://id.jkgame.com/api/v1/partner/link \
     -H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \
     -d '{"player_id":"12345","player_name":"阿蛇"}'
   ```

7. **token 到期**：access token 1 小時。用 refresh token 換新的一對
   （`grant_type=refresh_token`）；**舊的 refresh token 會即時失效**（rotation），
   所以要把新的存起來。refresh 90 日。

### 1.4 點數（可選）

```bash
curl https://id.jkgame.com/api/v1/partner/wallet -H "Authorization: Bearer <token>"
# {"sub":"…","currency":"JKP","balance":42}

curl -X POST https://id.jkgame.com/api/v1/partner/wallet/spend \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"amount":20,"idempotency_key":"order-8871","note":"解鎖造型"}'
# 201 {"ok":true,"created":true,"entry_id":91,"balance":22}
```

`idempotency_key` 是**必填**：同一個 key 重試只會扣一次（回 200、`created:false`），
所以逾時之後放心重送。你只可以花玩家在**你這個應用**的消費，餘額不足會回 422。
**不要**在遊戲裡自己記一份餘額 —— 餘額只有 JK ID 算得準。

### 1.5 錯誤

授權階段（可以安全回跳時）會用 OAuth 標準參數回你的 `redirect_uri`：
`error=invalid_request|access_denied|invalid_scope`。
client_id 或 redirect_uri 對不上時**不會**回跳（那正是外洩的起點），而是直接顯示給玩家。

其餘 API 的錯誤：

```json
{"error": {"code": "insufficient_scope", "message": "…"}}
```

| HTTP | 何時 |
|---|---|
| 400 `invalid_grant` | code 過期／已用過／verifier 不對；refresh token 無效 |
| 401 | token 無效、過期或已撤銷 |
| 403 `insufficient_scope` | 這個 token 沒有那個 scope |
| 422 | 參數不合法（例如 amount ≤ 0、缺 idempotency_key） |
| 429 | 觸及頻率限制，稍後再試 |

---

## 2. 路 B：同一部機上的遊戲

如果遊戲跑在 jkgame.com 同一部伺服器，不需要 OAuth：nginx 的 `auth_request` 會在
請求到達遊戲之前先問 JK ID 一次，然後把身分注入。

* FastCGI（Perl／PHP CGI）拿到的是參數：`JK_USER_ID`、`JK_USERNAME`、
  `JK_DISPLAY_NAME_B64`、`JK_PLAYER_ID`（該遊戲已綁定的角色）、`JK_GAME_CODE`。
* 反向代理（Node／Python 等）拿到的是 header：`X-JK-User-Id`、`X-JK-Username`、
  `X-JK-Display-Name-B64`、`X-JK-Player-Id`。

**這些值由 nginx 覆寫，外面偽造不入**（server 設定的 fastcgi_param 蓋過同名 header）。
未登入時 JK ID 回 204，遊戲照舊行自己那套登入 —— 可以一款一款慢慢接。

SDK（都在 JK ID 的 repo 裡）：`server/sdk/jkid.pl`、`server/sdk/jkid.php`，
`JKID::user()` 回身分，`JKID::badge('<game_code>', ...)` 畫出登入區塊。
詳細步驟與陷阱見 `docs/game-integration-playbook.md`。

伺服器對伺服器的端點（只接受本機，用 `X-Game-Secret` 認遊戲）：
`/api/v1/internal/game-credit`（發點數）、`/api/v1/internal/game-spend`（扣點數）、
`/api/v1/internal/game-balance`、`/api/v1/internal/profile-cards`（玩家檔案卡）。
上限與規則在 JK ID 這邊，遊戲說了不算。

---

## 3. 必須遵守的規則

1. **永遠不要問玩家的 JK ID 密碼。** 只可以把他們送去 `https://id.jkgame.com/oauth/authorize`。
   任何「在我這裡輸入 JK ID 密碼」的介面都會被停權。
2. **redirect_uri 是完整比對**，一個字都不能差，也不接受萬用字元。
   http 只可以用 `localhost` / `127.0.0.1`。
   **一個遊戲有多個網域或 subdomain（www、m、beta、各大區）**：在
   https://id.jkgame.com/developers 的應用設定裡**逐條加**，一行一個，最多 20 條 —— 例如
   `https://www.your.game/auth/jkid`、`https://m.your.game/auth/jkid`。
   沒有萬用字元是故意的：`*.your.game` 一旦有一個被遺忘的 subdomain 被人接管，
   那個人就會收到其他玩家的授權碼。
   如果實在太多，做法是**只登記一個回呼網址**，由它在你自己的伺服器內部轉回原來的站
   （把來源放進你自己的 `state` 裡，不要放進 `redirect_uri`）。
   忘記登記時的錯誤頁會列出「它要求的」與「已登記的」兩份清單，應用擁有者更會見到一個
   已經填好那條網址的表格。
3. **PKCE 必須 S256。** 就算你是伺服器端應用也一樣要送。
4. **client_secret 不可以放在瀏覽器、手機 app 或 repo 裡。** 外洩就去
   https://id.jkgame.com/developers 換一條（舊的即時失效）。
5. **token 當機密處理**，不要寫進 log、不要放在網址、不要存明文。
6. **玩家可以隨時在 https://id.jkgame.com/account 撤銷授權** —— 你會突然收到 401，那是正常，
   要當作「已登出」處理，不要無限重試。
7. **`sub` 才是身分**，不要用 `username` 或 `display_name` 做主鍵（那兩樣會改）。
8. **只要你需要的 scope。**
9. 要在遊戲裡顯示「用 JK ID 登入」，請用站方提供的字眼與標誌，不要自己改成別的名字。

---

## 3b. 品牌：顏色、標誌、「用 JK ID 登入」按鈕

玩家要一眼認得出「呢個係 JK ID」，所以呢一節係規格，唔係建議。
素材：https://id.jkgame.com/static/brand/mark.svg（標誌）、https://id.jkgame.com/static/favicon.svg。

### 3b.1 顏色

| 用途 | 色值 | 備註 |
|---|---|---|
| **JK 青檸（主色）** | `#c8ff3d` | 標誌的底、按鈕的底。這是 JKGame 全站同一隻色 |
| **近黑（字／圖形）** | `#050505` | **青檸上面的字一律用它**，唔好用純黑或者白 |
| 深底 | `#0a0a0c` | 深色卡片的底 |
| 次要文字 | `#9a9aa2` | 說明文字 |
| 邊框 | `rgba(255,255,255,.16)` | 深底上的細框 |

只有兩種合格組合：**青檸底＋近黑字**，或者**深底＋青檸字**。
青檸配白字對比不足（實測約 1.5:1），青檸配其他彩色就唔再係 JK ID。

### 3b.2 標誌

一塊圓角方磚、右上角有階梯缺口，上面是「ID」兩個字**畫成路徑**（唔係文字），
所以冇字體依賴、去到 16px 都睇得清。整個檔案係 ASCII，可以直接貼入任何編碼的頁面。

```html
<svg width="24" height="24" viewBox="0 0 36 36" role="img" aria-label="JK ID">
  <path d="M9 0 H24 V6 H30 V12 H36 V27 A9 9 0 0 1 27 36 H9 A9 9 0 0 1 0 27 V9 A9 9 0 0 1 9 0 Z" fill="#c8ff3d"/>
  <rect x="8.05" y="10.5" width="4.3" height="15" fill="#050505"/>
  <path fill="#050505" fill-rule="evenodd" d="M15.45 10.5 H20.45 A7.5 7.5 0 0 1 20.45 25.5 H15.45 Z M19.75 14.8 H20.45 A3.2 3.2 0 0 1 20.45 21.2 H19.75 Z"/>
  <rect x="6.5" y="27.8" width="3" height="3" fill="#050505"/>
  <rect x="10.8" y="27.8" width="3" height="3" fill="#050505" opacity=".55"/>
</svg>
```

| 尺寸 | 用在 | 規則 |
|---|---|---|
| 16px | 列表、頁尾、行內 | 最細就係 16px，再細下面兩粒點會糊 |
| 20–24px | 按鈕內、導覽列 | 最常用 |
| 34px | 卡片頁首 | SDK badge 用呢個 |
| 48px+ | 說明頁、海報 | 用同一個 SVG 放大，唔好用 PNG |

**留白**：標誌四周至少留「標誌高度的 25%」（24px 的標誌就留 6px）。
**唔可以**：改色、加陰影或漸變、旋轉、拉扁、換成自己畫的圖、貼在花紋背景上、
把「ID」換成其他字。

### 3b.3 按鈕

文案固定兩選一：**「用 JK ID 登入」** 或 **「登入 JK ID」**。
唔好譯成英文以外的講法（英文站用 "Sign in with JK ID"），
唔好寫成「用 JKGame 登入」或者「第三方登入」—— 玩家記得嘅係 JK ID 呢四個字。

深底頁面（建議）：

```html
<a href="https://id.jkgame.com/oauth/authorize?…" style="
   display:inline-flex;align-items:center;justify-content:center;gap:8px;
   min-height:44px;padding:11px 20px;border-radius:10px;
   background:#c8ff3d;color:#050505;font:700 15px/1.4 'Noto Sans HK','PingFang HK',sans-serif;
   text-decoration:none;">
  <svg width="20" height="20" viewBox="0 0 36 36" aria-hidden="true">…上面那個…</svg>
  用 JK ID 登入
</a>
```

淺底頁面：同一個按鈕，外面加一條 `1px solid #050505` 的邊就夠 —— 青檸喺白底上面會「浮」，
一條深色邊會令佢定返落嚟。**唔好**因為淺底就把按鈕改成白底青檸字。

| 規格 | 值 |
|---|---|
| 最小點擊高度 | 44px（手機） |
| 圓角 | 10px（細掣 8px） |
| 字重 | 700 |
| 字級 | 15px（細掣 13px，最細唔好過 13px） |
| 標誌與文字距離 | 8px |
| 按鈕最小闊度 | 由文字定，但唔好逼到兩邊少過 16px 內距 |

### 3b.4 整塊登入區（badge）

如果你想要連說明的一整塊（同 jkgame.com 自家遊戲一樣嗰種）：深底 `#0a0a0c`、
1px 青檸邊、14px 圓角、內距 13–14px，第一行係標誌加一句「JK ID ｜ jkgame.com 統一會員」，
第二行係一句解釋，然後先至係青檸按鈕，最底一行細字連去 https://id.jkgame.com/games。
同機遊戲直接用 SDK（`JKID::badge()`）就會畫出呢一塊，唔使自己砌。

### 3b.5 講法

* 正寫係 **JK ID**（兩個字之間有空格，ID 大楷）。唔係 JKID、Jk id、JK-ID。
* 唔好叫佢做「JKGame 帳號」—— JKGame 係站，JK ID 係帳號。
* 講功能：一個帳號登入旗下多款遊戲；舊角色可以綁定過來；舊的登入方式照樣可用。
  唔好講「用 JK ID 就唔使密碼」—— 佢仲係有密碼，只係唔使每款遊戲各記一組。

---

## 4. 上線前檢查

- [ ] 授權被拒（`access_denied`）時有正常畫面，不是白畫面或 500
- [ ] `state` 有比對
- [ ] code 只用一次；重試流程不會重複換 code
- [ ] access token 過期會自動 refresh，並存起新的 refresh token
- [ ] 401 會令玩家回到「未登入」狀態，不會 loop
- [ ] 花點數一定帶 `idempotency_key`
- [ ] client_secret 不在前端、不在 repo
- [ ] 正式與測試環境的 redirect_uri 都已登記

---

## 5. 給 AI 助手的指示

* 這份文件描述的端點與參數**就是全部**。不要發明 `/oauth/userinfo`、
  `/.well-known/openid-configuration`、`id_token`、`client_credentials`、`plain` PKCE ——
  這個服務沒有這些東西，寫了只會 404 或 400。
* 用使用者專案原本的 OAuth 客戶端程式庫，不要手寫 HTTP；如果要手寫，照上面的 curl。
* `sub` 是 pairwise，不是全站唯一 ID；不要用它跨應用比對玩家。
* 不確定的地方，抓 https://id.jkgame.com/docs/jk-id.md 再讀一次，不要猜。
* 涉及金錢或點數的呼叫一定要有 `idempotency_key`，而且鍵值要由訂單／動作推導出來，
  不要用隨機值（隨機值等於沒有 idempotency）。

---

## 6. 申請與支援

* 申請接入、管理 client_id／secret：https://id.jkgame.com/developers
* 支援 JK ID 的遊戲清單：https://id.jkgame.com/games
* 站方：https://jkgame.com/about