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
目標
- アクセストークンの有効期限が切れても、リフレッシュトークンを使ってアクセストークンを取得できるようにする
- リフレッシュトークンが切れた場合は、再度ログイン画面にリダイレクトさせる
環境
バージョン | |
---|---|
Mac | Ventura 13.2.1 |
Keycloak | 23.0.6 |
Docker | 26.0.0 |
Docker Compose | v2.24.5 |
Terraform | 1.7.4 |
Go | 1.22.0 |
Terraform と Docker でセットアップ
Keycloak を docker compose で起動します。
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 の設定を行います。
cd terraformterraform initterraform planterraform apply -auto-approve
リフレッシュトークンとは
リフレッシュトークンはアクセストークンを再発行するために使用されるクレデンシャルです。アクセストークン、ID トークンと一緒に発行され、主に、現在のアクセストークンが無効化されたり、アクセストークンの期限が切れた際に新しいアクセストークンを取得するために使用されます。
リフレッシュトークンを使ってアクセストークンを取得するには、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 分です。
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.
訳)リフレッシュ・トークンは長寿命で取り消し可能である必要があるため、サーバー側の永続ストレージに格納する必要がある。
しかし、SPA などのフロントエンドアプリケーションの場合、リフレッシュトークンをサーバーサイドに保存することは難しいため、Cookie などのフロントエンド側のストレージに保存することもあるそうです。
参考:Simple and Secure API Authentication for SPAs - medium
トークンをリフレッシュできるようにする
まずアクセストークン、リフレッシュトークンの状態の組み合わせを考えます。
アクセストークン | リフレッシュトークン | APIの挙動 |
---|---|---|
✅ 期限内 | ✅ 期限内 | アクセスを許可 |
✅ 期限内 | ❌ 期限切れ | アクセスを許可 |
❌ 期限切れ | ✅ 期限内 | 新規にアクセストークンを発行する |
❌ 期限切れ | ❌ 期限切れ | ログイン画面にリダイレクトする |
前回の記事で書いたサンプルコードをベースに、リフレッシュトークンを使ってアクセストークンを取得するコードを書いていきます。
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)}
エラーがあった場合は、認可に失敗したとしてログイン画面にリダイレクトします。
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 }
...}
アクセストークンが期限切れの場合、リフレッシュトークンを使ってアクセストークンを更新する関数を追加します。
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 の設定からアクセストークンとリフレッシュトークンの有効期限を個別に短く設定したり、両方とも短く設定することで期限切れの状態を再現し、動作確認を行いました。
実際にリフレッシュトークンを使ってアクセストークンを取得すると以下のようなレスポンスが返ってきます。
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"}
アクセストークンとリフレッシュトークンどちらも期限切れの状態でリクエストを送信すると、以下のようなエラーレスポンスが返ってきます。
refresh token{ "error": "invalid_grant", "error_description": "Token is not active"}
さいごに
これで、リフレッシュトークンが有効であれば、アクセストークンの有効期限が切れても再取得できるようになりました。次回はログアウトの機能を追加します。