OAuth 2.0 / OIDCを理解するために、自分でGoで実装してみました。
以下のハンズオンに従って実装していただくと、OAuth認可コードフローとOIDCの一連の流れの理解が深まると思います。
OAuth 2.0は、ユーザーの認証情報を直接アプリに渡すことなく、外部サービスを通じて安全に認可を行うためのプロトコルです 。特にWebやモバイルアプリ開発では、認証と認可を分離しセキュアに委譲する手段として広く利用されています。OpenID Connect (OIDC)はOAuth2.0を拡張してユーザーのアイデンティティ情報を扱う仕組みで、OIDCではIDトークンと呼ばれるJWT形式のトークン(署名付き)が発行され、これによってクライアントはユーザーを識別できます。OAuth2.0単体ではユーザー個人を表すトークンは発行されませんが、OIDCではscopeにopenidを含めることで認可サーバーからIDトークンが得られる点が大きな違いです。
本記事では、OAuth2.0の「認可コードフロー」(Authorization Code Grant)にOIDCの要素を加えた認可サーバーを、Go言語でライブラリに極力頼らず(JWT署名やCookie処理など最小限を除く)実装してみます。対象読者はGoでのWeb開発経験があるがOAuth/OIDCは初学者の方を想定しています。認可エンドポイント・トークンエンドポイントの処理や、ユーザー認証、IDトークン発行、セッション管理、PKCE、state・nonceといったセキュリティ機構を含め、OAuth認可コードフロー+OIDCの一連の流れをコードを追いながら理解することがゴールです。
↓ 遊園地のたとえで読み解く記事はこちら
フローの全体像
まず、OAuth2.0/OIDCの認可コードフローに登場する主要な要素と流れを押さえましょう。主な登場人物(ロール)は以下の通りです:
- リソースオーナー(ユーザー): 資源(ユーザーデータ)の所有者であり、第三者クライアントにアクセス許可を与える人。
- クライアント: ユーザーが利用するアプリケーション。リソースオーナーに代わりAPI等にアクセスを行う。本記事ではクライアントはWebアプリ(サーバーサイドを持つ機密(Confidential)クライアント)を想定します。
- 認可サーバー: リソースオーナーを認証し、クライアントにアクセストークンやIDトークンを発行するサーバー(OAuth2.0ではAuthorization Server、OIDCではOpenID Providerに相当)。
- リソースサーバー: アクセストークンの提示により保護リソースを提供するAPIサーバー(本記事では実装しませんが、概念として登場します)。
上図の認可コードフロー + OIDC のシーケンスを、ユーザーのブラウザ(ユーザーエージェント)を介したやり取りに沿って簡略化すると以下のようになります:
- ブラウザ → 認可サーバー(認可エンドポイント): クライアントはユーザーを認可エンドポイントにリダイレクトし、response_type=code, client_id, redirect_uri, scope(例: openid), state(推奨), code_challenge(PKCE)等を含む認可リクエストを送ります。
- 認可サーバー → ブラウザ: 認可サーバーはまずユーザーが認証済みか確認します。未ログインならログイン画面を表示してユーザーに認証を要求します。
- ブラウザ → 認可サーバー(ユーザー認証): ユーザーが認証情報を入力して送信し、認可サーバーでユーザー認証を行います。
- 認可サーバー → ブラウザ: 認可サーバーはユーザーが認証(および必要なら同意)したら認可コードを発行し、指定されたredirect_uriに対してHTTPリダイレクトで遷移させます。リダイレクト先のURIにはクエリパラメータとして発行したcodeおよびクライアントが送ってきたstateを付与します。
- ブラウザ → クライアント(リダイレクトURI): ブラウザがクライアントのredirect_uriに遷移し、クエリパラメータ経由で認可コード(およびstate)がクライアントに届きます。クライアント(の自前サーバー)はstateを検証し、一致しなければCSRF攻撃と見なしてエラーとします。
- クライアント → 認可サーバー(トークンエンドポイント): クライアントはバックエンドサーバーから認可サーバーのトークンエンドポイントに対し、先ほど受け取った認可コードを送信してアクセストークンの発行をリクエストします。このリクエストではgrant_type=authorization_code, code, redirect_uri、クライアント認証情報(例えばクライアントIDとシークレット)、さらにPKCEを使用している場合code_verifierを送信します。
- 認可サーバー → クライアント: 認可サーバーはリクエストを受け取ると、認可コードの有効性やクライアント認証を検証します。問題なければアクセストークンを発行し、JSON形式のレスポンスでクライアントに返します。このときOIDCのリクエストであればIDトークンもアクセストークンと一緒に返されます(後述コードで実装)。また、セキュリティ上アクセストークンには有効期限(expires_in)を設定します。
- クライアント → リソースサーバー: クライアントは受け取ったアクセストークンをAPIのリソースサーバーに提示し、保護されたユーザーデータへアクセスします。リソースサーバーはアクセストークンを検証してリクエストを処理します(この部分は本記事では省略します)。
上記のフロー全体を通じて、OAuth2.0/OIDCでは複数のセキュリティ対策が組み込まれています。PKCEは認可コード奪取への対策で、コード発行時にランダムなコードチャレンジを設定し、トークン要求時にその元となるコードベリファイアを提示させることで、コードが盗まれても第三者がトークンに交換できないようにします。stateパラメータはクライアントがランダムな値を渡し、リダイレクト後に同じ値が返ってくることを確認することでCSRF攻撃を防止します。nonceパラメータはOIDCで利用される追加の乱数で、後述するIDトークンに含めて認証リクエストのリプレイ攻撃を防止する目的があります。以降のセクションで、これらをすべて含む認可サーバー実装を行いながら詳しく見ていきます。
実装準備
プロジェクト準備
mkdir oauth2-oidc-sample
cd oauth2-oidc-sample
go mod init example.com/oauth2-oidc-sample
go get github.com/golang-jwt/jwt/v4
実行方法
起動すると、例えば http://localhost:8080 にサーバーが立ち上がり、
以下のようなエンドポイントを実装予定です:
-
/authorize
: 認可エンドポイント(ブラウザアクセスで state, code_challenge を受け取る) -
/login
: ダミーのログインフォーム -
/callback
: 認可コードを受け取るクライアントの固定窓口 -
/token
: トークンエンドポイント(code + verifier → AT/IDT/RT を返す)
テストの流れ
- ブラウザで
/authorize?client_id=xxx&redirect_uri=http://localhost:8080/callback&scope=openid&state=...&code_challenge=...&response_type=code
にアクセス。 - ログインフォームにユーザー名・パスワードを入力。
- 認可コードが
/callback
に届く。 - クライアントが
/token
に POST →access_token
とid_token
が返る。 -
access_token
を付けて保護APIにアクセスして動作確認。(今回は省略)
認可サーバーをシンプルに実装するため、Goの標準パッケージnet/httpを用いて最小限のWebサーバーを構築します。また、学習目的のためデータはすべてインメモリで管理し、外部ストレージやデータベースは使用しません(現実のシステムではクライアント情報や認可コード、トークンをDBで管理・検証する必要があります)。今回はあらかじめクライアントとユーザーを1件ずつ登録した固定の想定で進めます(クライアント登録API等は省略)。またOpenID Connectの要素としてJWTの署名生成が必要になるため、JWTライブラリ(ここではgithub.com/golang-jwt/jwt/v4)を利用します。その他、暗号学的に十分ランダムな文字列を生成するため、Goのcrypto/randを使用します。
それではまず、サーバーの基本構成とグローバル変数を用意しましょう。以下のコードは、認可サーバーに登録されたクライアントとユーザー、およびセッションや認可コード・トークンの保存に使う構造体や変数を定義しています。
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/golang-jwt/jwt/v4"
)
type Client struct {
ID string
Secret string
RedirectURI string
}
type AuthCodeData struct {
ClientID string
UserID string
RedirectURI string
CodeChallenge string
CodeChallengeMethod string
Nonce string
Expiry time.Time
Scope string
}
var demoClient = Client{
ID: "client1", Secret: "secret",
RedirectURI: "http://localhost:8080/callback",
}
var demoUserID = "user1"
var demoPassword = "pass1"
var authCodes = make(map[string]AuthCodeData)
var sessions = make(map[string]string)
var jwtSigningKey = []byte("HMAC-secret-key-123")
上記では、クライアントとしてclient1というIDとシークレットsecretを持つ1件を登録し、許可されたリダイレクト先をhttp://localhost:8080/callbackとしています。ユーザーも簡単のため1件(ユーザーID:user1, パスワード:pass1)のみとし、その認証に成功したらセッションIDを発行してsessionsマップに保存します。authCodesマップは発行した認可コードとその付随情報(どのクライアント・ユーザーに紐づくか、PKCEやnonceの値、スコープや有効期限など)を保存するためのものです。認可コードには一意性と有効期限の要件がありますので、コード発行時に現在時刻に基づく期限(例えば5分後)を設定しておき、トークンエンドポイントで検証します。JWTの署名にはHMAC方式を用い、上記jwtSigningKeyに固定の秘密鍵文字列を入れています(本来はより安全な管理が必要です)。
また、便宜的に以下のヘルパー関数も用意しておきます。
func generateRandomString(byteLen int) string {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
ユーザー認証とセッション管理
OAuth2.0の認可コードフローでは、クライアントからユーザーが認可サーバーに誘導された際にユーザーの認証(ログイン)が行われます。認可サーバーはユーザーをログインさせた上で、クライアントに認可コードを発行する必要があります。ユーザー認証の実装には様々な方法がありますが、本記事ではシンプルにセッションID+クッキーによるログイン管理を行います 。つまり、ユーザーが一度ログインするとサーバー側でセッション情報を保持し、以降のリクエストではブラウザのCookieに保存されたセッションIDを用いてユーザーを識別します 。こうすることで毎回認証情報を送らなくてもログイン状態を維持できます。CookieにはセッションID以外の認証情報(ユーザー名やパスワードなど)は保持しないのが一般的で、推測困難なランダム値をIDとして用います 。
認可サーバーにアクセスしたユーザーが未ログインの場合、認可サーバーはログイン用のエンドポイント(ページ)にリダイレクトし、ユーザー名・パスワードの入力を受け付けます。ログイン成功時に新規セッションを発行しCookieを設定、元々アクセスしようとしていた認可処理に戻る、という流れになります。以下に/login
エンドポイントの実装を示します。
func loginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
redirectTo := r.URL.Query().Get("redirect")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, ``, redirectTo)
case http.MethodPost:
user := r.FormValue("user")
pass := r.FormValue("pass")
redirectTo := r.FormValue("redirect")
if user == demoUserID && pass == demoPassword {
sessionID := generateRandomString(16)
sessions[sessionID] = demoUserID
http.SetCookie(w, &http.Cookie{
Name: "session_id", Value: sessionID, Path: "https://zenn.dev/", HttpOnly: true,
})
log.Printf("User '%s' logged in, set session %s", user, sessionID)
http.Redirect(w, r, redirectTo, http.StatusFound)
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
説明: loginHandlerはGETリクエストではシンプルなログインフォームHTMLを返し、POSTではフォーム送信されたユーザーID・パスワードを検証します(本例では固定のuser1/pass1と照合)。成功した場合、新たなセッションIDをランダム生成しsessionsマップに記録、クライアントへはSet-CookieヘッダーでセッションIDをクッキーに保存します 。CookieにはHttpOnly属性を付与し、JavaScriptからアクセスできないようにしています(クッキー盗難防止策)。最後に、ログイン画面に来る前の元のアクセス先(redirectパラメータで受け取ったURL)にリダイレクトしています。こうしてユーザーはログイン後、自動的に本来の認可処理に戻ることができます。認証失敗時は401エラーを返しています。
認可エンドポイント(/authorize)の実装
認可エンドポイントは、クライアントからの認可要求を受け付けて認可コードを発行する役割を持ちます。OAuth2.0のRFC 6749では、認可エンドポイントに対して以下のような検証や動作が要求されています:
- リクエストパラメータの検証: response_typeがcodeであること、client_idが有効なクライアントのIDであること、redirect_uriが事前に登録されたものと一致すること、stateがある場合は受理して後続のレスポンスに含めること、等。これらが満たされない場合、不正なリクエストとしてエラーを返します。不整合なリクエストに対して認可コードを発行してしまうと、悪意のあるクライアントにコードを渡してしまうリスクがあります。
- 認可コードの生成: リクエストが正当であれば、一意の認可コードを安全な方法で生成します。認可コードには有効期限を持たせる必要があります。本実装ではコードをランダム文字列(推測困難な値)とし、期限をたとえば5分後に設定します。
- 認可コードの保存: 発行したコードと対応するクライアント、リダイレクトURI、ユーザーID、期限などの情報をサーバー側で保存します。こうして後でトークンエンドポイントで検証できるようにします。
- リダイレクト応答: 認可コード発行後、リクエストで受け取ったredirect_uriに対してHTTPリダイレクト(303または302応答)します。その際、クエリパラメータにcodeとstate(もし受け取っていれば)を付与します。
さらに今回はOIDC対応としてnonceパラメータや、セキュリティ拡張のPKCEにも対応します。PKCEを使用するクライアントであれば、認可リクエストにcode_challengeとcode_challenge_method(通常はS256)が含まれているので、これらも認可コードと一緒に保存します。なおクライアントがPublic(ネイティブアプリ等)でシークレットを持たない場合でもPKCEを使うことでセキュリティを向上できます。本実装ではクライアントはWebアプリ(Confidential)ですが、ベストプラクティスとしてPKCEを扱います。
またstateについては、リクエストに含まれていればその値をそのままレスポンスのリダイレクトURIに付与します。認可サーバーはstateの中身を利用しませんが、クライアント側ではこの値を検証することでCSRF攻撃を防ぎます。そのためstateはクライアントが推奨されるパラメータです。
それでは、以上を踏まえて/authorize
エンドポイントのハンドラーを実装します。
func authorizeHandler(w http.ResponseWriter, r *http.Request) {
var userID string
if cookie, err := r.Cookie("session_id"); err == nil {
uid, ok := sessions[cookie.Value]
if ok {
userID = uid
}
}
if userID == "" {
redirectURL := "/login?redirect=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
responseType := r.URL.Query().Get("response_type")
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
scope := r.URL.Query().Get("scope")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
if responseType != "code" {
http.Error(w, "unsupported response_type", http.StatusBadRequest)
return
}
if clientID != demoClient.ID {
http.Error(w, "invalid client_id", http.StatusUnauthorized)
return
}
if redirectURI == "" || redirectURI != demoClient.RedirectURI {
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
return
}
if state == "" {
log.Println("warning: state is empty (CSRF対策にstate使用を推奨)")
}
if scope == "" {
scope = "openid"
}
method := "plain"
if codeChallenge != "" {
if codeChallengeMethod != "" {
method = codeChallengeMethod
}
if method != "S256" && method != "plain" {
http.Error(w, "unsupported code_challenge_method", http.StatusBadRequest)
return
}
}
code := generateRandomString(32)
authCodes[code] = AuthCodeData{
ClientID: clientID,
UserID: userID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
CodeChallengeMethod: method,
Nonce: r.URL.Query().Get("nonce"),
Scope: scope,
Expiry: time.Now().Add(5 * time.Minute),
}
log.Printf("Generated code %s for client=%s user=%s (PKCE=%v, scope=%s)",
code, clientID, userID, codeChallenge != "", scope)
redirectURL := fmt.Sprintf("%s?code=%s", redirectURI, url.QueryEscape(code))
if state != "" {
redirectURL += "&state=" + url.QueryEscape(state)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
}
このauthorizeHandlerは以下の手順で処理しています。
-
ユーザー認証確認: クッキーsession_idをチェックし、有効なログインセッションがなければ
/login
にリダイレクトします。ログイン後、再度同じ/authorize
に戻って処理が続行されます。 -
リクエストの検証: response_typeは今回はコードフローのみ対応するため”code”であることを確認します。client_idとredirect_uriは事前に登録済みのものと一致するか検証します(本実装では単一のクライアントをdemoClientに保持しているためそれと比較しています。実際の環境ではDB等に登録されたクライアント情報と照合します)。また、redirect_uriはクライアントごとに許可されたもの以外は受け付けません。stateは無くてもMUSTではありませんが、セキュリティ上はクライアントが付与すべきです。scopeも省略可能ですが、OIDCのIDトークンを発行するにはopenidが含まれている必要があります。ここでは簡略化のため、scopeが空なら強制的に”openid”とみなしています(通常はエラーにするか、デフォルトスコープを適用)。
加えてPKCE関連パラメータも確認しています。PKCEを使う場合、code_challengeが送られてくるはずなので受け取り、code_challenge_methodが明示されていなければデフォルトでplainとみなします。S256(SHA256ベースのチャレンジ)以外は今回は対応しないため、それ以外が来たらエラーにしています(本来はplainも許容はしますがセキュリティ上非推奨です)。
-
認可コード生成と保存: 検証が通ったら、ランダムな認可コードを生成します。ここでは32バイトのランダム値をURL安全な文字列にエンコードしてコード値としています。次にAuthCodeData構造体に必要な情報を詰め、グローバルのauthCodesマップに保存します。このとき、紐づくクライアントIDやユーザーID、リダイレクトURI、PKCEのチャレンジとメソッド、OIDC用のnonce値、要求されたscope、そして有効期限(現在時刻+5分)を記録しています。認可コード自体はアクセストークンと違いJWTなどではなくランダムな文字列ですが、十分なランダム性があればそれ自体の形式はRFCでは規定されていません。現時点ではアクセストークンもランダム文字列で良いですが、セキュリティや一部要件に応じてJWTにするケースもあります。今回はアクセストークンはJWT化せず単なるランダム文字列とします。
-
クライアントへのリダイレクト: 最後に、元のリクエストで指定されたredirect_uriに対して302リダイレクトを実行します。リダイレクト先のURLにクエリパラメータとして今回発行したcodeを付加し、stateもあれば同様に付加します。これにより、ユーザーのブラウザはクライアントのサイトへ遷移し、クライアントはコードを受け取ることができます。
以上で認可エンドポイントの処理は完了です。なお、ここではユーザーの同意画面(scopeに対してユーザーが「このアプリに〜のアクセスを許可しますか?」と承認するステップ)を省略しています。本来は認可サーバーはログイン後にユーザーに対してアクセス要求内容を確認し許可/拒否を選ばせるUIを出し、許可された場合にのみコードを発行します。しかし今回はシンプルさを優先し、ユーザーがログインした時点で同意もされたものとみなしています。この点は実際のサービス実装時にはご留意ください。
トークンエンドポイント(/token)の実装
トークンエンドポイントは、クライアントが認可コードと引き換えにアクセストークン(およびOIDCではIDトークン)を取得するためのエンドポイントです。クライアントはバックエンドからこのエンドポイントに対してPOSTリクエストを送り、適切なパラメータと認証情報を提示する必要があります。サーバー側では以下を行います:
-
リクエストの検証: HTTPメソッドはPOST、Content-Typeはapplication/x-www-form-urlencodedであることを確認します(今回は簡略化してContent-Typeチェックは省略)。grant_typeパラメータはauthorization_codeでなければならず、それ以外はエラーとします。
-
クライアント認証の検証: クライアントがConfidential(機密)である場合、クライアント認証(Client Authentication)が必要です。一般的にはHTTP Basic AuthヘッダーにクライアントIDとシークレットを載せるか、POSTボディにclient_idとclient_secretを含めます。本実装では簡単のため後者で処理し、client_idが登録済みであること、対応するclient_secretが一致することを確認します。不一致なら401エラーを返します。なお、Publicクライアント(ネイティブアプリ等シークレットを安全に保持できないクライアント)の場合はクライアント認証はスキップされますが、その場合でもPKCE等でセキュリティを補強します。
-
認可コードの検証: クライアントから提示されたcodeが、先ほど認可エンドポイントで発行したものと一致するか検証します。具体的には、サーバー側に保存してあるauthCodesマップから該当するコードを探し、存在しなかったり既に使用済みであればエラーです。また、コードが紐づくクライアントIDと、リクエストしているクライアント(認証済みのID)が一致することを確認します。一致しない場合、他のクライアントが盗んだコードを使おうとしている可能性があるため拒否します。加えて、有効期限が切れていないかも確認します(発行から時間が経ち過ぎていれば無効とします)。
-
PKCEの検証: 認可コードに対応するcode_challengeが保存されていれば、クライアントからcode_verifierが送られてきているはずなので照合します。code_challenge_methodがS256の場合、code_verifierに対してSHA256ハッシュを計算し、それをBase64URLエンコードした値が元のcode_challengeと一致すればOKです。一致しなければ不正なコード使用(コードを奪った攻撃者が正しいベリファイアを知らずにリクエストした)と判断しエラーにします。メソッドがplainの場合はcode_verifierそのものがcode_challenge値と一致するかを確認します。なお、認可時にPKCEが使われなかったコードに対してcode_verifierが送られてきた場合やその逆もエラーとします。
-
アクセストークン発行: 上記検証を全て通過したら、そのクライアント・ユーザーに対するアクセストークンを生成します。アクセストークンには一般にランダムな文字列やJWTが使われますが、今回の実装では単純にランダム文字列を用います(長さは適宜、例では32バイトをbase64エンコード)。あわせて有効期限(例:3600秒=1時間など)を設定し、トークンの種類token_typeはBearerとします。Bearerトークンは持ち出せば誰でも使えてしまうため、HTTPS通信が必須です(本記事のコードはデモのためHTTPで動かしますが、実際にはTLSを使用してください)。必要であればリフレッシュトークンも発行できますが、本記事では割愛します。
-
IDトークン発行: リクエストのscopeにopenidが含まれている(=OIDCリクエストである)場合、IDトークンを生成します。IDトークンはJWT形式で、認可サーバーが署名することで改ざん検知可能になっています。今回署名にはHMAC-SHA256(対称鍵)を使います。JWTのクレーム(中身)としては、OIDC Core仕様に基づき以下を含めます:
-
iss (Issuer): トークン発行者識別子。一般的に認可サーバーのURLを入れます(例: http://localhost:8080)。
-
sub (Subject): エンドユーザーを示す一意の識別子。ここでは簡単のためユーザーID文字列(user1)をそのまま使用します。本来はユーザーごとに一意なIDを発行することが望ましいです。
-
aud (Audience): トークンの対象となるクライアントIDを示します(このIDトークンを受け取るクライアント)。
-
exp (Expiration Time): トークンの有効期限(Unix時間)。アクセストークン同様短めの期限を設定します。
-
iat (Issued At): トークン発行時刻(Unix時間)。
-
nonce: 認可リクエストでクライアントから送信されたnonce値。認可サーバーは受け取ったnonceをそのままIDトークンに含めます。これによりクライアントは後でこのIDトークンが自分のリクエストに対応するものか検証できます(リプレイ攻撃対策)。
-
その他、本来OIDCでは認証時刻auth_timeやacrなど追加クレームも扱えますが本実装では省略します。
JWTをライブラリで生成し、HMACの場合は事前共有した秘密鍵で署名します。OIDCの実用環境ではRSAやECDSAの公開鍵暗号で署名し、公開鍵をクライアントに提供しておいてクライアント側で署名検証するケースが多いですが、デモでは簡易にHMACとしています。
-
-
レスポンス生成: アクセストークン(およびIDトークン)が用意できたら、JSONオブジェクトを生成してHTTPレスポンスのボディに書き出します。成功時のステータスコードは200 OKです。エラー時には401 Unauthorizedや400 Bad RequestでerrorコードをJSONで返すのがRFCで定められていますが、本実装では簡略のためメッセージ付きのステータスエラーを返すみに留めます。
それでは上記ロジックを実装した/tokenハンドラーを示します。
func tokenHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
grantType := r.FormValue("grant_type")
clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")
code := r.FormValue("code")
redirectURI := r.FormValue("redirect_uri")
codeVerifier := r.FormValue("code_verifier")
if grantType != "authorization_code" {
http.Error(w, "unsupported grant_type", http.StatusBadRequest)
return
}
if clientID != demoClient.ID || clientSecret != demoClient.Secret {
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
return
}
codeData, ok := authCodes[code]
if !ok {
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
delete(authCodes, code)
if codeData.ClientID != clientID || codeData.RedirectURI != redirectURI {
http.Error(w, "code validation failed", http.StatusBadRequest)
return
}
if time.Now().After(codeData.Expiry) {
http.Error(w, "code expired", http.StatusBadRequest)
return
}
if codeData.CodeChallenge != "" {
if codeVerifier == "" {
http.Error(w, "code_verifier required", http.StatusBadRequest)
return
}
if codeData.CodeChallengeMethod == "S256" {
sum := sha256.Sum256([]byte(codeVerifier))
expected := base64.RawURLEncoding.EncodeToString(sum[:])
if expected != codeData.CodeChallenge {
http.Error(w, "PKCE verification failed", http.StatusBadRequest)
return
}
} else if codeData.CodeChallengeMethod == "plain" {
if codeVerifier != codeData.CodeChallenge {
http.Error(w, "PKCE verification failed", http.StatusBadRequest)
return
}
}
} else {
if codeVerifier != "" {
http.Error(w, "PKCE not in use", http.StatusBadRequest)
return
}
}
accessToken := generateRandomString(32)
tokenType := "Bearer"
expiresIn := 3600
idToken := ""
if strings.Contains(codeData.Scope, "openid") {
now := time.Now().Unix()
claims := jwt.MapClaims{
"iss": "http://localhost:8080",
"sub": codeData.UserID,
"aud": codeData.ClientID,
"exp": now + 300,
"iat": now,
}
if codeData.Nonce != "" {
claims["nonce"] = codeData.Nonce
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(jwtSigningKey)
if err != nil {
log.Printf("Failed to sign ID token: %v", err)
http.Error(w, "server_error", http.StatusInternalServerError)
return
}
idToken = signed
}
resp := map[string]interface{}{
"access_token": accessToken,
"token_type": tokenType,
"expires_in": expiresIn,
}
if idToken != "" {
resp["id_token"] = idToken
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
log.Printf("Issued access_token (len=%d)%s for client=%s user=%s",
len(accessToken),
func() string { if idToken != "" { return " and ID token" } else { return "" } }(),
clientID, codeData.UserID)
}
tokenHandlerの内容を順に追って確認します。
- (a) grant_typeは認可コードフローの場合必ず”authorization_code”なので、それ以外ならエラーとします。
- (b) クライアント認証として、送られてきたclient_idとclient_secretをサーバー側の登録情報と照合します。一致しなければ認証失敗であり、401 Unauthorizedを返します。今回は簡略化のためAuthorizationヘッダーではなくフォームパラメータでシークレットを送る方式にしています。なお認可コードフローではクライアントシークレットを保持できる環境で動作するWebアプリ等(Confidentialクライアント)を想定しています。モバイルアプリ等シークレットを持たないPublicクライアントでは、この部分の認証は行わずに後述のPKCEで補完する運用が主流です。
- (c) 認可コードの検証では、まずauthCodesマップから該当コードを検索します。存在しなければ無効なコードとしてエラーを返します。また、一度使われたコードが再利用されないよう、取り出した時点でマップから削除しています(これにより同じコードで2回以上トークンを発行することを防止)。次に、コードに紐づいて保存しておいたクライアントIDと、現在トークンリクエストを行っているクライアント(認証ずみclient_id)が一致するか確認します。同様に、redirect_uriもチェックし、コード発行時と同じURIをクライアントが送ってきていることを確認します。もしここが一致しなければ、第三者が盗んだコードを別のリダイレクトURIで使おうとする攻撃などが考えられるため拒否します(なお、RFC上はクライアントが事前登録した一つのリダイレクトURIしか使わない場合、トークンリクエストにredirect_uriを含めるのはOPTIONALですが、セキュリティを高めるため実装上は検証することが推奨されています)。最後に、有効期限をチェックし、保存時に設定したExpiry時刻を過ぎていればエラーとします。
- (d) PKCEの検証では、コード発行時にcode_challengeが設定されていた場合のみ実施します。保存していたメソッド(S256/plain)に応じて、クライアントから送られてきたcode_verifierを検証します。S256ならSHA256ハッシュ→Base64URLエンコード値を比較し、plainならそのまま比較します。値が一致しなければエラーを返します。もしcode_challengeが保存されていないコード(= PKCE未使用のコード)なのにクライアントがcode_verifierを送ってきた場合も不正とみなしエラーにします。
- (e) ここまで検証をパスしたら、アクセストークンを新規発行します。generateRandomString(32)で32バイト長のランダム文字列を生成し、これをアクセストークンの値とします(例としてbase64エンコードなので約43文字の英数字になります)。token_typeはBearer固定、expires_inは3600秒(1時間)としました。ではアクセストークンの形式はRFCで定められていないものの一般的にJWTが使われると説明されていますが、本実装ではシンプルさを優先しランダム文字列を採用しています。
- (f) 次にOIDC用にIDトークンを作成します。認可時に保存したscope文字列にopenidが含まれている場合のみ処理します。JWTのClaimsを作成し、前述したiss, sub, aud, exp, iatおよびnonceを設定します。subにはログイン中のユーザーID(ここではuser1)を入れていますが、実際にはプライバシーを考慮してユーザーを間接的に表す固有ID(ペアごとに一意な識別子)を発行することが多い点に注意してください。本実装では割愛します。クレームを設定したら、JWTライブラリでHS256アルゴリズムの署名を行います。jwtSigningKeyとしてあらかじめ設定したバイト列を鍵に使い、SignedStringメソッドで署名済み文字列を取得します。エラーが起きた場合は500エラーを返します。正常に署名できればそれがIDトークンとなります。
-
(g) 最後にJSONのレスポンスを生成します。標準ライブラリの
json.NewEncoder().Encode()
を使ってマップをシリアライズしています。含めるフィールドはaccess_token, token_type, expires_inで、さらにIDトークンが発行されていればid_tokenを追加しています。レスポンスヘッダーのContent-Typeはapplication/jsonに設定しておきます。ログにもトークン発行の旨を出力しています。
以上でトークンエンドポイントの実装も完了です。認可コードが正しく検証されると、クライアントは無事にアクセストークンとIDトークンを入手できます。クライアント側では、IDトークンの署名検証とクレーム内容の検証を行う必要があります。具体的には、iss(発行者)が想定通りか、aud(クライアントID)が自分のIDと一致するか、expが有効か、そしてnonceが最初に送ったものと同じか、などを確認します。今回IDトークンをHMACで署名したため、クライアントはその検証に同じ秘密鍵が必要ですが、本来OIDCでは公開鍵方式が一般的です。また、アクセストークンはリソースサーバーにて検証されますが、その方法は別途考慮が必要です。
動作確認
実装がひととおり揃いましたので、実際にこの認可サーバーを動かしてフローを試してみましょう。main関数でハンドラーを登録し、サーバーを起動します。
func main() {
http.HandleFunc("/authorize", authorizeHandler)
http.HandleFunc("/token", tokenHandler)
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "Callback received code = %s
state = %s", code, state)
})
log.Println("OAuth2/OIDC Authorization Server is running at :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
上記では、便宜上クライアントのリダイレクト先(/callback)もサーバー内に実装しています。本来このパスはクライアント側アプリケーションのエンドポイントですが、デモのため認可サーバー自身で受けてコードを画面に表示するようにしています。
サーバーを起動したら、以下の手順で認可コードフロー+OIDCの一連の流れを確認できます。
- 認可リクエストを送る: ブラウザで次のURLにアクセスします(クエリパラメータが長いので注意してください)。
http://localhost:8080/authorize?response_type=code
&client_id=client1
&redirect_uri=http://localhost:8080/callback
&scope=openid
&state=xyz123
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
- ※改行は読みやすさのため。実際は1行で入力してください。ここではPKCEの例として、code_challenge_method=S256に対するチャレンジ値をE9Mel…-cMとしています。この値はコードベリファイア
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
をSHA256したものです。 -
ユーザー認証: 上記URLにアクセスすると認可サーバーは未ログインのため、自動的に
/login
ページにリダイレクトされます。ブラウザにシンプルなログインフォームが表示されるので、ユーザー名「user1」とパスワード「pass1」を入力してログインしてください。認証情報が正しければ、サーバーはセッションを作成し、元の/authorize
処理に戻ります。 -
認可コード受領: 認可が成功すると、ブラウザはクライアントのredirect_uri(今回用意した
/callback
)にリダイレクトされます。そのページ上に、受け取った認可コードとstateの値が表示されます。例えば以下のように表示されます。
Callback received code = 5PsFz4pN7Yh7hND9X3C4yJ0sb22XD3lkA4y8hQa3Jhg
state = xyz123
- ここで表示された認可コード(上記だと5PsFz…Jhgの部分)をコピーしておきます。また、stateは送ったものと一致していることを確認してください(今回はxyz123)。
- トークンリクエスト: 次に、クライアントになったつもりで認可コードをアクセストークンに交換します。ターミナルから以下のようにcurlコマンドを実行します(適宜コードの値を書き換えてください)。
curl -X POST http://localhost:8080/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&client_id=client1&client_secret=secret"\
-d "code=5PsFz4pN7Yh7hND9X3C4yJ0sb22XD3lkA4y8hQa3Jhg" \
-d "redirect_uri=http://localhost:8080/callback" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
- 正常に処理されれば、以下のようなJSONレスポンスが得られます。
{
"access_token": "k9yN3X5...(省略)...ZNg",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(以下省略)..."
}
- このようにアクセストークンとIDトークンが発行されました。access_tokenはランダムな文字列(ここでは一部省略)、token_typeはBearer、expires_inは3600秒です。id_tokenが長い文字列になっていますが、これは3つのドット区切り部分からなるJWTです。試しにIDトークンをデコードしてみると、中身は以下のようなJSONになります(ヘッダーと署名部分は省略)。
{
"iss": "http://localhost:8080",
"sub": "user1",
"aud": "client1",
"exp": 1692345678,
"iat": 1692345378,
"nonce": "n-0S6_WzA2Mj"
}
- subがユーザーを表す文字列user1になっており、audはクライアントID(client1)、nonceもリクエスト時に送った値と一致しています。issは認可サーバー自身(ここではローカルホストURL)、expとiatは発行時刻と有効期限を示すタイムスタンプです。HMAC署名の検証にはサーバーと同じ鍵が必要ですが、今回は割愛します。クライアントはこのIDトークンの内容を検証することで、認可サーバーが確かに自分(client1)のためにuser1というユーザーを認証したことを信頼できます。
- リソースアクセス(省略): アクセストークンの利用は本記事では実装していませんが、access_tokenを持っていれば、クライアントはそれをHTTP APIのAuthorizationヘッダーなどに載せて保護リソースにアクセスできます。リソースサーバー側は、そのアクセストークンが認可サーバーにより発行されたものであり有効(期限内)であることを検証し、対応するユーザーのデータにアクセスを許可します。
最後に、もう一度認可エンドポイントから試してみましょう。ブラウザで先ほどと同じ/authorize?…のURLにアクセスしてみてください。今度は既にログインセッションが有効なため、ログインページを経由せず即座に(同意画面も無しで)クライアントのredirect_uriにリダイレクトし、新しい認可コードが発行されるはずです。stateも含め正しく往復できることを確認してください。
まとめ
以上、Go言語による簡易なOAuth2.0認可コードフロー+OIDC対応の認可サーバー実装を行いました。認可エンドポイントでのパラメータ検証やコード発行、トークンエンドポイントでのコード検証・トークン発行という認可サーバーの基本的な役割をコードで追体験することで、OAuth/OIDCの各ステップで何が行われているか理解が深まったかと思います。特に、stateやPKCE、nonceといった一見難解なパラメータも、それぞれCSRF防止、認可コード盗用防止、リプレイ攻撃防止といった明確な目的があることがお分かりいただけたでしょう。
本記事の実装は学習用のミニマムなものであり、実運用するにはエラーハンドリングやセキュリティの強化や機能拡張が必要です。このコードをベースに色々と改良・拡張したり、実際のOAuthプロバイダ(Google等)の挙動と見比べたりしてみてください。お疲れ様でした。
↓ 遊園地のたとえで読み解く記事はこちら(実装後に読むと整理できるかもしれません)
Views: 0