はじめに
クライアントを作って理解するOAuth2(準備編) - oinume journalの続編。前の記事ではGoogle APIsのプロジェクトを作成してOAuth2 clientを登録した。この記事では発行されたClient idを使って実際にAccess tokenを取得する部分をGoで実装してみたいと思う。
ソースコードは GitHub - oinume/go-oauth2-client-sample: OAuth2 client sample in Go にあり、OAuthの実装はserver.go
にまとまっている。main関数はcmd/oauth2-client-sample/main.go
にあるが、これはサーバーの起動処理があるだけの薄いものである。サーバーの起動方法などについてはGitHubのREADMEを見てほしい。
前提
- OAuth2のGrant typeはAuthorization code grantを使用する
- Webブラウザを使用する
Webブラウザを使うことに関しては特に疑問はないと思うが、Authorization code grantって何?というのを説明しておく。
これはEnd userから認可を得て、Access tokenを取得するための手法の一つ。Authorization codeというトークンをAuthorization server(今回だとGoogleの認可サーバー)から取得して、そのAuthorization codeとAccess tokenを交換する。なお、Authorization code grantを使用すると、Access token以外にもRefresh tokenも取得ができる。このgrant typeはConfidential clientから実行されることを想定している。Confidential clientについては次に説明する。
Public clientとConfidential client
OAuth2.0の仕様にその定義が書いてあるが以下のようになっている。(RFCの日本語訳より抜粋)
- コンフィデンシャル (confidential): クレデンシャルの機密性を維持することができるクライアント (例えば, クライアントクレデンシャルへのアクセスが制限されたセキュアサーバー上に実装されたクライアント), または他の手段を使用したセキュアなクライアント認証ができるクライアント.
- パブリック (public): (例えば, インストールされたネイティブアプリケーションやブラウザベースのWebアプリケーションなど, リソースオーナーのデバイス上で実行されるクライアントのように) クライアントクレデンシャルの機密性を維持することができず, かつ他の手段を使用したセキュアなクライアント認証もできないクライアント.
今回はGoで実装されたサーバーからGoogleのAuthorization serverに対して通信するため、コンフィデンシャルクライアントになる。
Access tokenを取得するまでの流れ
- End userが
Sign in with Google
ボタンをクリックし、Clientのサーバーにリクエストが送信される
- Clientが認可リクエストを認可サーバーに送信する
- 認可サーバーが認可画面のHTMLをレスポンスで返す
- Webブラウザで認可画面を表示
- End userが認可を行い、認可サーバにリクエストが送信される
- 認可サーバが認可コードを生成し、ClientのサーバーにRedirectする
- Clientのサーバーがstateをチェックし、認可コードからトークンリクエストを認可サーバーに送信する
- 認可サーバーが認可コードをチェックしてAccess tokenを生成する
- 認可サーバーからClientのサーバーにリダイレクトしてAccess tokenをClientに返す
- ClientはAccess tokenを取得する
ソースコードベースでの説明
ソースコードの詳細な説明に入る前に全体の構成を説明する。今回作るAuth2 Clientのサーバーは以下のendpointを持っている。これらのendpointはソースコード上ではserver.goに定義されている。今回は説明のしやすさのためGoのOAuth2ライブラリであるgolang.org/x/oauth2は使用せず独自で実装している。
- /: Sign in with Googleボタンを表示するためのHTMLを返す
- /oauth2/authorize: Sign in with GoogleボタンがクリックされるとこのURLが呼び出される。この処理の中でAuthorization serverへリダイレクトし、Authorization requestを送る。
- /oauth2/callback: Authorization serverからリダイレクトで呼び出される。Authorization codeを受け取るので、それをもとにToken requestを送ってAccess tokenを取得する
- /static: Sign in with Googleボタンのイメージの静的リソースを返す
それではendpointごとに詳細に説明していくとしよう。/
と /static
は特に難しいところはないので省略する。
/oauth2/authorize
以下がこのendpointに対するメソッドで、おおまかに説明すると以下のような流れになっている。
- まずgenerateStateで
state
を生成する
- createAuthorizationRequestURLでURLを生成する。このURLにはAuthorization requestに必要なquery stringなどが含まれている。
- stateをCookieにセットする
- Authorization server(Google)にリダイレクトする
func (s *server) authorize(w http.ResponseWriter, r *http.Request) {
state, err := generateState()
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
u, err := s.createAuthorizationRequestURL(redirectURI, scopes, state)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
log.Printf("authorization request url = %v\n", u)
cookie := &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/",
Expires: time.Now().Add(10 * time.Minute),
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, u.String(), http.StatusFound)
}
createAuthorizationRequestURL
が重要なので、次にこのメソッドを詳細に説明する。
func (s *server) createAuthorizationRequestURL(
redirectURI string,
scopes []string,
state string,
) (*url.URL, error) {
u, err := url.Parse(authorizationEndpoint)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", s.clientID)
if redirectURI != "" {
q.Set("redirect_uri", redirectURI)
}
if len(scopes) > 0 {
q.Set("scope", strings.Join(scopes, " "))
}
q.Set("state", state)
q.Set("prompt", "consent")
u.RawQuery = q.Encode()
return u, nil
}
このメソッドは引数にredirectURI, scopes, stateを受け取る。redirectURIおよびscopesは動的に生成されるものではないのでハードコードしてもよいが、再利用性を考慮してメソッド外部から渡せるようにしている。
このメソッドでは
- authorizationEndpointからurl.URLを生成する
- 必要なパラメータをquery stringとしてセットする
u
にquery stringをURL encodeしてセットする
- 生成したAuthorization requestのURLである
u
を返す
という処理を行っている。必要なパラメータについてはOAuth2.0の仕様に定義されているが、簡単に説明すると以下である。
- response_type: Authorization code grantのため
code
を指定
- client_id: Googleから発行されたClient id
- redirect_uri: Googleに登録したRedirect URI
- scope
- state: 先に生成したランダム文字列
- prompt: 今回は認可画面を必ず表示するために
consent
を指定しているが、なくても良い。
話をもとに戻して、createAuthorizationRequestURL
で生成されたURLに対してHTTPリダイレクトを行い、GoogleのAuthorization serverにAuthorization requestを送るのがこのメソッドで行っている全てである。
認可画面の表示
Authorization requestが成功すると、Authorization serverから認可画面のHTMLがレスポンスとして返ってくるので、Webブラウザはそれを表示する。Googleの場合だと以下のような画面になる。(Googleにログインしていない場合は、認可画面の前にログイン画面が表示される)
認可画面で表示されている内容は、gmail-fetcherというClient(3rd party application)が、表示されているGoogleアカウントのメール メッセージと設定の表示
(scope)を行うことを許可するかどうかを確認するというものだ。ここで許可する
というボタンをクリックすると、gmail-fetcherというClientに対してアクセスする許可を与えたので、Access tokenが発行されることになる。
/oauth2/callback
End userが認可画面で許可する
ボタンをクリックすると、Authorization serverからこのendpointにリダイレクトでコールバックされる。メソッドのコードは以下。
func (s *server) callback(w http.ResponseWriter, r *http.Request) {
log.Printf("callback: state=%v, code=%v", r.FormValue("state"), r.FormValue("code"))
if e := r.FormValue("error"); e != "" {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("error returned in authorization: %v", e))
return
}
if err := validateState(r); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
code := r.FormValue("code")
if code == "" {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
token, err := s.exchange(ctx, code)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "accessToken = %v", token.AccessToken)
}
やっていることとしては以下になる。OAuth2.0の仕様としてはAuthorization Responseに記載されている。
- errorが渡って来ているかどうかをチェック
- 今回の実装では、errorがある場合は400エラーを返すようにしているが、本来であればこれはエラーの種類に応じて適切なエラー画面を表示するのが正しい実装だと思う。エラーの種類に関してもOAuth2.0の仕様に定義されている。
- checkStateを呼び出して、Cookieに保存したstateとcallbackのパラメータで来たstateが一致しているかをチェック
- なぜこのようなチェックをしているかは長くなるので別の記事で説明する予定
- codeが渡って来ているかをチェック
- exchangeを呼び出して
code
からAccess tokenを取得
- Access tokenをレスポンスとして返す
- Webブラウザに取得したAccess tokenが表示される
- ちゃんと実装するなら、Access tokenをDBに保存したりするはず
exchangeメソッドについては長くなるので次で詳細に説明する。
exchangeメソッド
これはAuthorization codeからAccess tokenを取得するメソッドである。Access Token Requestに仕様が書かれている。
- リクエストに必要なquery stringをセットする
- Authorization serverがクライアント認証を行うため、Client idとClient secretをセットする
- Token endpointにHTTPリクエストを送る
- 返ってきたHTTPレスポンスを受け取って内容をパースし、Access tokenなどを得る
- Webブラウザで表示するためにAccess tokenをレスポンスにセット
ということがやっていることだ。query stringにセットしているパラメータはそれぞれ以下になる。
- grant_type: Authorization code grantなので
authorization_code
をセット
- code: Authorization codeをセット
- redirect_uri: Authorization requestで使ったredirect_uriと同じものをセット
- client_id: Client idをセット
また、仕様には以下のように書かれているので、Client credentialsとしてreq.SetBasicAuthでClient idとClient secretをセットしている。
The authorization server MUST:
o require client authentication for confidential clients or for any client that was issued client credentials (or with other authentication requirements),
o authenticate the client if client authentication is included,
func (s *server) exchange(ctx context.Context, code string) (*tokenEntity, error) {
v := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {s.clientID},
}
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(s.clientID), url.QueryEscape(s.clientSecret))
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
}
if code := resp.StatusCode; code < 200 || code > 299 {
log.Printf("token request failed: statusCode=%v, body=%v\n", code, string(body))
return nil, fmt.Errorf("oauth2: token request failed: statusCode=%v", code)
}
var token *tokenEntity
contentType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return nil, fmt.Errorf("oauth2: failed to parse Content-Type header: %v", err)
}
if contentType != "application/json" {
return nil, fmt.Errorf("oauth2: invalid Content-Type in response: %v", contentType)
}
token = &tokenEntity{}
if err = json.Unmarshal(body, token); err != nil {
return nil, err
}
if token.ExpiresIn != 0 {
token.expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
}
return token, nil
}
なお、Access tokenには有効期限があり、あとどのぐらいで有効期限が切れるのか?がレスポンスのexpires_in
というフィールドにセットされている。通常であればAccess tokenをDBに保存すると同時にこの有効期限も保存しておき、有効期限が切れたらRefresh tokenを使ってAccess tokenをリフレッシュするという処理が必要になる。
まとめ
細かいところは飛ばしつつも、Authorization code grantによるAccess tokenの発行まで、ライブラリを使わずに一通り実装してみた。ところどころにOAuth2.0のRFCへのリンクを入れたので、適宜参照してもらうと仕様の理解が深まるのではないかと思う。自分で実装していて思ったのは、「エラーハンドリングとか細かいところは面倒なポイントがあるけど、全体的にOAuth2.0のClientの実装って簡単じゃん!」ということ。OAuthって聞くとなんか大変そうなイメージがあったけど自分的には攻略できた感じ。この記事が仕事で外部サービスとの連携でクライアントとか認可サーバーの実装しなくてはいけない人たちの役にたてば嬉しいです。