老喬會員中心:合作品牌接入文件
版本: v1.0
最後更新: 2026-09-28
這份文件寫給合作品牌的工程師:讓你的網站或服務用「老喬會員帳號」登入。老喬會員中心(https://id.joelin.cc)是標準的 OpenID Connect(OIDC)身分提供者,會做過「用 Google 登入」的工程師都接得起來。
一句話分工:驗證身分由老喬會員中心負責,你的系統只收到「這個人是誰」的一組號碼。 你不需要處理密碼、驗證碼或第三方登入。
1. 申請接入
請把下列資料交給老喬(你的合作窗口):
| 項目 | 說明 |
|---|---|
| 回呼網址(redirect_uri) | 登入完成後帶使用者回來的網址,必須 https://,可多個。例:https://brand.example.com/auth/callback |
| 要的資料範圍(scope) | 預設只有 openid(一組專屬會員號碼)。要 Email 加 email,要顯示名稱與大頭貼加 profile。要得越多,同意畫面列得越多 |
| 通知網址(backchannel_uri) | 選填但強烈建議:會員登出、取消授權、刪除帳號時我們通知你的網址(第 7 節) |
| 品牌名稱與 Logo | 顯示在登入頁與同意畫面。Logo 請給 PNG 或 SVG,我們會放在自己的圖床,不會連到你的伺服器 |
我們會給你:
client_id:公開的,像帳號。client_secret:私密的,像密碼。只顯示一次,我們用安全管道交給你;只能放在你的伺服器端,絕不可放進瀏覽器、手機 App 或公開程式碼。
2. 端點
所有端點都能從 Discovery 自動取得:
GET https://id.joelin.cc/.well-known/openid-configuration
| 端點 | 用途 |
|---|---|
GET /oauth/authorize |
把使用者導來登入 |
POST /oauth/token |
用 code 換 token;也用來更新 token |
GET /oauth/userinfo |
用 access_token 查使用者資料 |
GET /jwks.json |
公鑰,用來驗證我們簽的 token(ES256) |
GET /oauth/logout |
從你的服務發起登出 |
3. 流程總覽
你的網站 ──①導向──▶ id.joelin.cc/oauth/authorize
使用者 ──登入(Google/Apple/Email 等)──▶ 第一次會看到同意畫面
id.joelin.cc ──②導回──▶ 你的 redirect_uri?code=…&state=…
你的伺服器 ──③POST /oauth/token(code+code_verifier+client_secret)──▶ id.joelin.cc
id.joelin.cc ──④回──▶ { id_token, access_token, refresh_token }
你的伺服器 ──⑤用 /jwks.json 驗 id_token ──▶ 取得會員號碼 sub
採用 Authorization Code + PKCE(S256),並用 client_secret 認證。沒有 PKCE 的請求會被拒絕。
4. 把使用者導來登入
| 參數 | 必填 | 說明 |
|---|---|---|
response_type |
是 | 固定 code |
client_id |
是 | 我們給你的 |
redirect_uri |
是 | 必須與登記的完全相同(含路徑與結尾斜線) |
scope |
是 | 例:openid、openid email;不能超過登記範圍 |
state |
是 | 你產生的隨機字串,回來時比對,防 CSRF |
code_challenge |
是 | PKCE:base64url(sha256(code_verifier)) |
code_challenge_method |
是 | 固定 S256 |
nonce |
建議 | 會寫進 id_token,回來時比對,防重放 |
ui_locales |
選填 | 登入頁語言:zh-Hant、en、ja |
brand |
選填 | 品牌代號,由我們設定。同一個站要替不同品牌顯示不同 Logo 時才用;只能帶代號,不能帶圖址或名稱 |
prompt |
選填 | none=使用者還沒同意時不顯示同意畫面,直接回 error=consent_required |
import { randomBytes, createHash } from "node:crypto";
const b64url = (b: Buffer) => b.toString("base64url");
// 每次登入產生一組;code_verifier、state、nonce 存在你伺服器的 session(HttpOnly cookie)
const codeVerifier = b64url(randomBytes(32));
const codeChallenge = b64url(createHash("sha256").update(codeVerifier).digest());
const state = b64url(randomBytes(16));
const nonce = b64url(randomBytes(16));
const url = new URL("https://id.joelin.cc/oauth/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: "https://brand.example.com/auth/callback",
scope: "openid",
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
// 把使用者瀏覽器導到 url
同意畫面
使用者第一次從你的服務登入時,會看到「(你的品牌)想用你的老喬會員帳號登入」,列出你會拿到的資料。
- 按「同意並繼續」:照常導回你的
redirect_uri?code=…。之後再登入不會重問,除非你要的 scope 變多。 - 按「不同意」:導回
redirect_uri?error=access_denied&state=…。請顯示友善訊息,讓使用者能再試一次。
5. 用 code 換 token(在你的伺服器)
// 收到 redirect_uri?code=…&state=…
if (query.state !== session.state) throw new Error("state 不符");
const res = await fetch("https://id.joelin.cc/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: query.code,
redirect_uri: "https://brand.example.com/auth/callback",
code_verifier: session.codeVerifier,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const tok = await res.json();
// { access_token, id_token, refresh_token, token_type: "Bearer", expires_in: 900, scope }
- code 一分鐘內有效,只能用一次。
- 也可以用 HTTP Basic 帶
client_id:client_secret。
6. 驗證 id_token,取得會員號碼
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://id.joelin.cc/jwks.json"));
const { payload } = await jwtVerify(tok.id_token, JWKS, {
issuer: "https://id.joelin.cc",
audience: CLIENT_ID,
});
if (payload.nonce !== session.nonce) throw new Error("nonce 不符");
const memberId = payload.sub; // 你的品牌專屬會員號碼,形如 p_xxxxxxxx…
**關於 sub(會員號碼),請一定讀:**
- 它是你的品牌專屬的號碼(OIDC pairwise)。同一個人在你這裡永遠是同一個號碼;在其他品牌是另一個號碼,品牌之間無法比對。
- 請用它當你系統裡的會員主鍵。不要用 Email 當主鍵:你可能沒有要 Email,而且 Email 會變。
- 你旗下多個網站或 App(同一個品牌)拿到的是同一個號碼。
- id_token 只含你要的資料:只要
openid就只有sub;加email才有email、email_verified;加profile才有name、picture(使用者有設定時)。
查 userinfo(選用)
const u = await (await fetch("https://id.joelin.cc/oauth/userinfo", {
headers: { Authorization: `Bearer ${tok.access_token}` },
})).json();
// { sub, email?, email_verified?, name?, picture? }(依 scope)
更新 token
access_token 15 分鐘到期。需要時用 refresh_token 換新:
const r = await fetch("https://id.joelin.cc/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: stored.refresh_token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const next = await r.json(); // 新的 access_token 與新的 refresh_token
⚠️ 每次都要存回新的 refresh_token。重用舊的會被當成盜用,整個登入會被撤銷,使用者要重新登入。refresh_token 30 天沒用過會失效,最長 90 天。
7. 登出、取消授權與刪除帳號通知
會員在老喬會員中心做下列動作時,我們會送一個 POST 到你登記的通知網址:
| 情況 | 事件 |
|---|---|
| 登出所有服務、或在帳戶中心取消你的授權 | http://schemas.openid.net/event/backchannel-logout |
| 刪除老喬會員帳號 | https://id.joelin.cc/events/account-deleted |
請求格式(OIDC Back-Channel Logout 1.0):
POST {你的通知網址}
Content-Type: application/x-www-form-urlencoded
logout_token=<JWT>
logout_token 是我們用同一把金鑰簽的 JWT,內容:
{
"iss": "https://id.joelin.cc",
"aud": "<你的 client_id>",
"sub": "<你的品牌專屬會員號碼>",
"iat": 1790000000,
"exp": 1790000300,
"jti": "<每則通知唯一的識別碼>",
"events": { "https://id.joelin.cc/events/account-deleted": {} }
}
你要做的事:
- 用
/jwks.json驗簽章,檢查iss、aud、exp;演算法只接受 ES256。 - 用
jti去重:同一則通知可能送不只一次。 - 登出事件:結束這個
sub在你那邊的登入狀態。 - 刪除事件:依合作合約,在約定天數內刪除或匿名化這個
sub的個人資料。 - 處理完才回 2xx;暫時處理不了回 5xx,我們會重送。3xx 一律視為失敗,不會跟隨轉址。
重送規則:當下送一次,失敗後約 3 秒、12 秒各再送一次;三次都失敗時,我們會收到告警並人工補送。會員的刪除不會等你的回應:帳號會立刻刪除,之後這個號碼就對不回任何人。
8. 從你的服務發起登出(選用)
GET https://id.joelin.cc/oauth/logout?id_token_hint=<id_token>&client_id=<client_id>&post_logout_redirect_uri=<網址>
帶 id_token_hint 時會直接登出,不顯示確認頁。post_logout_redirect_uri 必須與登記的 redirect_uri 其中一個完全相同,否則會導回老喬會員中心首頁。
9. 錯誤碼
| 出現在哪 | error | 意思與處理 |
|---|---|---|
| 導回 redirect_uri | access_denied |
使用者在同意畫面按了「不同意」 |
| 導回 redirect_uri | consent_required |
你帶了 prompt=none,但使用者還沒同意 |
| 導回 redirect_uri | invalid_scope |
scope 超出登記範圍 |
| 導回 redirect_uri | invalid_request |
缺 PKCE 或參數格式錯誤 |
| 導回 redirect_uri | server_error |
我們這邊暫時出錯,請讓使用者重試 |
| /oauth/token | invalid_client(401) |
client_id 或 client_secret 錯誤 |
| /oauth/token | invalid_grant(400) |
code 無效、過期、已用過、PKCE 不符,或 refresh_token 已撤銷 |
| /oauth/token | rate_limited(429) |
請求太頻繁,60 秒後再試 |
client_id 或 redirect_uri 本身不對時,我們不會導回你的網站(防止被利用成轉址工具),使用者會停在老喬會員中心的錯誤頁。
10. 安全須知與限制
client_secret只能在伺服器端使用。懷疑外洩請在 24 小時內通知我們,我們會立刻換一組。- 一定要驗
state、nonce,一定要用 JWKS 驗 token 簽章,不要只解開 token 讀內容。 - 不可與其他品牌或第三方交換、比對會員號碼。
- 交易回報與交易通知信(
/transactions)只給老喬自家站使用,合作品牌無法呼叫。 - 使用者可以隨時在帳戶中心取消你的授權;取消後你會收到登出通知,下次登入時會重新詢問同意。
11. 測試與上線檢查
- 用測試帳號完整走一次:導向登入 → 同意 → 導回 → 換 token → 驗 id_token。
- 按「不同意」走一次,確認你的錯誤頁正常。
- 請我們從帳戶中心取消你的授權,確認你的通知網址收到登出事件、回 2xx。
- 確認你的隱私權政策有寫「可使用老喬會員帳號登入,將取得一組會員號碼(及你要的其他資料)」。
有問題請聯絡你的合作窗口,或寫信到 joepspsps2@gmail.com。