本記事ではRFC8628 OAuth 2.0 Device Authorization Grantを読みながらGithubのaccess tokenを取得するCLIをrustで実装していきます。
作成したCLIはこちら
概要
CLIからOAuthのauthorization code grantやimplicit grantを利用して、Github等のauthorization serverからaccess tokenを取得したい場合、userがCLI applicationへの権限委譲に同意したあと、authorization serverからのredirectをうけるhttp serverが必要になります。
CLIの場合、localhostでlistenしているhttp serverを起動しておき、authorization serverからhttp://localhost:8080
等へredirectさせるという方法も考えられますが、以下の問題があると思いました。
- 指定のportでlistenできるとは限らない
- 同一hostでCLIを複数個同時に起動するとportが衝突する
Portが既に利用されている場合は、利用portをincrementして使えるportを見つける処理を行うことで、対応できるかなと考えていたところ、本仕様を見つけました。
RFC8628を使えば、http serverを建てることなくOAuthの認可処理を行うことができたので、本記事ではrustで実装しながらその過程をみていこうと思います。
目標は以下の処理を実装することです。
# CLIから処理を起動
CLIを実行すると、code入力画面がbrowserに表示されるので
Github上の画面でcodeを入力すると、access tokenを取得できます。
Device Authorization Grantとは
一言で説明すると、userの権限委譲を表すgrant codeをredirectによるinboundのhttp requestから取得するのではなく、authorization serverにpollingして取得する方式です。
仕様の1 Introductionでは
This OAuth 2.0 RFC6749 protocol extension enables OAuth clients to request user authorization from applications on devices that have limited input capabilities or lack a suitable browser.
とあり、今回のCLIではhttp serverを用いない為、http requestを処理できないので、lack a suitable browserにあたるでしょうか。
また、
The authorization flow defined by this specification, sometimes referred to as the "device flow", instructs the user to review the authorization request on a secondary device, such as a smartphone, which does have the requisite input and browser capabilities to complete the user interaction.
とあり、Device Authorization Grantはdevice flowとも呼ばれているようです。Githubでもdevice flowとして扱われていました。
そして
The device authorization grant is not intended to replace browser-based OAuth in native apps on capable devices like smartphones. Those apps should follow the practices specified in "OAuth 2.0 for Native Apps" RFC8252.
とあり、native app等ではdevice flowを使うべきではないようです。
Requirements for device authorization grant
仕様に定義されている、device flowのための要求事項は
- Internetに接続されていること
- Outbound http requestができること
- URIとcode sequenceをuserに表示できること
- Userはrequestを処理するために別のdevice(personal computer, smartphone等)をもっていること
と定義されていました。
CLIとしてはbrowserかsmartphoneをuserが使えることを期待するのは特に問題ないといえそうです。
Device Authorization Grantの流れ
Device authorization grantにおいて、各種情報がどのようにやり取りされるかみていきます。Introduction Figure 1がわかりやすいので引用します。
+----------+ +----------------+
| |>---(A)-- Client Identifier --->| |
| | | |
| |<---(B)-- Device Code, ---<| |
| | User Code, | |
| Device | & Verification URI | |
| Client | | |
| | [polling] | |
| |>---(E)-- Device Code --->| |
| | & Client Identifier | |
| | | Authorization |
| |<---(F)-- Access Token ---<| Server |
+----------+ (& Optional Refresh Token) | |
v | |
: | |
(C) User Code & Verification URI | |
: | |
v | |
+----------+ | |
| End User | | |
| at |<---(D)-- End user reviews --->| |
| Browser | authorization request | |
+----------+ +----------------+
Figure 1: Device Authorization Flow
今回の場合、DeviceClientがCLI、Authorization ServerがGithubにあたります。
(A) まずuserによってcliが実行されると、cliはauthorization serverにdevice flowの開始を要求するrequestを送る。
(B) するとauthorization serverはuserのbrowserで表示すべきURI(verification URI)と入力するuser codeをresponseで返す
(C) CLIはbrowserを開いて、verification URIを表示して、user codeの入力を促す
(D) Userはverification URI上で、CLIが委譲を要求する権限を確認して同意を判断する
(E) CLIはuserにverification URIとuser codeを表示した後はauthorization serverにpollingを行い、userの判断/入力の結果を待つ
(F) Userがuser codeの入力を完了すると、access tokenがauthorization serverからresponseとして返され、処理が完了する
CLIが実装するrequest/responseは2種類だけと非常にシンプルになっています。
処理の概要が把握できたので、それぞれのステップを実装していきます。
事前準備
本記事ではauthorization serverとしてGithubを利用します。
あらかじめ、CLIをGithubにOAuth applicationとして登録しておきます。
Settings > Developer Settings > New OAuth App
Enable Device Flowのcheckboxを有効にします。 Authorization callback URLはCLIのみの利用では使用しませんが、必須なので適当な値を設定しました。
設定が完了するとClient IDが振り出されるので控えておきます。
また、cargoのdependenciesは以下の通りです。
[]
= "1.0"
= "0.2.9"
= "0.1.6"
= "5.0.0"
= { = "0.11.22", = ["rustls-tls-webpki-roots", "json"] }
= { = "1.0.190", = ["derive"] }
= "1.0.108"
= { = "1.32.0", = ["rt-multi-thread", "macros", "time"] }
= "0.1.37"
= { = "0.3.17", = false, = ["smallvec", "fmt", "ansi", "std", "env-filter", "time"] }
Device Authorization Request
まず本処理はDeviceFlow
に実装していきます。
use Client;
use Duration;
use crate config;
config::USER_AGENT
にはapplicationのuser agentを設定config::github::CLIENT_ID
にはGithubから振り出されたClient IDが定義されています
続いて、(A)の処理に該当するdevice authorization requestについてみていきます。
仕様の3.1 Device Authorization Requestには
This specification defines a new OAuth endpoint: the device authorization endpoint. This is separate from the OAuth authorization endpoint defined in RFC6749 with which the user interacts via a user agent (i.e., a browser).
とあり、device flowでは専用のendpointが定義されます。
Githubではhttps://github.com/login/device/code
がdevice authorization endpointです。
仕様では、device authorization endpointに以下のrequestを行います。
/// https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
client_id
: Githubに登録したCLIのClient IDscope
: OAuthにおけるuserが委譲する権限の射程です。
GithubのscopeはScopes for OAuth appsに定義されていました。今回はuser:email
を利用します。
device authorization requestが成功すると以下のresponseをえます。(B)に該当します。
/// https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
device_code
は次のrequestで利用するcodeです。userには表示しません。user_code
userに遷移先のbrowserで入力してもらうcodeですverification_uri
userのbrowserの遷移先URIですverification_uri_complete
QRコード等のtext以外の表示手段です。今回は利用しません。expires_in
device codeのTTLです。この時間以内に処理を完了できなければ処理をやり直す必要があります。interval
pollingする際のintervalです。仕様でdefaultが5秒と定められています。
device authorization requestは以下のように実装しました。
The client initiates the authorization flow by requesting a set of verification codes from the authorization server by making an HTTP "POST" request to the device authorization endpoint. The client makes a device authorization request to the device authorization endpoint by including the following parameters using the "application/x-www-form-urlencoded" format, per Appendix B of [RFC6749], with a character encoding of UTF-8 in the HTTP request entity-body:
とあるので、POSTかつ、form-urlencodedが仕様のようでした。
Githubではjsonでも受け付けてくれそうでした。
また、responseに関しては
In response, the authorization server generates a unique device verification code and an end-user code that are valid for a limited time and includes them in the HTTP response body using the "application/json" format [RFC8259] with a 200 (OK) status code
とあるので、200のjsonで返されるのが仕様です。
これで、browserの遷移先のverification_uri
とuserが入力するuser_code
が手に入ったので、以下のように出力できます。
verification_uri
は基本的にはuserに開いてもらえばよいのですが、aws sso login
等の他のcliではbrowserを開く処理まで行っていたので、以下のようにopen-rs
でbrowserを起動させてみました。
実際にuserのbrowserをCLIから起動させようと思うとなかなか悩ましく、macであれば、open
が利用できますが、linuxの場合はxdg-open
, gnome-open
, ...と選択肢があります。
open-rs
のopen::that()
はconditional compileでplatform側の差異を吸収してくれます。
lib.rs
に以下のように定義してあり、redoxまでサポートされていました。
compile_error!;
Device Access Token Request
Userをverification_uriに遷移させ、user_codeの入力を促した後は、pollingを行います。処理の(E)にあたる部分です。
3.4 Device Access Token Requestでは
After displaying instructions to the user, the client creates an access token request and sends it to the token endpoint (as defined by Section 3.2 of RFC6749) with a "grant_type" of "urn:ietf:params:oauth:grant-type:device_code".
とあり、token endpointはこれまでのOAuthのものです。 Requestは以下のように定義しました。
grant_type
には仕様で定められたurn:ietf:params:oauth:grant-type:device_code
を指定します。device_code
はdevice authorization responseで取得した値を利用しますclient_id
: Githubから振り出されたClient IDです。
requestはdevice authorization request同様に、POSTで、form-urlencodedで行います。
続いてresponseについて。
まず、userがcodeを入力し、権限委譲に同意した場合のresponseはRFC6749 The OAuth 2.0 Authorization Framework 5.1に定義されている、通常のOAuthのresponseです。
Githubの場合は以下のような値が返ってきました。
access_token
:gho_
からはじまるaccess tokentoken_type
:bearer
expires_in
:None
Device flowでは、pollingでuserの判断を確認するので、まだuserの判断が示されていないという状態をハンドリングする必要があります。
実装している中でここが悩ましかったところなのですが、仕様では、userの入力が完了していない場合、error
の値として、authorization_pending
が返ると既定されているのですが、その際のresponse codeが明示されていません。
GithubのDevice flow docにも、status codeが明記されていませんでした。
3.5 Device Access Token Responseでは
If the user has approved the grant, the token endpoint responds with a success response defined in Section 5.1 of RFC6749; otherwise, it responds with an error, as defined in Section 5.2 of RFC6749.
In addition to the error codes defined in Section 5.2 of RFC6749, the following error codes are specified for use with the device authorization grant in token endpoint responses:
と、errorの場合は、RFC6749が参照されています。
RFC6749 5.2 Error Responseでは
The authorization server responds with an HTTP 400 (Bad Request) status code (unless specified otherwise) and includes the following parameters with the response:
と、errorの場合は400で返すとあるので、userの入力がまだ完了していない場合は400で返ってくるのかなと思いました。
が、結果としては、Githubはauthorization_pending
を200で返す実装となっていました。
HTTPのsemantics的にも、request自体のparameterは正しいので、200はおかしいと思わないのですが、どうしてここが仕様で曖昧になっているのか疑問でした。
ということで、rustの実装的には、httpのstatus codeからdeserializeする型を決めたいところなのですが、200であってもresponseの型が違うので以下のように実装しました。
request::Response::bytes()
でresponse bodyを取得して、DeviceAccessTokenResponse
にdeserializeできたら、成功、失敗した場合、DeviceAccessErrorResponse
に変換したのち、処理が継続できるか判定します。
The "authorization_pending" and "slow_down" error codes define particularly unique behavior, as they indicate that the OAuth client should continue to poll the token endpoint by repeating the token request (implementing the precise behavior defined above). If the client receives an error response with any other error code, it MUST stop polling and SHOULD react accordingly, for example, by displaying an error to the user.
と定義されており、特定のerrorの場合にのみ、pollingを継続しなければならないようなので以下のように実装しました。
hyperのcodeでmatchのarmで使い回す処理をmacroで定義していたのでこういう場面ならmacroいいのかなと思い、許容しました。
これで無事、access tokenを取得でき、device flowを完了できました。
まとめ
RFCを読みながら、Device Authorization Grantこと、device flowを実装してみました。仕様自体も20ページ程度で短く、説明もわかりやすかったです。