OAuth2
サードパーティのアプリは、OAuth2 の認可コードフロー(Authorization Code + PKCE)で、ユーザーの同意後にそのユーザーを表す API Token を取得できます。取得した token は、ユーザーがダッシュボードで自分で作成した token と同じで、利用量はそのユーザーのアカウントとクォータに計上されます。
OAuth2 のエンドポイントはすべて https://manager.exptech.com.tw にあります。
- 認可リクエスト(GET):不要です。ユーザーは公式サイトでログインし、同意します。
- token の交換:
client_idと PKCE のcode_verifier。機密 client はさらにclient_secretが必要です。 - ユーザー情報:
Authorization: Bearer <access_token>。
client の申請
Section titled “client の申請”現在、自分で登録する画面はありません。概要のページの連絡先から申請し、次を伝えてください。
- アプリ名と 1 文の説明(同意ページに表示され、ユーザーの token 一覧のメモにもなります)
redirect_uriの一覧:認可後に戻る URL。https://の URL でも、アプリ独自の scheme(例:myapp://oauth/callback)でもかまいません。比較は完全に同じ文字列で、ワイルドカードやプレフィックス一致はありません- 必要な権限
- client の種類:
- 公開 client(モバイルアプリ、デスクトップアプリ):
client_secretはなく、PKCE だけに依存します。 - 機密 client(自前のサーバーがあるウェブサイト):token の交換時に
client_secretを付けます。secret はサーバー側にだけ置いてください。
- 公開 client(モバイルアプリ、デスクトップアプリ):
-
アプリはランダムな
code_verifierを作り、code_challenge = base64url(SHA-256(code_verifier))(=のパディングなし)を計算し、さらにランダムなstateを作ります。どちらもユーザーの session に保存します。 -
ユーザーを認可リクエストの URL へ誘導します。
-
サーバーはパラメータを確認したあと、ユーザーを公式サイトのログインページ(未ログインのとき)または同意ページへ誘導します。この段階では認可コードは発行されません。
-
ユーザーは同意ページで同意または拒否を押します。同意ページが同意または拒否のリクエストを送り、サーバーはユーザーをあなたの
redirect_uriへ戻します。- 同意:
redirect_uri?code=<認可コード>&state=<state> - 拒否:
redirect_uri?error=access_denied&state=<state>
- 同意:
-
アプリは
stateが手順 1 と同じことを確認してから、サーバーまたはネイティブアプリからtoken の交換を呼び、codeとcode_verifierでaccess_tokenを取得します。 -
(任意)ユーザー情報の取得を呼び、ユーザー id とメールアドレスを取得します。
-
access_tokenで API を呼びます。付け方は通常の API Token と同じです。SSE 配信はAuthorization: Bearerまたは querytoken、token が必要な HTTP API はX-Api-Tokenheader です。API Tokenを参照してください。
認可リクエスト
Section titled “認可リクエスト”ユーザーのブラウザをこの URL へ誘導します(トップレベルの遷移、またはシステムのブラウザを開きます。fetch は使わないでください)。
https://manager.exptech.com.tw/api/v1/oauth2/authorize response_type固定で code
client_id申請時に受け取った client id
redirect_uri登録したうちの 1 件と完全に同じ文字列
code_challengebase64url(SHA-256(code_verifier))。パディングなし
code_challenge_method固定で S256。plain は非対応
scope空白区切りの権限名またはグループ。例:eq.report trem.rts。省略すると token に権限は付きません
stateCSRF 対策のランダム値。redirect_uri にそのまま戻ります。使用を強く推奨します
curl -i --compressed -G "https://manager.exptech.com.tw/api/v1/oauth2/authorize" \ --data-urlencode "response_type=<response_type>" \ --data-urlencode "client_id=<client_id>" \ --data-urlencode "redirect_uri=<redirect_uri>" \ --data-urlencode "code_challenge=<code_challenge>" \ --data-urlencode "code_challenge_method=<code_challenge_method>"const params = new URLSearchParams({ response_type: "<response_type>", client_id: "<client_id>", redirect_uri: "<redirect_uri>", code_challenge: "<code_challenge>", code_challenge_method: "<code_challenge_method>",});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": "<response_type>", "client_id": "<client_id>", "redirect_uri": "<redirect_uri>", "code_challenge": "<code_challenge>", "code_challenge_method": "<code_challenge_method>", }, allow_redirects=False, timeout=10,)print(res.status_code, res.headers.get("Location"))パラメータが正しい場合は公式のログインページまたは同意ページへ誘導します。誤りがある場合は error を付けて redirect_uri へ戻します
Location: https://app.example.com/oauth/callback?error=invalid_scope&state=af0ifjsldkjclient_id または redirect_uri が不正です。アプリへは戻りません(zstd 圧縮の JSON)
{ "error": "invalid_redirect_uri"}試してみる
client_id または redirect_uri に問題があるとき、サーバーはどの URL へも戻しません(オープンリダイレクトになるのを避けるため)。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)。無視はしません。
同意または拒否
Section titled “同意または拒否”このリクエストは、ユーザーがボタンを押したときに公式の同意ページが送ります。ユーザーが公式サイトにログインしている必要があります。サードパーティのアプリが呼ぶ必要はなく、直接呼ぶこともできません。 ここに載せているのは、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"))同意:code を付けて redirect_uri へ戻します。拒否:error=access_denied を付けます
Location: https://app.example.com/oauth/callback?code=4mXq9vKpL2sTzW8yRb1nCe7uJ0hGdA5fYiOkN3wPtQx&state=af0ifjsldkj- 認可コードは 5 分間有効で、使えるのは 1 回だけです。
- ユーザーが同意ページに長く留まり、ログインの期限が切れていると、ログインページへ戻され、ログイン後に再び同意ページへ戻ります。
- サーバーが認可コードを作成できないときは、
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
coderedirect_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=<grant_type>" \ --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: "<grant_type>", 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": "<grant_type>", "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"}レスポンスのフィールド
Section titled “レスポンスのフィールド”| フィールド | 型 | 説明 |
|---|---|---|
access_token | string | API Token(et_ で始まる)。このレスポンスに一度だけ出るので、サーバー側に保存してください |
token_type | string | 固定で Bearer |
scope | string | 実際に取得した権限。空白区切りで、展開後の名前。権限が 1 つもないときはこのフィールドを省略します |
レスポンスに 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 のうしろの空白は 1 つだけです
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 が無効・取り消し済み。本文はありませんレスポンスボディなし
試してみる
レスポンスのフィールド
Section titled “レスポンスのフィールド”| フィールド | 型 | 説明 |
|---|---|---|
sub | string | ユーザー id(10 進数の文字列)。同じユーザーでは変わりません。アカウントの識別に使えます |
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 を作り、誘導先の URL を返します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 は、リクエストの
Accept-Encodingに関係なく、常にContent-Encoding: zstdが付きます。比較的新しい Node.js のfetchは自動で展開します。curl は zstd に対応しているとき(curl --versionの Features にzstdがあるとき)だけ--compressedで展開できます。そうでなければcurl -s ... | zstd -dを使ってください。他の HTTP クライアントは zstd に対応しているか確認するか、自分で展開してください。 - サーバー側から呼んでください。 これらのエンドポイントの CORS は公式サイトだけを許可し、サードパーティのウェブページ内の
fetchはレスポンスを読めません。token の交換とユーザー情報は、自分のサーバーまたはネイティブアプリから呼んでください。フロントエンドだけのウェブページは、この 2 段階を完了するバックエンドが必要です。 - token は期限切れにならず、refresh token もありません。
expires_inに依存しないでください(レスポンスにこのフィールドはありません)。token が無効になるのは、取り消されたときだけです。 - 取り消しはユーザーがダッシュボードで行います。 認可後の token は、ユーザーのダッシュボードの API Token 一覧に出ます。メモはあなたのアプリ名で、ユーザーはいつでも削除できます。現在、アプリ自身が token を取り消す API はありません。必要なときは、ユーザーをダッシュボードの削除へ案内してください。token が取り消されると、HTTP は 401 になり、SSE 配信は
token revokedを受け取って切断されます。 token_typeはBearerですが、HTTP データ API はX-Api-Tokenheader を使います。Authorization: Bearerを使うのは SSE 配信とユーザー情報だけです。- ユーザー 1 人につき token は最大 25 個で、OAuth2 が発行したものも数に入ります。上限に達すると token の交換は
403 too_many_tokensになり、その時点で認可コードは無効です。ユーザーに不要な token を削除してもらってから、認可をやり直してください。 - 頻度の制限。 OAuth2 エンドポイントは IP 単位で、毎秒約 4 回、短時間なら 5 回までです。超えると 429 を返します。
- 利用量はユーザーに計上されます。 あなたのアプリがこの token でデータを取得すると、ユーザーの利用量とクォータに計上されます。購読が必要なリソースは、ユーザー自身が購読する必要があります。
