CLI ツールからのログインと提出
oj(online-judge-tools)のようなコマンドラインのツールを作っている方向けの説明です。
利用者にアカウントの APIキーを貼ってもらう代わりに、
ブラウザで許可するだけで、提出だけができるトークンを CLI が受け取れます。
GitHub CLI の gh auth login と同じ流れ(OAuth 2.0 のデバイス認可、RFC 8628)です。
- 提出専用トークンでできること
- ログインの流れ
- 1. コードをもらう
- 2. 利用者にブラウザで許可してもらう
- 3. トークンを受け取る
- 4. 提出する
- 期限切れと失効
- トークンの保存
- 実装例(Python)
提出専用トークンでできること
- トークンは
ysb_で始まる文字列です。 - 使える API は、提出(
POST /api/v1/problems/{ProblemId}/submit、POST /api/v1/problems/no/{No}/submit)と、誰としてログインしているかの確認(GET /api/v1/authUser)だけです。それ以外の API や画面では、ログインしていないものとして扱われます。 - 有効期間は発行から 24 時間です。延長はできないので、切れたらもう一度ログインの流れを通してください。
- 利用者は /device で、発行済みのトークンの一覧を見て、いつでも失効させられます。
アカウントの APIキーは、漏れるとそのユーザーとして何でもできてしまいます。提出しかしない CLI では、こちらを使ってください。
ログインの流れ
CLI → yukicoder POST /api/v1/device/code
CLI ← yukicoder device_code と user_code(WDJB-MJHT など)
CLI → 利用者 「/device を開いて WDJB-MJHT を入力」と表示し、ブラウザを開く
ブラウザ → yukicoder ログインして、コードを確かめて「許可する」
CLI → yukicoder POST /api/v1/device/token(5 秒おき)
CLI ← yukicoder 許可されるまでは authorization_pending
CLI ← yukicoder 許可された後の 1 回だけ access_token(ysb_...)
CLI → yukicoder POST /api/v1/problems/no/1/submit(Authorization: Bearer ysb_...)
どの API も、送る値は application/x-www-form-urlencoded(提出は multipart/form-data も可)、返す値は JSON です。
ログインの API(/api/v1/device/...)には認証は要りません。
1. コードをもらう
curl -X POST https://yukicoder.me/api/v1/device/code -d client_name=oj
client_name は許可の画面に「○○ が提出できるようにします」と出す、CLI の名前です(任意、100 文字まで)。
利用者が見分けられるよう、ツールの名前を入れてください。
{
"device_code": "ydc_...", // CLI だけが持つ。利用者に見せない
"user_code": "WDJB-MJHT", // 利用者がブラウザで入力する
"verification_uri": "https://yukicoder.me/device",
"verification_uri_complete": "https://yukicoder.me/device?user_code=WDJB-MJHT",
"expires_in": 600, // この秒数のうちに許可してもらう
"interval": 5 // トークンを取りに来る間隔(秒)
}
2. 利用者にブラウザで許可してもらう
verification_uri と user_code を表示し、できればブラウザで verification_uri_complete を開いてください(コードが入った状態で開きます)。
SSH の先などでブラウザを開けない環境もあるので、URL とコードは必ず画面にも出してください。
ブラウザで https://yukicoder.me/device を開き、次のコードを入力してください: WDJB-MJHT
利用者はログインしたうえで、CLI の名前とコードを確かめて「許可する」か「拒否する」を押します。 ログインしていなければ、ログインの後にこのページへ戻ります。
3. トークンを受け取る
interval 秒おきに、次を呼んでください。
curl -X POST https://yukicoder.me/api/v1/device/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=ydc_...
許可されると 200 で次が返ります。トークンを渡すのはこの 1 回だけです。
{"access_token": "ysb_...", "token_type": "Bearer", "expires_in": 86400, "scope": "submit"}
まだのときなどは 400 で {"error": "コード", "error_description": "説明"} が返ります。
| error | 意味 | CLI がすること |
|---|---|---|
authorization_pending | まだ許可されていない | interval 秒待って、また呼ぶ |
slow_down | 呼ぶ間隔が短すぎる | 間隔を 5 秒延ばして続ける |
access_denied | 利用者が拒否した | やめる |
expired_token | 10 分のうちに許可されなかった | やめる(やり直すなら 1. から) |
invalid_grant | device_code が違う、または受け取り済み | やめる |
unsupported_grant_type | grant_type が違う | 実装を直す(省略してもよい) |
4. 提出する
curl -X POST https://yukicoder.me/api/v1/problems/no/1/submit \
-H 'Authorization: Bearer ysb_...' \
-F lang=cpp23 -F file=@main.cpp
lang と、source(本文)か file(ファイル)のどちらかを送ります。
成功すると {"SubmissionId": 123456} が返り、結果は https://yukicoder.me/submissions/123456 で見られます。
使える lang は /api/v1/languages にあります。
ログインできているかは GET /api/v1/authUser(同じヘッダを付ける)で確かめられます。ユーザー名などと、利用者の既定の言語が返ります。
ほかの API(テストケースの取得など)には、このトークンは使えません。 問題文やサンプルを読むだけなら、ログインは要りません。
期限切れと失効
期限が切れたか、利用者が失効させたトークンを付けると、401 と
WWW-Authenticate: Bearer realm="yukicoder", error="invalid_token" が返ります。
このときは保存したトークンを消して、ログインの流れをもう一度通してください。
403 は、トークンは有効でも提出できない場合です(問題が公開前、その言語が使えない、など)。本文に理由が入っています。もう一度ログインしても直りません。
受け取ったときの expires_in から期限を計算して保存しておくと、切れたトークンで提出する前にログインを促せます。
トークンの保存
- 本人だけが読めるファイル(Unix ならパーミッション 600)に保存してください。
- ログやエラーメッセージにトークンを出さないでください。
ysb_で始まる文字列は、秘密情報として扱ってください。 device_codeも、トークンを受け取るまでは秘密です。利用者に見せるのはuser_codeだけです。
実装例(Python)
外部のライブラリを使わず、標準ライブラリだけで書いた例です。
import json, time, urllib.error, urllib.parse, urllib.request, webbrowser
BASE = "https://yukicoder.me"
def post(path, data):
req = urllib.request.Request(BASE + path, data=urllib.parse.urlencode(data).encode())
try:
with urllib.request.urlopen(req) as res:
return res.status, json.load(res)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
def login(client_name="my-cli"):
_, code = post("/api/v1/device/code", {"client_name": client_name})
print(f"ブラウザで {code['verification_uri']} を開き、次のコードを入力してください: {code['user_code']}")
webbrowser.open(code["verification_uri_complete"])
interval = code["interval"]
deadline = time.time() + code["expires_in"]
while time.time() < deadline:
time.sleep(interval)
status, res = post("/api/v1/device/token", {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": code["device_code"],
})
if status == 200:
return res["access_token"], time.time() + res["expires_in"]
if res["error"] == "authorization_pending":
continue
if res["error"] == "slow_down":
interval += 5
continue
raise RuntimeError(res["error_description"])
raise RuntimeError("時間内に許可されませんでした")