Skip to content

KeycloakとGo言語でAuthorization Code Flowを学ぶ 2

はじめに

前回の記事では Go 言語でコードを書きながら Authorization Code Flow について学びました。
しかし、state や nonce、PKCE について考慮しておらず、そのままのコードをアプリケーションで使用することはできません。

この記事では、アクセストークン、ID トークンのセキュリティを考慮しながら対策をしてみたいと思います。

成果物

https://github.com/kntks/blog-code/2024/03/keycloak-authorization-code-flow-2

目標

  1. Keycloak で OpenID Connect の設定をする
  2. Go 言語でサーバーを起動する
  3. ブラウザで http://localhost:8080/login にアクセスすると Keycloak のログイン画面にリダイレクトされる
  4. ログイン完了後、http://localhost:8080/home にリダイレクトされる

環境

バージョン
MacVentura 13.2.1
Keycloak23.0.6
Docker25.0.3
Docker Composev2.24.5
Terraform1.7.4
Go1.22.0

Terraform と Docker でセットアップ

Keycloak を docker compose で起動します。

compose.yaml
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0.6
ports:
- target: 8080
published: 8080
protocol: tcp
mode: host
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
command: ["start-dev"]

Terraform を使用するために Keycloak側のセットアップ が必要です。

terraform apply で Keycloak の設定を行います。

Terminal window
cd terraform
terraform init
terraform plan
terraform apply -auto-approve

stete

OpenID Connect の Authorization Code Flow では、認証リクエスト時に state パラメータを設定できます。state パラメータは、主に Cross-Site Request Forgery (CSRF) 攻撃への対策として使用されます。

state パラメータの利用手順は以下の通りです。

  1. クライアントアプリケーションは、認証リクエストを送信する前に、ランダムで予測が困難な値(通常は乱数による文字列)を state パラメータとして生成する。
  2. 生成した state パラメータは、認証リクエストにそのまま含められ、OpenID Provider(Keycloak)に送信される。
  3. OpenID Provider(Keycloak)は、認証後のリダイレクトレスポンスに、その state パラメータを含めて返す。
  4. クライアントアプリケーションは、リダイレクトレスポンスに含まれる state パラメータと、元々生成して保存していた値を比較する。値が一致すれば、リクエストの完全性が確認でき、安全に Authorization Code を受け取ることができる。
  5. 値が一致しない場合は、不正な認証リクエストである可能性があるため、クライアントは Authorization Code を受け取らず、処理を中止する。

このように state パラメータは、本来のクライアントが送信した認証リクエストに対するレスポンスであることを保証するために使用されます。攻撃者による偽の認証リクエストをブロックし、CSRF やリプレイ攻撃のリスクを軽減できます。

Authorization Code Flow を利用した認証において、state パラメータは Recommended です。

state
RECOMMENDED. リクエストとコールバックの間で維持されるランダムな値. 一般的に Cross-Site Request Forgery (CSRF, XSRF) 対策の目的で利用される, ブラウザ Cookie と紐づく暗号論的にセキュアな値を取る.

引用:3.1.2.1. Authentication Request - OpenID Connect 1.0 specification 日本語訳

しかし、RFC6749 では、“state を利用すべき” と書かれているので設定します。

クライアントは認可要求の発行時, この値を認可サーバーへ伝搬するために state リクエストパラメーターを利用すべきである (SHOULD).

引用:10.12. クロスサイトリクエストフォージェリ - RFC6749

state パラメータによる効果

どのような CSRF 対策が行われているかわからなかったため、調べてみると RFC6819 に詳細が記載されていました。

state パラメータを設定しなかった場合や、検証しなかった場合、攻撃者が被害者に対して攻撃者のアクセストークンを使用させることができるそうです。

The “state” parameter is used to link client requests and prevent CSRF attacks, for example, attacks against the redirect URI. An attacker could inject their own authorization “code” or access token, which can result in the client using an access token associated with the attacker’s protected resources rather than the victim’s (e.g., save the victim’s bank account information to a protected resource controlled by the attacker).

(訳)「state」パラメータは、クライアントリクエストをリンクし、CSRF攻撃(例えば、 リダイレクトURIに対する攻撃)を防ぐために使用される。攻撃者は自分自身の認可「コード」やアクセストークンを注入することができ、その結果クライアントは被害者のものではなく、攻撃者の保護されたリソースに関連付けられたアクセストークンを使用することになります(例えば、被害者の銀行口座情報を攻撃者が制御する保護されたリソースに保存する)。

引用:5.3.5. Link the “state” Parameter to User Agent Session - RFC 6819 OAuth 2.0 Threat Model and Security Considerations

以下のページでも紹介されています。

state はどこに保存するのか

state を生成するタイミングは、OpenID Provider(Keycloak)にリダイレクトする前です。そこではまだ Redis などのデータベースにセッションデータを持っていないため、Cookie に保存します。

You send a random value when starting an authentication request and validate the received value when processing the response. You store something on the client application side (in cookies, session, or localstorage) that allows you to perform the validation.

認証リクエストを開始するときにランダムな値を送信し、レスポンスを処理するときに受信した値を検証します。クライアント・アプリケーション側で(クッキー、セッション、あるいはローカルストレージに)、検証を実行するための何かを保存します。

Prevent Attacks and Redirect Users with OAuth 2.0 State Parameters - Auth0 Documentation

state を設定してみる

リダイレクト URL に state を雑につけて、ログイン完了後のリクエストURLを確認してみます。

コード全体は blog-code/2024/03/keycloak-authorization-code-flow-2/state にあります。

state/main.go
func handleLogin(w http.ResponseWriter, r *http.Request) {
v := url.Values{}
v.Add("scope", "openid")
v.Add("response_type", "code")
v.Add("client_id", clientID)
v.Add("redirect_uri", "http://localhost:8081/callback")
v.Add("state", "これはstateです")
redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s/protocol/openid-connect/auth", keycloakURL))
...
}
func handleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
for k, v := range r.URL.Query() {
fmt.Println(k, "=", v)
}
tokenURL := fmt.Sprintf("%s/protocol/openid-connect/token", keycloakURL)
...
}

API サーバー起動後、ブラウザで http://localhost:8081/login にアクセスします。Keycloak の画面が出てくると思うので、Username、Password どちらも myuser と入力します。その結果が以下です。

Terminal window
$ go run state/*.go
Server is running on :8081
code = [284f91af-27c5-4489-a82b-49d739a50973.d9a4744c-db9b-4d7a-81d3-dbd465981b5f.42d6d9aa-f232-4bab-8a71-aeaffdc4aff3]
state = [これはstateです]
session_state = [d9a4744c-db9b-4d7a-81d3-dbd465981b5f]
iss = [http://localhost:8080/realms/myrealm]

Authorization Codeと一緒に state の値も確認でき、state = [これはstateです] が出力されました。

後ほど、より安全なランダムな文字列を生成して設定するようにコードを改善します。

nonce

OpenID Connect の Authorization Code Flow では、認証リクエスト時に nonce パラメータを設定できます。nonce パラメータには、攻撃者が推測することができないようなランダムな文字列かつ1度きりしか使用できないように設定する必要があります。このパラメータは、主にリプレイアタック、Authorization Code Injection 攻撃、CSRF を防止する対策として使用されます。

The nonce value is one-time use and created by the client.

4.5.3.2. Nonce - OAuth 2.0 Security Best Current Practice

nonce パラメータの利用手順は以下の通りです。

  1. クライアントアプリケーションは、認証リクエストを送信する前に、ランダムで予測が困難な値(通常は乱数による文字列)を nonce パラメータとして生成する。
  2. 生成した nonce パラメータは、認証リクエストにそのまま含められ、OpenID Provider(Keycloak)に送信される。
  3. OpenID Provider(Keycloak)は、認証が成功した場合、nonce パラメータの値を ID トークンの nonce Claim に設定する。
  4. クライアントアプリケーションは、Token Endpoint から取得した ID トークンの nonce Claim と、元々生成して保存していた nonce パラメータの値を比較する。値が一致すれば、この ID トークンが本来の認証リクエストから発行されたものであることが保証される。
  5. 値が一致しない場合は、ID トークンが古い認証フローから発行された可能性があるため、クライアントはその ID トークンを拒否する

このように nonce パラメータは、発行された ID トークンが最新の認証フローから発行されたものであることを保証するために使用されます。攻撃者による古い認証フローの再利用(リプレイアタック)をブロックできます。

Authorization Code Flow を利用した認証において、nonce パラメータは Optional です。

nonce
OPTIONAL. Client セッションと ID Token を紐づける文字列であり, リプレイアタック対策に用いられる. この値は Authentication Request で指定され, そのままの値で ID Token に含まれる. nonce 値には, 推測不可能なように十分なエントロピーを持たせること (MUST).

引用:3.1.2.1. Authentication Request - OpenID Connect 1.0 specification 日本語訳

参考:

nonce パラメータによる効果

Authorization Cade Injection 攻撃

被害者が認証を行い、クライアントアプリケーションがリダイレクトされる際に、redirect_uri のクエリパラメータに Authorization Code が含まれています。この時、攻撃者が何らかの手段でその被害者の Authorization Code を取得した場合、攻撃者が自身の認証を行うタイミングで、その取得した Authorization Code を使って認証を試みることで、Authorization Code Injection 攻撃が発生します。

この攻撃が成功すると、攻撃者は被害者の Authorization Code を不正に入手し、それを使ってアクセストークンを取得できてしまいます。これにより、攻撃者は被害者に代わって、リソースへのアクセス権を不正に手に入れられてしまいます。

しかし、ID トークンの Claims に nonce が含まれている場合、攻撃者が Authorization Code Injection 攻撃を行っても、ID トークンの nonce Claim と、元々生成して保存していた nonce パラメータの値を比較することでその ID トークンが本来の認証リクエストから発行されたものであることが保証されます。

リプレイアタック

ID トークンリプレイアタックでは、被害者が認証を行い ID トークンを取得します。その ID トークンを攻撃者が取得し、再利用することでリプレイアタックが発生します。
Authorization Code リプレイアタックでは、ブラウザの履歴などから Authorization Code を取得し、再利用することでリプレイアタックが発生します。

nonce は1回限りの使い捨てのランダムな値なので、どちらの攻撃も同じ nonce が使われた場合は不正な要求と判断できます。

参考:

nonce はどこに保存するのか

nonce は HttpOnly 属性付きのセッションクッキーに格納します。

nonce パラメータ値は, セッション毎の状態を含むとともに, 攻撃者によって推測されないようにする必要がある. Web Server Client がこの要件を満足する手法の一つは, HttpOnly 属性付きのセッションクッキーとして暗号論的乱数値を格納し, その値の暗号学的ハッシュを nonce パラメータとして使用することである. このケースでは, 第三者による ID Token リプレイを検出するために, 返却される ID Token 内の nonce と セッションクッキーのハッシュを比較する. JavaScript Client に対して適用可能な関連手法として, HTML5 ローカルストレージに暗号論的乱数値を格納し, その値の暗号学的ハッシュを用いる方法がある.

引用:15.5.2. Nonce Implementation Notes

nonce を設定してみる

state パラメータのとき同様、リクエストパラメータに nonce を雑に設定します。

コード全体は blog-code/2024/03/keycloak-authorization-code-flow-2/nonce にあります。

nonce/main.go
func handleLogin(w http.ResponseWriter, r *http.Request) {
v := url.Values{}
v.Add("scope", "openid")
v.Add("response_type", "code")
v.Add("client_id", clientID)
v.Add("redirect_uri", "http://localhost:8081/callback")
v.Add("state", "これはstateです")
v.Add("nonce", "これはnonceです")
redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s/protocol/openid-connect/auth", keycloakURL))
...
}

nonce は ID トークンの中に入っているはずなので、IDTokenClamesNonce を追加します。

nonce/claims.go
type IDTokenClames struct {
AuthorizedParty string `json:"azp,omitempty"`
AccessTokenHash string `json:"at_hash,omitempty"`
Nonce string `json:"nonce"`
jwt.RegisteredClaims
}

確認用の print 文を入れます。

nonce/main.go
func handleCallback(w http.ResponseWriter, r *http.Request) {
...
fmt.Println("nonce =", idTokenClames.Nonce)
}

API サーバー起動後、ブラウザで http://localhost:8081/login にアクセスします。Keycloak の画面が出てくると思うので、Username、Password どちらも myuser と入力します。その結果が以下です。

Terminal window
$ go run nonce/*.go
...
nonce = これはnonceです

ID トークンの検証後に nonceの値も確認でき、nonce = これはnonceです が出力されました。

後ほど、より安全なランダムな文字列を生成して設定するようにコードを改善します。

Proof Key for Code Exchange (PKCE)

Proof Key for Code Exchange とは、OAuth 2.0 の Authorization Code Flow において、パブリッククライアントが Autorization Code Intercept 攻撃から保護するための仕組みです。Proof Key for Code Exchange は、RFC 7636 で定義されています。

PKCE で使用するパラメータは以下の通りです。

  • code_challenge_method
  • code_challenge
  • code_verifier

code_challenge_method は、code_challenge の生成方法を指定します。この値をリクエストに含めない場合、デフォルトは plain です。指定できる値は plainS256 です。 今回は、S256を使用します。

code_challenge_method が S256 である場合、code_challenge は、code_verifier をハッシュ化したものです。

code_verifier ですが、4.1. Client Creates a Code Verifier によると以下の条件を満たす必要があります。

  • 高エントロピーのランダムな文字列
  • 予約されていない文字(Unreserved Character)(A-Z、a-z、0-9、”-”、”.”、”_”、”~“)を使用
  • 最小長は43文字、最大長は128文字

PKCE の code_verifier の手順は以下の通りです。

  1. クライアントアプリケーションは、認証リクエストを送信する前に、code_verifier となる文字列を生成する。
  2. code_verifier から SHA-256 ハッシュを計算し、Base64 URL safe エンコーディングを行った値を code_challenge とする。
  3. code_challenge と code_challenge_method(ここではS256)を認証リクエストのクエリパラメータに含めて、OpenID Provider(Keycloak)にリダイレクトする。
  4. 認証完了後、先ほど生成した code_verifier をトークンリクエストの POST パラメータに含める。
  5. OpenID Provider(Keycloak)は、code_verifier から SHA-256 ハッシュを計算し、Base64 URL safe エンコーディングを行った値と、認証リクエストの code_challenge を比較する。一致すれば、PKCE が正常に検証されたことになる。

state、nonce を使用した検証では、Go 言語の APIサーバーで実施していましたが、PKCE は code_verifier を使用した検証は、Authorization Server(Keycloak)で実施します。

参考:

PKCE の効果

PKCE は、Autorization Code Intercept 攻撃から保護するための仕組みです。2.1.1. Authorization Code Grant によると、他にも Authorization Code injection 攻撃や Authorization Code の誤用を防ぐことにも役立ちます。とくにパブリッククライアントの場合、PKCE は必須です。コンフィデンシャルクライアントの場合は、推奨であり、さらに OpenID Connect を使用している場合は代わりに nonce を使用しても良いとされています。(2.1.1. Authorization Code Grant が示す Authorization Code injection 攻撃と RFC 7636 が示す Autorization Code Intercept 攻撃は同じ攻撃を示しているかもしれない)

code_verifier はどこに保存するのか

今回はコンフィデンシャルクライアントのため、code_verifier は Cookie に保存します。しかし、パブリッククライアントの場合、Auth0 のドキュメントによると、Web WorkerJavaScript closure などの手法を使用して、code_verifier をメモリに保存することが推奨されています。

auth0-spa-js の実装を見ると、Web Worker を使用していることがわかります。

if (
typeof window !== 'undefined' &&
window.Worker &&
this.options.useRefreshTokens &&
cacheLocation === CACHE_LOCATION_MEMORY
) {
if (this.options.workerUrl) {
this.worker = new Worker(this.options.workerUrl);
} else {
this.worker = new TokenWorker();
}
}

src/Auth0Client.ts

PKCE を設定してみる

Keycloak の PKCE を有効にする

Clients ページから Advanced タブをクリックします。 clients-advanced-tab

そのページを下にスクロールすると Proof Key for Code Exchange Code Challenge Method という設定項目があるので、S256 に設定します。

clients-advanced-setting-pkce

code_verifier を生成するサンプルコード

func generateCodeVerifier() (string, error) {
// ランダムなバイト列を生成
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
// バイト列をBase64 URLエンコード
codeVerifier := base64.RawURLEncoding.EncodeToString(randomBytes)
return codeVerifier, nil
}
func generateCodeChallenge(codeVerifier string) string {
// code_verifierのSHA-256ハッシュ値を計算
hash := sha256.Sum256([]byte(codeVerifier))
// ハッシュ値をBase64 URLエンコード
return base64.RawURLEncoding.EncodeToString(hash[:])
}

Go Playground

PKCE の設定する

コード全体は blog-code/2024/03/keycloak-authorization-code-flow-2/pkceにあります。

handleLogin 関数に code_verifier を生成し、code_challenge を生成する処理を追加します。

pkce/main.go
func handleLogin(w http.ResponseWriter, r *http.Request) {
codeVerifier, err := generateCodeVerifier()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
v := url.Values{}
v.Add("scope", "openid")
v.Add("response_type", "code")
v.Add("client_id", clientID)
v.Add("redirect_uri", "http://localhost:8081/callback")
v.Add("state", "これはstateです")
v.Add("nonce", "これはnonceです")
v.Add("code_challenge", generateCodeChallenge(codeVerifier))
v.Add("code_challenge_method", "S256")
http.SetCookie(w, &http.Cookie{
Name: "verifier",
Value: codeVerifier,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
...
}
pkce/main.go
func handleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
// Cookieからcode_verifierを取得
codeVerifier, err := r.Cookie("verifier")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tokenURL := fmt.Sprintf("%s/protocol/openid-connect/token", keycloakURL)
v := url.Values{}
v.Add("grant_type", "authorization_code")
v.Add("code", code)
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("redirect_uri", "http://localhost:8081/callback")
v.Add("code_verifier", codeVerifier.Value)

API サーバー起動後、ブラウザで http://localhost:8081/login にアクセスします。Keycloak の画面が出てくると思うので、Username、Password どちらも myuser と入力します。その結果が以下です。

Terminal window
$ go run pkce/*.go
{
"access_token": "eyJ..._Yg",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJ...bSs",
"token_type": "Bearer",
"id_token": "eyJ...4H3g",
"not-before-policy": 0,
"session_state": "b85bc0c5-6194-40c8-9658-d37fe81a856a",
"scope": "openid email profile"
}
nonce = これはnonceです

無事に access_token、refresh_token、id_token を取得できました。ちなみに handleCallback 関数に code_verifier を post パラメータに含めなかった場合も確認してみましたが、そのときトークンは発行されませんでした。

コードを改善する

state、nonce を生成するサンプルコード

ランダムな文字列を生成する関数を定義します。

package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
)
func randString(nByte int) (string, error) {
b := make([]byte, nByte)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func main() {
r, err := randString(128)
if err != nil {
log.Println(err)
return
}
fmt.Println(r)
}

Go Playground を実行してみると十分に長い(と思われる)文字列を取得できます。

Terminal window
NTKKIJzYpA8MU47VJUOMXrIICG01z8_8VJLXyqB8swXIr4cTI9jh8AJKODwwHT_SLmvCmXcNEywnGSfCx3GxVTXmatb_VnLttGT8Q49T0LUe2KTKrdmJLLJuTnYl-3WOEcP9WCZDfxajZOPauI-kaHvuF49Ih8s-2WSPwL7eUwE

state、nonce を検証するコードを追加する

まずは、statenonce を生成する関数を追加します。そのあと、handleLogin 関数で statenonce に設定していた固定値をランダムな文字列に変更します。

pkce/main.go
func generateCodeVerifier() (string, error) {
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
codeVerifier := base64.RawURLEncoding.EncodeToString(randomBytes)
return codeVerifier, nil
}
func generateRandomString(nByte int) (string, error) {
b := make([]byte, nByte)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
state, err := generateRandomString(128)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
nonce, err := generateRandomString(128)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
codeVerifier, err := generateCodeVerifier()
codeVerifier, err := generateRandomString(64)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
v := url.Values{}
v.Add("scope", "openid")
v.Add("response_type", "code")
v.Add("client_id", clientID)
v.Add("redirect_uri", "http://localhost:8081/callback")
v.Add("state", "これはstateです")
v.Add("state", state)
v.Add("nonce", "これはnonceです")
v.Add("nonce", nonce)
v.Add("code_challenge", generateCodeChallenge(codeVerifier))
v.Add("code_challenge_method", "S256")
http.SetCookie(w, &http.Cookie{
Name: "state",
Value: state,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: "nonce",
Value: nonce,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: "verifier",
Value: codeVerifier,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
...
}
pkce/main.go
func handleCallback(w http.ResponseWriter, r *http.Request) {
// Cookieからstateを取得
state, err := r.Cookie("state")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// stateの検証
if state.Value != r.URL.Query().Get("state") {
log.Printf("cookie state: %s、callback url state: %s\n", state, r.URL.Query().Get("state"))
http.Error(w, "state is not equal", http.StatusBadRequest)
return
}
nonce, err := r.Cookie("nonce")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
...
// IDトークンのnonceを検証する
if c, ok := token.Claims.(IDTokenClames); ok {
if c.Nonce != nonce.Value {
log.Println("nonce is not equal")
http.Error(w, "InternalServerError", http.StatusInternalServerError)
return
}
}
...
}

API サーバー起動後、ブラウザで http://localhost:8081/login にアクセスします。Keycloak の画面が出てくると思うので、Username、Password どちらも myuser と入力します。

Terminal window
$ go run sample/*.go
Server is running on :8081

認証完了後、Webブラウザで Cookie を確認すると、statenonceverifier が設定されていることが確認できます。 chrome-developer-tools-cookie

まとめ

OAuth、OIDC の Authorization Code Flow において、state、nonce、PKCE はセキュリティを向上させるために使用されます。これらのパラメータを使用することで、アプリケーションのセキュリティを向上させることができることを学びました。

さいごに

学習のために書いたコードなので、golang-jwt/jwt 以外のライブラリを使用せずに実装しています。実際の開発ではフレームワークと合わせて使うことが多いと思うので、また機会があればフレームワークとの組み合わせも試してみようとおもいます。

参考