フューチャー技術ブログ

OAuth の仕組みを理解しながらクライアントを実装してみる

はじめに

こんにちは、TIG の吉岡と申します。Tech Blog には初投稿です。認証認可連載の 5 本目です。

業務で認証・認可に関する SaaS に触れる場面があり、そういえば OAuth, OpenID Connect の仕組みをちゃんと理解していなかったと思い、RFC を読みながら OAuth クライアントを実装してみました。N 番煎じの車輪の再発明ですが、何かの役に立てば幸いです。

※本稿において、OAuth は基本的に OAuth 2.0 を指しますが、OAuth 2.1 にて推奨されているベストプラクティスを取り入れています。
※本当は OpenID Connect にも触れたかったのですが力尽きました。また機会があれば書かせてください。

Prerequisites

この記事は、次のような方々に向けて書いています。

  • OAuth を聞いたことがある
  • 認証と認可の違いを認識している
  • HTTP の基本的な仕組みを知っている

OAuth とは

OAuth は、サードパーティアプリケーションが HTTP サービスに対して制限付きのアクセスを取得することを可能にする認可フレームワークです。

具体的には、ある SNS アプリケーションがあり、ユーザーはこの SNS に対して、自身の Google Photos 上の画像を投稿したいとします。このとき、SNS アプリケーションに Google のユーザー名とパスワードを教えることにより、画像を取得させることもできますが、この方法には、付与される権限が大きすぎる、権限を剥奪するためにはパスワードを変更するしかないなど、さまざまな問題があります。

これらの問題を解決し、サードパーティアプリケーションに適切な権限を付与するための認可フレームワークが OAuth です。

なお、2022 年現在広く使われている仕様である OAuth 2.0 は RFC 6749 により規定されています。この仕様には、後にさまざまな拡張が施されたため、それらの拡張とセキュリティに関するベストプラクティスをまとめた OAuth 2.1 が策定中です。OAuth 2.1 は OAuth 2.0 とその拡張をまとめ直した仕様として策定中であり、OAuth 2.0 を大きく変えるものではありません。

本稿では、OAuth 2.0 並びに OAuth 2.1 における代表的なフローである「認可コードグラント + PKCE」を解説します。

OAuth のロール

OAuth では、次の 4 つのロール (登場人物) が定義されています。

  • リソースオーナー: リソースの所有者であるエンドユーザー
  • リソースサーバー: リソースを保持しているサーバー
  • クライアント: リソースオーナーが利用し、リソースに対する権限を付与されるアプリケーション
  • 認可サーバー: リソースオーナーの承諾を得たうえでクライアントに対してアクセストークンを発行するサーバー

4 つのロールのうち、クライアントには注意が必要です。通常私たちがクライアントと聞くと、OS のネイティブアプリケーションやブラウザ上で動作するアプリケーションを想像してしまいますが、OAuth の言葉遣いにおいては、それらに限らず、サーバー上で動作するウェブアプリケーションもクライアントに含まれます。

また、クライアントは コンフィデンシャルクライアントパブリッククライアント の 2 種類に分類されます。後に説明しますが、認可サーバーはクライアントを識別するために、クライアント ID とクライアントシークレットを発行します。このクライアントシークレットを安全に保持できるクライアントはコンフィデンシャルクライアント、安全に保持できないクライアントはパブリッククライアントと呼ばれます。サーバー上で動作するウェブアプリケーションはコンフィデンシャルクライアントで、OS のネイティブアプリケーションやブラウザ上で動作するアプリケーションはパブリッククライアントです。

なお、クライアントは事前に認可サーバーに登録されている必要があります。登録の方法は OAuth 仕様の範疇外ですが、多くの場合、ウェブアプリケーションとして構築されたマネジメントコンソールなどから登録することができます。本稿では Google API にクライアントを登録するフローを説明します。

OAuth のグラントタイプ

OAuth では、権限付与の種類が 4 種類規定されており、これをグラントタイプと呼びます。

  • 認可コードグラント + PKCE
  • インプリシットグラント (非推奨)
  • リソースオーナーパスワードクレデンシャルグラント (非推奨)
  • クライアントクレデンシャルグラント

認可コードグラント + PKCE は、エンドユーザーの承諾を得た上でクライアントがリソースにアクセスするという、OAuth の典型的なパターンです。クライアントクレデンシャルグラントは、クライアントが直接、クライアント自身の認証情報を認可サーバーに提示して、リソースへのアクセスを取得する方法です。

OAuth 2.0 では、これらに加えてインプリシットグラント、リソースオーナーパスワードクレデンシャルグラントが規定されていますが、これらは OAuth 2.1 で削除される予定であり、もはや利用すべきではありません。

本稿では 認可コードグラント + PKCE による権限付与を説明します。認可コードグラントは、リソースオーナー・クライアント・認可サーバーの三者のやりとりによって認可を達成するため、3-legged OAuth などとも呼ばれます。

OAuth のシーケンス

認可コードグラントによる OAuth の処理シーケンスは図のとおりです。

oauth.png
  1. ユーザーが「Google Photos から画像を取得する」ボタンを押下する
  2. クライアントが 302 Found を返却し、ユーザーエージェントを認可サーバーの認可エンドポイントにリダイレクトする 1
  3. ユーザーエージェントが認可サーバーの認可エンドポイントにアクセスする
  4. 認可サーバーが認証画面を表示し、ユーザーにログインを促す
  5. ユーザーが認証情報を入力し、認可サーバーに認証を求める
  6. 認証に成功した認可サーバーは、クライアントが要求する権限一覧をユーザーに提示し、ユーザーの承諾を求める
  7. ユーザーがクライアントに対する権限の付与を承諾する
  8. 権限付与の承諾を得た認可サーバーは 302 Found を返却し、ユーザーエージェントをクライアントのリダイレクト URI にリダイレクトする 1
  9. ユーザーエージェントがリダイレクト URI にアクセスする
  10. クライアントは手順 9 で取得した認可コードを用いて認可サーバーにトークンリクエストを送信する
  11. リクエストの有効性を検証した認可サーバーは、アクセストークンを発行し、トークンレスポンスを返却する
  12. クライアントは手順 11 で取得したアクセストークンを用いてリソースサーバーにアクセスする
  13. アクセストークンの有効性を検証したリソースサーバーは、リソースを返却する
  14. クライアントはリソースオーナーのユーザーエージェントに対して、リソースを含む画面をレスポンスする

順に詳しく見ていきます。

Step A. 手順 1…2

手順 1 から 2 では、ユーザーをクライアントから認可サーバーに誘導します。ユーザーがアプリケーションの「Google Photos から画像を取得する」などのボタンをクリックすると、クライアントは各種パラメータを生成し、302 Found をレスポンスします。これにより、ユーザーエージェントは認可サーバーにリダイレクトされます。この認可サーバーに対するリクエストを 認可リクエスト と呼び、リクエスト先を 認可エンドポイント と呼びます。

認可リクエストは次の通りです。ここで、認可サーバーの認可エンドポイントは auth.example.com/authorize であるとします。

GET /authorize
?response_type=code # 1
&client_id=<client_id> # 2
&state=<state> # 3
&scope=<scope> # 4
&redirect_uri=https://client.example.com/callback # 5
&code_challenge=<code_challenge> # 6
&code_challenge_method=<code_challenge_method> HTTP/1.1 # 7
Host: auth.example.com
  1. (必須) 認可コードグラントを利用するため、response_type=code を指定します
  2. (必須) クライアント登録時に認可サーバーから発行された client_id を指定します
  3. (推奨) CSRF 攻撃を防ぐため、ランダムな state 値を指定します
  4. (任意) リソースサーバー・認可サーバーが規定するリソースアクセスのスコープを指定します
  5. (任意) クライアントが認可レスポンスを受け取るための URI を指定します
  6. (推奨) ランダムに生成した code_verifier から生成されたチャレンジを指定します (PKCE)
  7. (推奨) code_challenge を生成する方法を指定します (PKCE)

上の例では、認可コード横取り攻撃と呼ばれる攻撃を防ぐため、 PKCE (Proof Key for Code Exchange) という仕組みを使用しています。PKCE は、OAuth 2.0 において使用が推奨されており、OAuth 2.1 においては事実上必須となる見込みです。

ここで、PKCE を用いる場合、クライアントはまず code_verifier と呼ばれるランダムな ASCII 文字列を生成します。code_verifier を正規表現で書くと /[A-Za-z0-9-_.~]{43,128}/ です。次に、クライアントは code_challenge_method の値を選択します。code_challenge_method として有効な値は plainS256 です。code_challenge_method の値により、code_challenge はそれぞれ次のように計算されます。

code_challenge_method code_challenge
plain code_verifier
S256 BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

code_challenge_method == plain のとき、code_challengecode_verifier は同一の値です。code_challenge_method == S256 のとき、code_challengecode_verifier を SHA-256 ハッシュ関数に通した値となります。特段の技術的な制約がない限り、code_challenge_method == S256 を指定すべきです。

Step B. 手順 3…8

手順 3 から 8 では、ユーザーは認可サーバーとコミュニケーションをとります。手順 4, 5 においてユーザーは認可サーバーにログインします。この認証は、あくまで認可サーバーが認可処理を進めるために必要な認証であり、クライアントは認証情報そのものはおろか、認証情報がやりとりされていることすら知ることはありません。また、ユーザーがすでにユーザーエージェント上で対象サービスにログインしており、そのセッションが有効な場合、手順 4, 5 は省略されることが一般的です。手順 6, 7 において、ユーザーはクライアントに対する権限の付与を提示され、問題がなければそれを承諾します。権限付与の承諾を確認した認可サーバーは、手順 8 で 認可コード を発行し、これをクエリパラメータに付与して リダイレクト URI に対する 302 Found をレスポンスします。

認可レスポンスは次のとおりです。Location ヘッダにはリダイレクト URI が含まれています。ここで、クライアントのリダイレクト URI は https://client.example.com/callback であるとします。

HTTP/1.1 302 Found
Location: https://client.example.com/callback
?code=<authorization_code> # 1
&state=<state> # 2
  1. (必須) 認可サーバーが発行した認可コード
  2. (認可リクエストに state が含まれる場合必須) 認可リクエストにて送信した state

なお、手順 8 と手順 9 の間において、認可リクエストと認可レスポンスの state 値を比較し、これらが等しくない場合は CSRF 攻撃が行われたと判断して処理を中断する必要があります。

Step C. 手順 9…14

手順 8 により 302 Found を受け取ったユーザーエージェントは、手順 9 でリダイレクト URI に遷移します。ここにおいてクライアントは認可コードを取得し、手順 10 で トークンリクエスト を発行します。なお、トークンリクエストを受け取る認可サーバーのエンドポイントは トークンエンドポイント と呼ばれます。手順 11 において、認可コードの有効性を確認した認可サーバーは アクセストークン を発行し、トークンレスポンス としてクライアントに返却します。これ以降、クライアントはアクセストークンを用いてリソースサーバーにアクセスし、リソースへの CRUD 操作を実行することができるようになります。

トークンリクエストは次の通りです。ここで、認可サーバーのトークンエンドポイントは auth.example.com/token であるとします。

POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic <basic_auth_token> # 1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code # 2
&code=<code> # 3
&redirect_uri=https://client.example.com/callback # 4
&code_verifier=<code_verifier> # 5
  1. (必須) クライアント ID とクライアントシークレットを使用して Basic 認証を行います
  2. (必須) grant_type=authorization_code を指定します
  3. (必須) 手順 8 認可レスポンスにより取得した code を指定します
  4. (必須) クライアントが認可レスポンスを受け取るための URI を指定します
  5. (認可リクエストで code_challenge, code_challenge_method を指定した場合必須) クライアントが生成した code_verifier を指定します

トークンレスポンスは次の通りです。

HTTP/1.1 200 OK
Content-Type: application/json

{
"access_token": <access_token>, # 1
"token_type": "bearer", # 2
"expires_in": 3600, # 3
"refresh_token": <refresh_token> # 4
}
  1. 発行されたアクセストークン
  2. トークンの種類
  3. トークンの有効期限 (秒数)
  4. リフレッシュトークン (本稿では触れない)

Google Photos API を使ってみる

ここからは、実際に Google Photos API を利用するクライアントアプリケーションを実装し、OAuth の処理フローを確認していきます。

GCP マネジメントコンソールにてクライアントを登録する

GCP に実験用のプロジェクトを作成します。

API & Services から、OAuth consent screen を設定していきます。

External を選択し、CREATE を押下します。

App information として、App name に任意の名前を設定し、User support email にはご自身の Google アカウントのメールアドレスを設定します。

Developer contact information として、ご自身の Google アカウントのメールアドレスを設定し、SAVE AND CONTINUE を押下します。

Scopes 画面において、ADD OR REMOVE SCOPES を押下します。

今回は Google Photos API を利用したいため、Manually add scopes に https://www.googleapis.com/auth/photoslibrary.readonly と入力し、ADD TO TABLE を押下します。なお、このスコープは、対象ユーザーの Google Photos リソースに対する読み取り権限を付与します。

テーブルに Google Photos API のスコープが追加されるので、チェックボックスをチェックし、UPDATE を押下します。次いで、SAVE AND CONTINUE を押下します。

Test users 画面においてテストのためのユーザーを設定します。ADD USERS を押下し、ご自身の Google アカウントのメールアドレスを入力の上、ADD を押下してください。

テーブルにご自身のメールアドレスが追加されていることを確認し、SAVE AND CONTINUE を押下します。

Summary 画面において、各種パラメータが正しいことを確認の上、BACK TO DASHBOARD を押下します。

次に OAuth client ID を作成します。Credential 画面に遷移し、CREATE CREDENTIAL, OAuth client ID と順に押下します。

実験用のサーバーは localhost:8080 に立てる予定なので、Authorization redirect URI として http://localhost:8080/callback を入力し、CREATE を押下します。

OAuth クライアントのクライアント ID とクライアントシークレットが作成されました。これらを控えておいてください。

次に、今回利用する Photos Library API を有効にしておきます。Google Photos Library API のページにアクセスし、ENABLE を押下してください。

ここまでで GCP の作業は完了です。次に、ローカル環境でアプリケーションを立て、ブラウザからアクセスします。

クライアントを実装する

ここからは、Go によりクライアントアプリケーションを実装し、実際にローカル環境で動かしていきます。なお、テストに使用したソースコードは https://github.com/tmsick/tech-blog-oauth にアップロードしました。本稿にも main.go の全量を掲載します。

なお、このアプリケーションは次の環境変数を必要とします。これらは、先ほどの手順で Google から発行されたクライアント ID とクライアントシークレットです。

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET

掲載しているコードは簡単な検証用のコードであり、適切なエラーハンドリングを省略していますのでご承知おきください。

main.go
package main

import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"text/template"

"github.com/gorilla/sessions"
)

type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}

type Photos struct {
MediaItems []struct {
ID string `json:"id"`
ProductURL string `json:"productUrl"`
BaseURL string `json:"baseUrl"`
MimeType string `json:"mimeType"`
Filename string `json:"filename"`
} `json:"mediaItems"`
}

const (
lenState = 30
lenCodeVerifier = 64
redirectURI = "http://localhost:8080/callback"
)

var (
googleClientID string
googleClientSecret string
store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
)

func init() {
googleClientID = os.Getenv("GOOGLE_CLIENT_ID")
googleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")

if googleClientID == "" {
log.Fatal("Env var GOOGLE_CLIENT_ID is required")
}
if googleClientSecret == "" {
log.Fatal("Env var GOOGLE_CLIENT_SECRET is required")
}
}

func main() {
http.HandleFunc("/", handleIndex)
http.HandleFunc("/oauth", handleOAuth)
http.HandleFunc("/callback", handleCallback)
http.HandleFunc("/photos", handlePhotos)
log.Print("Serving web server at localhost:8080")
http.ListenAndServe("0.0.0.0:8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
tpl, _ := template.ParseFiles("templates/index.html")
tpl.Execute(w, nil)
}

func handleOAuth(w http.ResponseWriter, r *http.Request) {
// Save `state` and `code_verifier` to session
session, _ := store.Get(r, "session")
// Generate a random state
state, _ := randomString(lenState)
session.Values["state"] = state
// Generate code_verifier
codeVerifier, _ := randomString(lenCodeVerifier)
session.Values["code_verifier"] = codeVerifier
session.Save(r, w)

// Generate code_challenge
b := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b[:])

// Redirect the user agent == Make a authorization request
u, _ := url.Parse("https://accounts.google.com/o/oauth2/v2/auth")
q := u.Query()
q.Add("response_type", "code") // Indicate authorization code grant
q.Add("client_id", googleClientID) // The client ID issued by Google
q.Add("state", state) // The random state
q.Add("scope", "https://www.googleapis.com/auth/photoslibrary.readonly") // The scope we need
q.Add("redirect_uri", redirectURI) // The redirect URI
q.Add("code_challenge", codeChallenge) // Code challenge
q.Add("code_challenge_method", "S256") // Code challenge method
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
// Confirm `state` matches
session, _ := store.Get(r, "session")
if r.URL.Query().Get("state") != session.Values["state"] {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "Invalid state")
return
}
// Get `code_verifier`
codeVerifier := session.Values["code_verifier"].(string)
// Clear session
session.Values["state"] = ""
session.Values["code_verifier"] = ""
session.Save(r, w)

// Make a token request
code := r.URL.Query().Get("code")
q := url.Values{}
q.Add("grant_type", "authorization_code") // Indicate token request
q.Add("code", code) // The authorization code
q.Add("redirect_uri", redirectURI) // The redirect URI
q.Add("code_verifier", codeVerifier) // Code verifier
req, _ := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(q.Encode()))
req.SetBasicAuth(googleClientID, googleClientSecret)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

// Capture the access token we've received
body, _ := io.ReadAll(resp.Body)
var token TokenResponse
json.Unmarshal(body, &token)
session.Values["access_token"] = token.AccessToken
session.Save(r, w)

// Redirect the user agent to the photos page
http.Redirect(w, r, "http://localhost:8080/photos", http.StatusFound)
}

func handlePhotos(w http.ResponseWriter, r *http.Request) {
// Fetch photos from Google Photos Library API using the access_token
session, _ := store.Get(r, "session")
accessToken := session.Values["access_token"].(string)
req, _ := http.NewRequest(http.MethodGet, "https://photoslibrary.googleapis.com/v1/mediaItems", nil)
req.Header.Add("Authorization", "Bearer "+accessToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var photos Photos
json.Unmarshal(body, &photos)

// Render photos
tpl, _ := template.ParseFiles("templates/photos.html")
tpl.Execute(w, photos)
}

// randomString generates a secure random string of length `length`.
// It returns an error when `length` is negative or failed to use the platform's
// secure pseudorandom number generator.
func randomString(length int) (string, error) {
if length < 0 {
return "", fmt.Errorf("cannot generate random string of negative length %d", length)
}
var s strings.Builder
for s.Len() < length {
r, err := rand.Int(rand.Reader, big.NewInt(1<<60))
if err != nil {
return "", err
}
// 1<<60 == 2**60 equals to 1,000,000,000,000,000 in hex.
s.WriteString(fmt.Sprintf("%015x", r))
}
return s.String()[:length], nil
}

アプリケーションのエンドポイントは次の 4 つです。

パス 説明
/ アプリケーションのインデックス。「Fetch photos from Google Photos」ボタンが設置されている。
/oauth OAuth 開始エンドポイント。ブラウザを Google Photos API の認可エンドポイントにリダイレクトする。
/callback リダイレクトエンドポイント。認可レスポンスにより呼び出される。
/photos 認可完了後にブラウザがリダイレクトされるエンドポイント。Google Photos の画像が表示される。

以下、ハンドラごとに処理を説明していきます。

  1. handleIndex()
  2. handleOAuth()
  3. handleCallback()
  4. handlePhotos()

1. handleIndex()

handleIndex() は、静的な HTML をレスポンスしているだけです。

2. handleOAuth()

78…86 行目において、ランダムな statecodeVerifier を生成し、ブラウザのセッションに保存しています。

88…90 行目において、codeVerifier から codeChallenge を生成しています。

92…103 行目において、認可リクエストの URL を生成し、ブラウザをリダイレクトしています。

3. handleCallback()

107…113 行目において、ブラウザが保持している statecode_verifier を取得し、セッションをクリアしています。

115…120 行目において、ブラウザが保持していた session と認可サーバからレスポンスされた state が等しいことを確認しています。これにより、CSRF 攻撃を防いでいます。

122…133 行目において、トークンリクエストを生成し、リクエストをおこなっています。

135…140 行目において、トークンレスポンスからアクセストークンを取得し、ブラウザのセッションに保存しています。

142…143 行目において、ブラウザを /photos にリダイレクトしています。

4. handlePhotos()

147…156 行目において、セッションに保存されたアクセストークンを用いて Google Photos API にアクセスし、画像情報を取得しています。

158…160 行目において、画像情報 (URL) を含むレスポンスを返却しています。

おわりに

OAuth, OpenID 周りには、なんとなく苦手意識があったのですが、今回仕様に基づいてスクラッチで実装することで、技術の基盤とセキュリティ上の考慮事項などを知ることができました。

IDaaS の採用が当然となった現在において、認証・認可周りの知識は、ウェブ技術者の基礎教養といえると思います。今後も引き続き認証・認可周りの情報をウォッチしていきます。


  1. 1.仕様上、302 Found 以外の方法でユーザーエージェントをリダイレクトすることも許可されています。特に理由がなければ 302 Found でよいでしょう。