CLI ツールからのログインと提出

oj(online-judge-tools)のようなコマンドラインのツールを作っている方向けの説明です。 利用者にアカウントの APIキーを貼ってもらう代わりに、 ブラウザで許可するだけで、提出だけができるトークンを CLI が受け取れます。 GitHub CLI の gh auth login と同じ流れ(OAuth 2.0 のデバイス認可、RFC 8628)です。

提出専用トークンでできること

  • トークンは 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_token10 分のうちに許可されなかったやめる(やり直すなら 1. から)
invalid_grantdevice_code が違う、または受け取り済みやめる
unsupported_grant_typegrant_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("時間内に許可されませんでした")