OAuth2
第三方 App 可以用 OAuth2 授權碼流程(Authorization Code + PKCE),在使用者同意後取得一個代表該使用者的 API Token。取得的 token 和使用者在主控台自己建立的 token 相同,用量計入該使用者的帳號與配額。
所有 OAuth2 端點都在 https://manager.exptech.com.tw。
- 授權請求(GET):不需要。使用者在官方網站登入並同意。
- 換取 token:
client_id、PKCEcode_verifier;機密 client 另需client_secret。 - 使用者資訊:
Authorization: Bearer <access_token>。
申請 client
Section titled “申請 client”目前沒有自助註冊介面。請透過關於頁的聯絡方式申請,並提供:
- App 名稱與一句說明(會顯示在同意頁,也會成為使用者 token 列表中的備註)
redirect_uri清單:授權後導回的網址,可以是https://網址或 App 的自訂 scheme(例如myapp://oauth/callback)。比對方式是完全相同的字串,不支援萬用字元或前綴比對- 需要的權限
- client 類型:
- 公開 client(手機 App、桌面 App):沒有
client_secret,只靠 PKCE。 - 機密 client(有自己伺服器的網站):換 token 時要帶
client_secret,secret 只能放在伺服器端。
- 公開 client(手機 App、桌面 App):沒有
-
App 產生隨機的
code_verifier,計算code_challenge = base64url(SHA-256(code_verifier))(不含=填充),再產生一個隨機state。兩者都存在使用者的 session 裡。 -
把使用者導向授權請求網址。
-
伺服器檢查參數後,把使用者導向官方網站的登入頁(未登入時)或同意頁。這一步不會發出授權碼。
-
使用者在同意頁按下同意或拒絕。同意頁會送出同意或拒絕的請求,伺服器再把使用者導回你的
redirect_uri:- 同意:
redirect_uri?code=<授權碼>&state=<state> - 拒絕:
redirect_uri?error=access_denied&state=<state>
- 同意:
-
App 確認
state與第 1 步相同,然後從伺服器或原生 App 呼叫換取 token,用code與code_verifier換到access_token。 -
(選用)呼叫取得使用者資訊,取得使用者 id 與電子郵件。
-
用
access_token呼叫 API,帶法與一般 API Token 相同:SSE 串流用Authorization: Bearer或 querytoken,需要 token 的 HTTP API 用X-Api-Tokenheader。見 API Token。
把使用者的瀏覽器導向這個網址(頂層導向或開啟系統瀏覽器,不要用 fetch)。
https://manager.exptech.com.tw/api/v1/oauth2/authorize response_type固定為 code
client_id申請時取得的 client id
redirect_uri必須與註冊的其中一筆完全相同
code_challengebase64url(SHA-256(code_verifier)),不含填充
code_challenge_method固定為 S256,不支援 plain
scope以空白分隔的權限名稱或群組,例如 eq.report trem.rts。省略時 token 沒有任何權限
state防 CSRF 的隨機值,會原樣帶回 redirect_uri。強烈建議使用
curl -i --compressed -G "https://manager.exptech.com.tw/api/v1/oauth2/authorize" \ --data-urlencode "response_type=code" \ --data-urlencode "client_id=<client_id>" \ --data-urlencode "redirect_uri=<redirect_uri>" \ --data-urlencode "code_challenge=<code_challenge>" \ --data-urlencode "code_challenge_method=S256"const params = new URLSearchParams({ response_type: "code", client_id: "<client_id>", redirect_uri: "<redirect_uri>", code_challenge: "<code_challenge>", code_challenge_method: "S256",});const res = await fetch(`https://manager.exptech.com.tw/api/v1/oauth2/authorize?${params}`, { redirect: "manual",});console.log(res.status, res.headers.get("location"));import requests
res = requests.get( "https://manager.exptech.com.tw/api/v1/oauth2/authorize", params={ "response_type": "code", "client_id": "<client_id>", "redirect_uri": "<redirect_uri>", "code_challenge": "<code_challenge>", "code_challenge_method": "S256", }, allow_redirects=False, timeout=10,)print(res.status_code, res.headers.get("Location"))參數正確:導向官方登入頁或同意頁;參數錯誤:導回 redirect_uri 並帶 error
Location: https://app.example.com/oauth/callback?error=invalid_scope&state=af0ifjsldkjclient_id 或 redirect_uri 錯誤,不會導回 App(zstd 壓縮的 JSON)
{ "error": "invalid_redirect_uri"}試試看
client_id 或 redirect_uri 有問題時,伺服器不會導回任何網址(避免成為開放導向),而是直接回 400 JSON,使用者會停在錯誤頁。其他錯誤都會導回 redirect_uri,並在 query 帶上 error 與 state:
error |
條件 |
|---|---|
unsupported_response_type |
response_type 不是 code |
invalid_request |
沒帶 code_challenge,或 code_challenge_method 不是 S256 |
invalid_scope |
scope 含有不存在的權限名稱 |
- 實際發出的權限 = 請求的
scope∩ client 註冊的權限。請求了 client 沒有註冊的權限不會報錯,只是不會發出。請以換 token 回應中的scope為準。 - 群組(
trem.*、eq.*)會展開成各個權限。 - 不存在的名稱會讓整個請求失敗(
invalid_scope),不會略過。
這個請求由官方同意頁在使用者按下按鈕時送出,需要使用者在官方網站的登入狀態。第三方 App 不需要、也無法直接呼叫。 列在這裡是為了說明導回 redirect_uri 的行為。
https://manager.exptech.com.tw/api/v1/oauth2/authorize query 參數與授權請求相同(response_type、client_id、redirect_uri、code_challenge、code_challenge_method、scope、state)
action表單欄位。值為 deny 時拒絕,其他值或省略都視為同意
curl -i -X POST "https://manager.exptech.com.tw/api/v1/oauth2/authorize"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/authorize", { method: "POST", redirect: "manual",});console.log(res.status, res.headers.get("location"));import requests
res = requests.post( "https://manager.exptech.com.tw/api/v1/oauth2/authorize", allow_redirects=False, timeout=10,)print(res.status_code, res.headers.get("Location"))同意:導回 redirect_uri 並帶 code;拒絕:帶 error=access_denied
Location: https://app.example.com/oauth/callback?code=4mXq9vKpL2sTzW8yRb1nCe7uJ0hGdA5fYiOkN3wPtQx&state=af0ifjsldkj- 授權碼 5 分鐘內有效,只能使用一次。
- 使用者在同意頁停留太久、登入已過期時,會被導回登入頁,登入後再回到同意頁。
- 伺服器無法建立授權碼時,導回
redirect_uri?error=server_error。
換取 token
Section titled “換取 token”用授權碼換取 access_token。請求內容是 application/x-www-form-urlencoded 表單,不接受 JSON。
https://manager.exptech.com.tw/api/v1/oauth2/token grant_type固定為 authorization_code
code導回 redirect_uri 時拿到的授權碼
redirect_uri必須與授權請求時的值完全相同
client_id必須與授權請求時的值相同
code_verifier第 1 步產生的 PKCE 原文
client_secret機密 client 必填;公開 client 不需要
curl --compressed "https://manager.exptech.com.tw/api/v1/oauth2/token" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=<code>" \ --data-urlencode "redirect_uri=<redirect_uri>" \ --data-urlencode "client_id=<client_id>" \ --data-urlencode "code_verifier=<code_verifier>"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/token", { method: "POST", body: new URLSearchParams({ grant_type: "authorization_code", code: "<code>", redirect_uri: "<redirect_uri>", client_id: "<client_id>", code_verifier: "<code_verifier>", }),});if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.post( "https://manager.exptech.com.tw/api/v1/oauth2/token", data={ "grant_type": "authorization_code", "code": "<code>", "redirect_uri": "<redirect_uri>", "client_id": "<client_id>", "code_verifier": "<code_verifier>", }, timeout=10,)res.raise_for_status()print(res.json())成功(zstd 壓縮的 JSON)
{ "access_token": "et_3q2-7wAAexampleexampleexampleexampleexample", "token_type": "Bearer", "scope": "trem.rts eq.report"}授權碼無效、已使用、已過期,或 client_id、redirect_uri、PKCE 不符
{ "error": "invalid_grant"}client 不存在,或 client_secret 錯誤
{ "error": "invalid_client"}使用者的 token 已達 25 個
{ "error": "too_many_tokens"}| 欄位 | 類型 | 說明 |
|---|---|---|
access_token | string | API Token(et_ 開頭)。只會在這個回應出現一次,請存在伺服器端 |
token_type | string | 固定為 Bearer |
scope | string | 實際取得的權限,以空白分隔、展開後的名稱。沒有任何權限時省略這個欄位 |
回應沒有 expires_in 與 refresh_token:token 不會過期,直到使用者撤銷。
- 授權碼在伺服器開始比對時就會作廢:得到
invalid_grant、too_many_tokens或server_error之後,同一個授權碼不能再用,需要重新授權。invalid_request、unsupported_grant_type、invalid_client發生在比對之前,授權碼仍可在有效期內重試。 - 完整錯誤清單見錯誤碼。
取得使用者資訊
Section titled “取得使用者資訊”用 access_token 取得授權使用者的 id 與電子郵件。不檢查 token 的權限,只要 token 有效即可。
https://manager.exptech.com.tw/api/v1/oauth2/userinfo AuthorizationBearer <access_token>。Bearer 後面只能有一個空白
curl --compressed "https://manager.exptech.com.tw/api/v1/oauth2/userinfo" \ -H "Authorization: Bearer <access_token>"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/userinfo", { headers: { Authorization: "Bearer <access_token>" },});if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.get( "https://manager.exptech.com.tw/api/v1/oauth2/userinfo", headers={"Authorization": "Bearer <access_token>"}, timeout=10,)res.raise_for_status()print(res.json())成功(zstd 壓縮的 JSON)
{ "sub": "123",}沒有 Bearer token,或 token 無效、已撤銷。沒有內容沒有回應本文
試試看
| 欄位 | 類型 | 說明 |
|---|---|---|
sub | string | 使用者 id(十進位數字字串)。同一個使用者固定不變,可用來識別帳號 |
email | string | 使用者的電子郵件。少數情況下可能是空字串 |
以 Node.js 伺服器串起第 1–6 步的完整流程(以下程式只在伺服器端執行):
import crypto from "node:crypto";
const AUTH_BASE = "https://manager.exptech.com.tw";const CLIENT_ID = "your-client-id";const REDIRECT_URI = "https://app.example.com/oauth/callback";
// 第 1、2 步:產生 PKCE 與 state,回傳要導向的網址export function buildAuthorizeUrl(session) { session.codeVerifier = crypto.randomBytes(32).toString("base64url"); session.state = crypto.randomBytes(16).toString("base64url"); const codeChallenge = crypto .createHash("sha256") .update(session.codeVerifier) .digest("base64url");
const url = new URL("/api/v1/oauth2/authorize", AUTH_BASE); url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: "eq.report trem.rts", state: session.state, code_challenge: codeChallenge, code_challenge_method: "S256", }).toString(); return url.toString();}
// 第 5、6 步:使用者回到 REDIRECT_URI 之後export async function handleCallback(session, query) { if (query.error) throw new Error(`授權失敗:${query.error}`); if (query.state !== session.state) throw new Error("state 不符");
const tokenRes = await fetch(new URL("/api/v1/oauth2/token", AUTH_BASE), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: query.code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: session.codeVerifier, // 機密 client 才需要: // client_secret: process.env.EXPTECH_CLIENT_SECRET, }), }); const token = await tokenRes.json(); if (!tokenRes.ok) throw new Error(`換取 token 失敗:${tokenRes.status} ${token.error}`);
const userRes = await fetch(new URL("/api/v1/oauth2/userinfo", AUTH_BASE), { headers: { Authorization: `Bearer ${token.access_token}` }, }); if (!userRes.ok) throw new Error(`取得使用者資訊失敗:${userRes.status}`); const user = await userRes.json();
return { accessToken: token.access_token, scope: token.scope ?? "", userId: user.sub };}- 回應是 zstd 壓縮。 換取 token、使用者資訊與錯誤 JSON 一律帶
Content-Encoding: zstd,不論請求的Accept-Encoding。較新版本 Node.js 的fetch會自動解壓;curl 要有 zstd 支援(curl --version的 Features 有zstd)才能用--compressed解開,否則改用curl -s ... | zstd -d。其他 HTTP 用戶端請確認支援 zstd,或自行解壓。 - 要在伺服器端呼叫。 這些端點的 CORS 只允許官方網站,第三方網頁裡的
fetch讀不到回應。請從你的伺服器或原生 App 呼叫換取 token 與使用者資訊;純前端網頁需要一個後端來完成這兩步。 - token 不會過期,也沒有 refresh token。 不要依賴
expires_in(回應沒有這個欄位)。token 失效只有一種情況:被撤銷。 - 撤銷由使用者在主控台操作。 授權後的 token 會出現在使用者主控台的 API Token 列表,備註是你的 App 名稱,使用者可以隨時刪除。目前沒有讓 App 自己撤銷 token 的 API;需要時請引導使用者到主控台刪除。token 被撤銷後,HTTP 會得到 401,SSE 串流會收到
token revoked並斷線。 token_type是Bearer,但 HTTP 資料 API 要用X-Api-Tokenheader。 SSE 串流與使用者資訊才用Authorization: Bearer。- 每位使用者最多 25 個 token,OAuth2 發出的也算。達到上限時換取 token 會得到
403 too_many_tokens,此時授權碼已作廢,請引導使用者刪除不用的 token 後重新授權。 - 頻率限制。 OAuth2 端點以 IP 計算,每秒約 4 次,可短暫突發 5 次,超過回 429。
- 用量算在使用者身上。 你的 App 用這個 token 取用資料,會計入使用者的用量與配額;需要訂閱的資源,也要使用者自己訂閱。
