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 でセットアップ
Section titled “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リフレッシュトークンとは
Section titled “リフレッシュトークンとは”リフレッシュトークンはアクセストークンを再発行するために使用されるクレデンシャルです。アクセストークン、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
 
参考:
有効期限を変更する
Section titled “有効期限を変更する”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"}どこに保存するのか
Section titled “どこに保存するのか”リフレッシュトークンはアクセストークンを比べて期限が長いため、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
トークンをリフレッシュできるようにする
Section titled “トークンをリフレッシュできるようにする”まずアクセストークン、リフレッシュトークンの状態の組み合わせを考えます。
| アクセストークン | リフレッシュトークン | 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"}これで、リフレッシュトークンが有効であれば、アクセストークンの有効期限が切れても再取得できるようになりました。次回はログアウトの機能を追加します。