Skip to content

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

はじめに

これまで 3回にわたり、Keycloak と Go 言語で Authorization Code Flow を学びました。

今までログアウトについては触れていなかったため、この記事ではログアウトのエンドポイントにリクエストしてセッションを削除する方法を学びます。

成果物

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

目標

  1. API サーバーからログアウトのエンドポイントにリクエストしてセッションを削除する
  2. Cookie に保存されたアクセストークン、リフレッシュトークン、ID トークンを削除する

環境

バージョン
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

ログアウトのエンドポイント

Keycloak では、ログアウトのためのエンドポイントが用意されており、今回使用する URL は http://localhost:8080/realms/myrealm/protocol/openid-connect/logout です。

/realms/{realm-name}/protocol/openid-connect/logout

エンドポイントを調べるには /realms/{realm-name}/.well-known/openid-configuration のレスポンスにある end_session_endpoint を確認します。

Terminal window
$ curl -s http://localhost:8080/realms/myrealm/.well-known/openid-configuration | jq -M 'with_entries(select(.key | endswith("_endpoint")))'
{
"authorization_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/auth",
"token_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/token",
"introspection_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/token/introspect",
"userinfo_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/userinfo",
"end_session_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/logout",
"registration_endpoint": "http://localhost:8080/realms/myrealm/clients-registrations/openid-connect",
"revocation_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/revoke",
"device_authorization_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/auth/device",
"backchannel_authentication_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/ext/ciba/auth",
"pushed_authorization_request_endpoint": "http://localhost:8080/realms/myrealm/protocol/openid-connect/ext/par/request"
}

ログアウトの実装

このエンドポイントに関して、Keycloak のドキュメントには2つの方法が記載されています。

1つ目はリダイレクトする方法です。

The user agent can be redirected to the endpoint, which causes the active user session to be logged out. The user agent is then redirected back to the application.

訳)ユーザエージェントはエンドポイントにリダイレクトされ、アクティブなユーザセッションはログアウトされます。その後、ユーザエージェントはアプリケーションにリダイレクトされます。

2つ目はエンドポイントに直接リクエストを送信する方法です。

The endpoint can also be invoked directly by the application. To invoke this endpoint directly, the refresh token needs to be included as well as the credentials required to authenticate the client.

訳)このエンドポイントは、アプリケーションから直接呼び出すこともできます。このエンドポイントを直接呼び出すには、クライアントを認証するために必要な認証情報だけでなく、リフレッシュトークンを含める必要があります。

引用:2.1.1. Endpoints - Keycloack Documentation

RP-Initiated Logout

1つ目のリダイレクトする方法は、以下の引用を参照すると、RP-Initiated Logout のことを指していることがわかります。

This URL is normally obtained via the end_session_endpoint element of the OP’s Discovery response

訳)このURLは通常、OP の Discovery レスポンスの end_session_endpoint 要素を介して取得されます。

引用:2. RP-Initiated Logout - OpenID Connect RP-Initiated Logout 1.0

You can optionally include parameters such as id_token_hint, post_logout_redirect_uri, client_id and others as described in the OpenID Connect RP-Initiated Logout.

訳)オプションで、OpenID Connect RP-Initiated Logoutで説明されているid_token_hint、post_logout_redirect_uri、client_idなどのパラメータを含めることができます。

引用:2.3.12. Logout - Keycloak Documentation

Go 言語で実装する前に Keycloak にログアウト後のリダイレクト先を設定します。

左のナビゲーションペインから Clients を選択し、myclient を選択します。Settings タブから Valid post logout redirect URIs にログアウト後にリダイレクトする URL を設定します。今回は、http://localhost:8081/login を設定します。 clients-settings-valid-post-logout-redirect-urls

Terraform の設定も変更します。

terraform/main.tf
resource "keycloak_openid_client" "myqpp" {
...
valid_post_logout_redirect_uris = [""]
valid_post_logout_redirect_uris = ["http://localhost:8081/login"]
valid_redirect_uris = ["http://localhost:8081/callback"]
web_origins = ["http://localhost:8081"]
}

このセクションでは、Go 言語で実装した REST API に /logout エンドポイントは作成し RP-Initiated Logout を実装します。

必要なパラメータは以下の通りです。

  • id_token_hint
    • RECOMMENDED
    • ログアウトするユーザの ID トークン
  • logout_hint
    • OPTIONAL
  • client_id
    • OPTIONAL
    • ログアウトするクライアントの ID
  • post_logout_redirect_uri
    • OPTIONAL
    • ログアウト後にリダイレクトする URL
  • state
    • OPTIONAL
    • CSRF 対策のためのランダムな文字列
  • ui_locales
    • OPTIONAL

変更を追加したコミット

logout/main.go
...
// Keycloakのログアウトエンドポイントにリクエストを送信する
func handleRPInitiatedLogout(w http.ResponseWriter, r *http.Request) {
idToken, err := r.Cookie("id_token")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v := url.Values{}
v.Add("id_token_hint", idToken.Value)
v.Add("post_logout_redirect_uri", "http://localhost:8081/login")
v.Add("client_id", clientID)
redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s/protocol/openid-connect/logout", keycloakURL))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
redirectURL.RawQuery = v.Encode()
http.Redirect(w, r, redirectURL.String(), http.StatusSeeOther)
}

API サーバーを起動します。

Terminal window
go run logout/*.go

実際に Keycloak の Session を確認しながらログアウトを実行すると、ログアウト後にリダイレクトされ、Session も削除されていることが確認できました。 rp-initiated-logout

クエリパラーメータは、client_idid_token_hint のみでもログアウトは可能です。そのような設定の場合、ログアウト後に Keycloak は以下の画面になります。 you-are-logged-out

クエリパラメータが、post_logout_redirect_uriclient_id のみの場合、ログアウト後に確認ページが表示されます。 do-you-want-to-log-out

直接リクエストを送信するログアウト

POST リクエストに必要なパラメータは以下の通りです。

  • client_id
  • client_secret
  • refresh_token

参考:https://stackoverflow.com/a/77135305

POST リクエストに成功すると、Keycloak は 204 No Content を返すことがわかりました。そのため、以下のように実装します。

変更を追加したコミット

logout/main.go
// Keycloakのログアウトエンドポイントにリクエストを送信する
func handleDirectLogout(w http.ResponseWriter, r *http.Request) {
refreshToken, err := r.Cookie("refresh_token")
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v := url.Values{}
v.Add("refresh_token", refreshToken.Value)
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
logoutURL := fmt.Sprintf("%s/protocol/openid-connect/logout", keycloakURL)
payload := strings.NewReader(v.Encode())
req, _ := http.NewRequest("POST", logoutURL, payload)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// Keycloakのログアウトエンドポイントにリクエストを送信
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer res.Body.Close()
// レスポンスボディには何も含まれないため、ステータスコードのみで判断する
if res.StatusCode >= 400 {
http.Error(w, "logout failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}

API サーバーを起動します。

Terminal window
go run logout/*.go

ログイン後に Web ブラウザから http://localhost:8081/logout2 にアクセスすると、ログイン画面にリダイレクトされることが確認できました。

ログアウトの実装をしたことで問題点が出てきました。

それは、ログアウト後に再度 /home にアクセスすると、リクエストが成功してしまうことです。

access-home-after-logout

これは、Cookie が削除されていないことが原因です。

API サーバーから Cookie を削除するには、Cookie の有効期限を過去に設定します。

to remove a cookie, the server returns a Set-Cookie header with an expiration date in the past. The server will be successful in removing the cookie only if the Path and the Domain attribute in the Set-Cookie header match the values used when the cookie was created.

訳)クッキーを削除するには、サーバーが過去の有効期限を設定したSet-Cookieヘッダーを返します。サーバーがクッキーを正常に削除できるのは、Set-CookieヘッダーのPathとDomainの属性値が、クッキーが作成された時に使用された値と一致する場合のみです。

引用:3.1. Example - RFC6265

参考:Correct way to delete cookies server-side - stack overflow

ログアウトの実装に Cookie の削除を追加します。

変更を追加したコミット

cookie-delete/main.go
func handleRPInitiatedLogout(w http.ResponseWriter, r *http.Request) {
...
cookieNames := []string{"access_token", "refresh_token", "id_token"}
for _, name := range cookieNames {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
// 有効期限を過去に設定することで、Cookieを削除する
Expires: time.Now(),
// MaxAge: -1,
})
}
...
}
func handleDirectLogout(w http.ResponseWriter, r *http.Request) {
...
cookieNames := []string{"access_token", "refresh_token", "id_token"}
for _, name := range cookieNames {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
// 有効期限を過去に設定することで、Cookieを削除する
Expires: time.Now(),
// MaxAge: -1,
})
}
...
}

API サーバーを起動します。

Terminal window
go run cookie-delete/*.go

実際に API サーバーを起動して /logout にアクセスした後に Cookie が削除されることを確認しました。

delete-cookie

まとめ

Keycloak にはログアウトする方法が2つあることがわかりました。そしてログアウトのエンドポイントにリクエストすることで Keycloak のセッションを削除する方法を学びました。また、Cookie の削除方法についても学びました。

さいごに

2024年2月ごろから OpenID Connect と Keycloak を学びながら、Go 言語で API サーバーを作成してきました。楽をするためにアクセストークンを Cookie に保存する実装をしましたが、実際のアプリケーションでは、Redis などの永続ストレージに保存することをオススメします。

そのほかにも、Token RevocationToken Introspection など、触れていない仕様がたくさんありますし、Keycloak 自体の設定もほとんど触れていません。

この “Authorization Code Flow を学ぶ” シリーズはこれで終わりにしますが、今後も OpenID Connect や Keycloak について学びながら、新しい記事を書いていきたいと思います。