Skip to content

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

はじめに

前々回の記事では Authorization Code Flow について調べながら、Go 言語で API サーバーを作成しました。前回では、state や nonce、PKCE について調べながら、セキュリティを考慮したコードを書いていきました。前回の実装は、https://github.com/kntks/blog-code/2024/03/keycloak-authorization-code-flow-2/sample にあります。

しかし、この API サーバーにはまだ問題があります。それは、アクセストークンの有効期限が切れた場合、再度ログイン画面にアクセスしないといけないことです。

この記事では、リフレッシュトークンを使うことで、アクセストークンを再取得する方法を学びます。

成果物

https://github.com/kntks/blog-code/2024/04/keycloak-authorization-code-flow-3

目標

  1. アクセストークンの有効期限が切れても、リフレッシュトークンを使ってアクセストークンを取得できるようにする
  2. リフレッシュトークンが切れた場合は、再度ログイン画面にリダイレクトさせる

環境

バージョン
MacVentura 13.2.1
Keycloak23.0.6
Docker26.0.0
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

リフレッシュトークンとは

リフレッシュトークンはアクセストークンを再発行するために使用されるクレデンシャルです。アクセストークン、ID トークンと一緒に発行され、主に、現在のアクセストークンが無効化されたり、アクセストークンの期限が切れた際に新しいアクセストークンを取得するために使用されます。

参考:1.5. リフレッシュトークン - RFC6749

リフレッシュトークンを使ってアクセストークンを取得するには、Token Endpoint にリクエストを送信します。 Keycloak の Token Endpoint は /realms/{realm-name}/protocol/openid-connect/token です。

前々回の記事ではアクセストークン、ID トークンを取得するためにリクエストに以下のパラメータを含めました。

  • grant_type
    • 値は “authorization_code”
  • code
    • Authorization Code Flow で取得したコード
  • redirect_uri
  • client_id
  • client_secret
  • code_verifier

参考:4.1.3. アクセストークンリクエスト - RFC6749

しかし、リフレッシュトークンを使ってアクセストークンを取得する場合は、以下のパラメータを含めます。

  • grant_type
    • 値は “refresh_token”
  • refresh_token
  • scope
    • 設定は任意
  • client_id
  • client_secret

参考:

有効期限を変更する

Keycloak では、Realm settings ページから SSO Session Idle の値を変更することで、リフレッシュトークンの有効期限を設定できます。
デフォルトの有効期限は 30 分です。 sso-session-idle

Keycloak では、Token Endpoint のレスポンスにリフレッシュトークンの有効期限(refresh_expires_in)が含まれるので、設定が反映されているか確認できます。
以下は SSO Session Idle を 20 分に設定した場合のレスポンスです。

{
"access_token": "eyJ...dFg",
"expires_in": 300,
"refresh_expires_in": 1200,
"refresh_token": "eyJ...jGo",
"token_type": "Bearer",
"id_token": "eyJ...uRg",
"not-before-policy": 0,
"session_state": "2fc2803d-517e-420e-8dda-3b63a190701f",
"scope": "openid email profile"
}

どこに保存するのか

リフレッシュトークンはアクセストークンを比べて期限が長いため、Cookie などのフロントエンド側のストレージに保存するよりも、サーバーサイドの永続ストレージに保存する方が安全です。

Refresh tokens need to be long-lived and revocable, so they need to be stored in persistent storage server-side.

訳)リフレッシュ・トークンは長寿命で取り消し可能である必要があるため、サーバー側の永続ストレージに格納する必要がある。

引用:Where to store JWT refresh tokens - StackExchange

しかし、SPA などのフロントエンドアプリケーションの場合、リフレッシュトークンをサーバーサイドに保存することは難しいため、Cookie などのフロントエンド側のストレージに保存することもあるそうです。

参考:Simple and Secure API Authentication for SPAs - medium

トークンをリフレッシュできるようにする

まずアクセストークン、リフレッシュトークンの状態の組み合わせを考えます。

アクセストークンリフレッシュトークンAPIの挙動
✅ 期限内✅ 期限内アクセスを許可
✅ 期限内❌ 期限切れアクセスを許可
❌ 期限切れ✅ 期限内新規にアクセストークンを発行する
❌ 期限切れ❌ 期限切れログイン画面にリダイレクトする

前回の記事で書いたサンプルコードをベースに、リフレッシュトークンを使ってアクセストークンを取得するコードを書いていきます。

修正を追加したコミット

sample/main.go
func handleCallback(w http.ResponseWriter, r *http.Request) {
...
var tokenResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
...
// リフレッシュトークンをCookieに保存
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokenResponse.RefreshToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// ログイン完了後のリダイレクト
http.Redirect(w, r, "/home", http.StatusSeeOther)
}

エラーがあった場合は、認可に失敗したとしてログイン画面にリダイレクトします。

sample/main.go
func handleHome(w http.ResponseWriter, r *http.Request) {
...
// アクセストークンが期限切れの場合、リフレッシュトークンを使ってアクセストークンを更新する
if errors.Is(err, jwt.ErrTokenExpired) {
refreshToken, err := r.Cookie("refresh_token")
if err != nil {
log.Println("Unauthorized", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// アクセストークンが期限切れの場合、リフレッシュトークンを使ってアクセストークンを更新する
newAccessToken, newRefrefreshToken, err := RefreshToken(refreshToken.Value)
if err != nil {
log.Println("Unauthorized", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// アクセストークンをCookieに保存
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: newAccessToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// リフレッシュトークンをCookieに保存
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newRefrefreshToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// 認証が成功した場合の処理
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Welcome !!!")
return
}
...
}

アクセストークンが期限切れの場合、リフレッシュトークンを使ってアクセストークンを更新する関数を追加します。

sample/main.go
func RefreshToken(refreshToken string) (string, string, error) {
fmt.Println("refresh token")
tokenURL := fmt.Sprintf("%s/protocol/openid-connect/token", keycloakURL)
v := url.Values{}
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", refreshToken)
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
payload := strings.NewReader(v.Encode())
req, _ := http.NewRequest("POST", tokenURL, payload)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// v.Add("client_secret", clientSecret) ではなく、Basic認証でもOK
// req.SetBasicAuth(clientID, clientSecret)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer res.Body.Close()
var tmp, out bytes.Buffer
teeReader := io.TeeReader(res.Body, &tmp)
// レスポンスからアクセストークンを取得
var tokenResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(teeReader).Decode(&tokenResponse); err != nil {
return "", "", err
}
// responseをterminalに出力する用
{
if err := json.Indent(&out, tmp.Bytes(), "", " "); err != nil {
return "", "", nil
}
fmt.Println(out.String())
}
if tokenResponse.AccessToken == "" {
return "", "", errors.New("token is empty")
}
return tokenResponse.AccessToken, tokenResponse.RefreshToken, nil
}

Keycloak の設定からアクセストークンとリフレッシュトークンの有効期限を個別に短く設定したり、両方とも短く設定することで期限切れの状態を再現し、動作確認を行いました。

実際にリフレッシュトークンを使ってアクセストークンを取得すると以下のようなレスポンスが返ってきます。

Terminal window
refresh token
{
"access_token": "eyJ..A3g",
"expires_in": 60,
"refresh_expires_in": 1800,
"refresh_token": "eyJ...eRA",
"token_type": "Bearer",
"id_token": "eyJ...Lfw",
"not-before-policy": 0,
"session_state": "9057799c-ad8c-42b3-b8b3-eb144304c52b",
"scope": "openid profile email"
}

アクセストークンとリフレッシュトークンどちらも期限切れの状態でリクエストを送信すると、以下のようなエラーレスポンスが返ってきます。

Terminal window
refresh token
{
"error": "invalid_grant",
"error_description": "Token is not active"
}

さいごに

これで、リフレッシュトークンが有効であれば、アクセストークンの有効期限が切れても再取得できるようになりました。次回はログアウトの機能を追加します。