{"type":"file-history-snapshot","messageId":"580b0f35-2496-4176-bdb4-bf20d74ba4cd","snapshot":{"messageId":"580b0f35-2496-4176-bdb4-bf20d74ba4cd","trackedFileBackups":{},"timestamp":"2026-03-30T12:57:01.512Z"},"isSnapshotUpdate":false} {"parentUuid":null,"isSidechain":false,"promptId":"0f18784c-546f-4d81-81bd-69c25b663a3b","type":"user","message":{"role":"user","content":"How can you logout if logged in in openid connect?"},"uuid":"580b0f35-2496-4176-bdb4-bf20d74ba4cd","timestamp":"2026-03-30T12:57:00.780Z","permissionMode":"default","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix"} {"parentUuid":"580b0f35-2496-4176-bdb4-bf20d74ba4cd","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01KkbX2S1XKfBnGU5Pyqrmc4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EsUCClsIDBgCKkDvTrkxmvb4ycGYgKiYY/JZbUUBjlFtmVi3rd0iRQaZl1VkEwY0kOUAIAWPkp72mm3jCeLCeBHZFDL2K1V/+qZOMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgyL6Oo5g95Jus3lZSoaDHsqr1zJsp12JqLGZiIwjTwOvQCFhQw0VW+xD1eX1VqFIA0xsY2CsdJXl8BGDrEBFdy8xNp8inGKg+KrdVwEKpcBC0iRedmKMFhX4H/qWJZxhefrIB6xyksZRASAYbMMzRwZf8jVEgsQ2BSRZIrzW35k2IlexauVtSDJbzhGCwRUrMKNagCb/3o8jWwskqbcDnzY0OfR+S9YSnFdS5AjJ6MGSN8AeEksWFoLyFRJnMX0veVCv7Be4nWTi2qO4mNT7la4ZNhaD4oj2GDyjGmDlOqxom3awm+8lRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":8175,"cache_read_input_tokens":11245,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8175},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CZZJVQkSDxdkzVsqDVVTf","type":"assistant","uuid":"9152916e-913f-4cd4-a598-9a6a0c6442dc","timestamp":"2026-03-30T12:57:04.897Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix"} {"parentUuid":"9152916e-913f-4cd4-a598-9a6a0c6442dc","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01KkbX2S1XKfBnGU5Pyqrmc4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0136UcZC8NrTVr1PszemSC5Q","name":"Read","input":{"file_path":"/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":8175,"cache_read_input_tokens":11245,"output_tokens":125,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":8175,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CZZJVQkSDxdkzVsqDVVTf","type":"assistant","uuid":"2d2e8432-4ade-4264-a411-ada8fabf9975","timestamp":"2026-03-30T12:57:05.431Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"2d2e8432-4ade-4264-a411-ada8fabf9975","isSidechain":false,"promptId":"0f18784c-546f-4d81-81bd-69c25b663a3b","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0136UcZC8NrTVr1PszemSC5Q","type":"tool_result","content":"1\t// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.\n2\t// All rights reserved. This file is part of cc-backend.\n3\t// Use of this source code is governed by a MIT-style\n4\t// license that can be found in the LICENSE file.\n5\t\n6\tpackage auth\n7\t\n8\timport (\n9\t\t\"context\"\n10\t\t\"crypto/rand\"\n11\t\t\"encoding/base64\"\n12\t\t\"fmt\"\n13\t\t\"io\"\n14\t\t\"net/http\"\n15\t\t\"os\"\n16\t\t\"time\"\n17\t\n18\t\t\"github.com/ClusterCockpit/cc-backend/internal/repository\"\n19\t\tcclog \"github.com/ClusterCockpit/cc-lib/v2/ccLogger\"\n20\t\t\"github.com/ClusterCockpit/cc-lib/v2/schema\"\n21\t\t\"github.com/coreos/go-oidc/v3/oidc\"\n22\t\t\"github.com/go-chi/chi/v5\"\n23\t\t\"golang.org/x/oauth2\"\n24\t)\n25\t\n26\ttype OpenIDConfig struct {\n27\t\tProvider string `json:\"provider\"`\n28\t\tSyncUserOnLogin bool `json:\"sync-user-on-login\"`\n29\t\tUpdateUserOnLogin bool `json:\"update-user-on-login\"`\n30\t}\n31\t\n32\ttype OIDC struct {\n33\t\tclient *oauth2.Config\n34\t\tprovider *oidc.Provider\n35\t\tauthentication *Authentication\n36\t\tclientID string\n37\t}\n38\t\n39\tfunc randString(nByte int) (string, error) {\n40\t\tb := make([]byte, nByte)\n41\t\tif _, err := io.ReadFull(rand.Reader, b); err != nil {\n42\t\t\treturn \"\", err\n43\t\t}\n44\t\treturn base64.RawURLEncoding.EncodeToString(b), nil\n45\t}\n46\t\n47\tfunc setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {\n48\t\tc := &http.Cookie{\n49\t\t\tName: name,\n50\t\t\tValue: value,\n51\t\t\tMaxAge: int(time.Hour.Seconds()),\n52\t\t\tSecure: r.TLS != nil,\n53\t\t\tHttpOnly: true,\n54\t\t\tSameSite: http.SameSiteLaxMode,\n55\t\t}\n56\t\thttp.SetCookie(w, c)\n57\t}\n58\t\n59\t// NewOIDC creates a new OIDC authenticator with the configured provider\n60\tfunc NewOIDC(a *Authentication) *OIDC {\n61\t\t// Use context with timeout for provider initialization\n62\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n63\t\tdefer cancel()\n64\t\n65\t\tprovider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)\n66\t\tif err != nil {\n67\t\t\tcclog.Fatal(err)\n68\t\t}\n69\t\tclientID := os.Getenv(\"OID_CLIENT_ID\")\n70\t\tif clientID == \"\" {\n71\t\t\tcclog.Warn(\"environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)\")\n72\t\t}\n73\t\tclientSecret := os.Getenv(\"OID_CLIENT_SECRET\")\n74\t\tif clientSecret == \"\" {\n75\t\t\tcclog.Warn(\"environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)\")\n76\t\t}\n77\t\n78\t\tclient := &oauth2.Config{\n79\t\t\tClientID: clientID,\n80\t\t\tClientSecret: clientSecret,\n81\t\t\tEndpoint: provider.Endpoint(),\n82\t\t\tScopes: []string{oidc.ScopeOpenID, \"profile\"},\n83\t\t}\n84\t\n85\t\toa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}\n86\t\n87\t\treturn oa\n88\t}\n89\t\n90\tfunc (oa *OIDC) RegisterEndpoints(r chi.Router) {\n91\t\tr.HandleFunc(\"/oidc-login\", oa.OAuth2Login)\n92\t\tr.HandleFunc(\"/oidc-callback\", oa.OAuth2Callback)\n93\t}\n94\t\n95\tfunc (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {\n96\t\tc, err := r.Cookie(\"state\")\n97\t\tif err != nil {\n98\t\t\thttp.Error(rw, \"state cookie not found\", http.StatusBadRequest)\n99\t\t\treturn\n100\t\t}\n101\t\tstate := c.Value\n102\t\n103\t\tc, err = r.Cookie(\"verifier\")\n104\t\tif err != nil {\n105\t\t\thttp.Error(rw, \"verifier cookie not found\", http.StatusBadRequest)\n106\t\t\treturn\n107\t\t}\n108\t\tcodeVerifier := c.Value\n109\t\n110\t\t_ = r.ParseForm()\n111\t\tif r.Form.Get(\"state\") != state {\n112\t\t\thttp.Error(rw, \"State invalid\", http.StatusBadRequest)\n113\t\t\treturn\n114\t\t}\n115\t\tcode := r.Form.Get(\"code\")\n116\t\tif code == \"\" {\n117\t\t\thttp.Error(rw, \"Code not found\", http.StatusBadRequest)\n118\t\t\treturn\n119\t\t}\n120\t\t// Exchange authorization code for token with timeout\n121\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n122\t\tdefer cancel()\n123\t\n124\t\ttoken, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))\n125\t\tif err != nil {\n126\t\t\tcclog.Errorf(\"token exchange failed: %s\", err.Error())\n127\t\t\thttp.Error(rw, \"Authentication failed during token exchange\", http.StatusInternalServerError)\n128\t\t\treturn\n129\t\t}\n130\t\n131\t\t// Get user info from OIDC provider with same timeout\n132\t\tuserInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))\n133\t\tif err != nil {\n134\t\t\tcclog.Errorf(\"failed to get userinfo: %s\", err.Error())\n135\t\t\thttp.Error(rw, \"Failed to retrieve user information\", http.StatusInternalServerError)\n136\t\t\treturn\n137\t\t}\n138\t\n139\t\t// Verify ID token and nonce to prevent replay attacks\n140\t\trawIDToken, ok := token.Extra(\"id_token\").(string)\n141\t\tif !ok {\n142\t\t\thttp.Error(rw, \"ID token not found in response\", http.StatusInternalServerError)\n143\t\t\treturn\n144\t\t}\n145\t\n146\t\tnonceCookie, err := r.Cookie(\"nonce\")\n147\t\tif err != nil {\n148\t\t\thttp.Error(rw, \"nonce cookie not found\", http.StatusBadRequest)\n149\t\t\treturn\n150\t\t}\n151\t\n152\t\tverifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})\n153\t\tidToken, err := verifier.Verify(ctx, rawIDToken)\n154\t\tif err != nil {\n155\t\t\tcclog.Errorf(\"ID token verification failed: %s\", err.Error())\n156\t\t\thttp.Error(rw, \"ID token verification failed\", http.StatusInternalServerError)\n157\t\t\treturn\n158\t\t}\n159\t\n160\t\tif idToken.Nonce != nonceCookie.Value {\n161\t\t\thttp.Error(rw, \"Nonce mismatch\", http.StatusBadRequest)\n162\t\t\treturn\n163\t\t}\n164\t\n165\t\tprojects := make([]string, 0)\n166\t\n167\t\t// Extract custom claims from userinfo\n168\t\tvar claims struct {\n169\t\t\tUsername string `json:\"preferred_username\"`\n170\t\t\tName string `json:\"name\"`\n171\t\t\t// Keycloak realm-level roles\n172\t\t\tRealmAccess struct {\n173\t\t\t\tRoles []string `json:\"roles\"`\n174\t\t\t} `json:\"realm_access\"`\n175\t\t\t// Keycloak client-level roles\n176\t\t\tResourceAccess struct {\n177\t\t\t\tClient struct {\n178\t\t\t\t\tRoles []string `json:\"roles\"`\n179\t\t\t\t} `json:\"clustercockpit\"`\n180\t\t\t} `json:\"resource_access\"`\n181\t\t}\n182\t\tif err := userInfo.Claims(&claims); err != nil {\n183\t\t\tcclog.Errorf(\"failed to extract claims: %s\", err.Error())\n184\t\t\thttp.Error(rw, \"Failed to extract user claims\", http.StatusInternalServerError)\n185\t\t\treturn\n186\t\t}\n187\t\n188\t\tif claims.Username == \"\" {\n189\t\t\thttp.Error(rw, \"Username claim missing from OIDC provider\", http.StatusBadRequest)\n190\t\t\treturn\n191\t\t}\n192\t\n193\t\t// Merge roles from both client-level and realm-level access\n194\t\toidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)\n195\t\n196\t\troleSet := make(map[string]bool)\n197\t\tfor _, r := range oidcRoles {\n198\t\t\tswitch r {\n199\t\t\tcase \"user\":\n200\t\t\t\troleSet[schema.GetRoleString(schema.RoleUser)] = true\n201\t\t\tcase \"admin\":\n202\t\t\t\troleSet[schema.GetRoleString(schema.RoleAdmin)] = true\n203\t\t\tcase \"manager\":\n204\t\t\t\troleSet[schema.GetRoleString(schema.RoleManager)] = true\n205\t\t\tcase \"support\":\n206\t\t\t\troleSet[schema.GetRoleString(schema.RoleSupport)] = true\n207\t\t\t}\n208\t\t}\n209\t\n210\t\tvar roles []string\n211\t\tfor role := range roleSet {\n212\t\t\troles = append(roles, role)\n213\t\t}\n214\t\n215\t\tif len(roles) == 0 {\n216\t\t\troles = append(roles, schema.GetRoleString(schema.RoleUser))\n217\t\t}\n218\t\n219\t\tuser := &schema.User{\n220\t\t\tUsername: claims.Username,\n221\t\t\tName: claims.Name,\n222\t\t\tRoles: roles,\n223\t\t\tProjects: projects,\n224\t\t\tAuthSource: schema.AuthViaOIDC,\n225\t\t}\n226\t\n227\t\tif Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {\n228\t\t\thandleOIDCUser(user)\n229\t\t}\n230\t\n231\t\tif err := oa.authentication.SaveSession(rw, r, user); err != nil {\n232\t\t\tcclog.Errorf(\"session save failed for user %q: %s\", user.Username, err.Error())\n233\t\t\thttp.Error(rw, \"Failed to create session\", http.StatusInternalServerError)\n234\t\t\treturn\n235\t\t}\n236\t\tcclog.Infof(\"login successful: user: %#v (roles: %v, projects: %v)\", user.Username, user.Roles, user.Projects)\n237\t\tuserCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)\n238\t\thttp.RedirectHandler(\"/\", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))\n239\t}\n240\t\n241\tfunc (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {\n242\t\tstate, err := randString(16)\n243\t\tif err != nil {\n244\t\t\thttp.Error(rw, \"Internal error\", http.StatusInternalServerError)\n245\t\t\treturn\n246\t\t}\n247\t\tsetCallbackCookie(rw, r, \"state\", state)\n248\t\n249\t\t// use PKCE to protect against CSRF attacks\n250\t\tcodeVerifier := oauth2.GenerateVerifier()\n251\t\tsetCallbackCookie(rw, r, \"verifier\", codeVerifier)\n252\t\n253\t\t// Generate nonce for ID token replay protection\n254\t\tnonce, err := randString(16)\n255\t\tif err != nil {\n256\t\t\thttp.Error(rw, \"Internal error\", http.StatusInternalServerError)\n257\t\t\treturn\n258\t\t}\n259\t\tsetCallbackCookie(rw, r, \"nonce\", nonce)\n260\t\n261\t\t// Build redirect URL from the incoming request\n262\t\tscheme := \"https\"\n263\t\tif r.TLS == nil && r.Header.Get(\"X-Forwarded-Proto\") != \"https\" {\n264\t\t\tscheme = \"http\"\n265\t\t}\n266\t\toa.client.RedirectURL = fmt.Sprintf(\"%s://%s/oidc-callback\", scheme, r.Host)\n267\t\n268\t\t// Redirect user to consent page to ask for permission\n269\t\turl := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,\n270\t\t\toauth2.S256ChallengeOption(codeVerifier),\n271\t\t\toidc.Nonce(nonce))\n272\t\thttp.Redirect(rw, r, url, http.StatusFound)\n273\t}\n274\t\n\n\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n\n"}]},"uuid":"0c9adc02-0d54-47c4-9c9e-77112635c8c6","timestamp":"2026-03-30T12:57:05.449Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go","content":"// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.\n// All rights reserved. This file is part of cc-backend.\n// Use of this source code is governed by a MIT-style\n// license that can be found in the LICENSE file.\n\npackage auth\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/ClusterCockpit/cc-backend/internal/repository\"\n\tcclog \"github.com/ClusterCockpit/cc-lib/v2/ccLogger\"\n\t\"github.com/ClusterCockpit/cc-lib/v2/schema\"\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n\t\"github.com/go-chi/chi/v5\"\n\t\"golang.org/x/oauth2\"\n)\n\ntype OpenIDConfig struct {\n\tProvider string `json:\"provider\"`\n\tSyncUserOnLogin bool `json:\"sync-user-on-login\"`\n\tUpdateUserOnLogin bool `json:\"update-user-on-login\"`\n}\n\ntype OIDC struct {\n\tclient *oauth2.Config\n\tprovider *oidc.Provider\n\tauthentication *Authentication\n\tclientID string\n}\n\nfunc randString(nByte int) (string, error) {\n\tb := make([]byte, nByte)\n\tif _, err := io.ReadFull(rand.Reader, b); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b), nil\n}\n\nfunc setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {\n\tc := &http.Cookie{\n\t\tName: name,\n\t\tValue: value,\n\t\tMaxAge: int(time.Hour.Seconds()),\n\t\tSecure: r.TLS != nil,\n\t\tHttpOnly: true,\n\t\tSameSite: http.SameSiteLaxMode,\n\t}\n\thttp.SetCookie(w, c)\n}\n\n// NewOIDC creates a new OIDC authenticator with the configured provider\nfunc NewOIDC(a *Authentication) *OIDC {\n\t// Use context with timeout for provider initialization\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tprovider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)\n\tif err != nil {\n\t\tcclog.Fatal(err)\n\t}\n\tclientID := os.Getenv(\"OID_CLIENT_ID\")\n\tif clientID == \"\" {\n\t\tcclog.Warn(\"environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)\")\n\t}\n\tclientSecret := os.Getenv(\"OID_CLIENT_SECRET\")\n\tif clientSecret == \"\" {\n\t\tcclog.Warn(\"environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)\")\n\t}\n\n\tclient := &oauth2.Config{\n\t\tClientID: clientID,\n\t\tClientSecret: clientSecret,\n\t\tEndpoint: provider.Endpoint(),\n\t\tScopes: []string{oidc.ScopeOpenID, \"profile\"},\n\t}\n\n\toa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}\n\n\treturn oa\n}\n\nfunc (oa *OIDC) RegisterEndpoints(r chi.Router) {\n\tr.HandleFunc(\"/oidc-login\", oa.OAuth2Login)\n\tr.HandleFunc(\"/oidc-callback\", oa.OAuth2Callback)\n}\n\nfunc (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {\n\tc, err := r.Cookie(\"state\")\n\tif err != nil {\n\t\thttp.Error(rw, \"state cookie not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tstate := c.Value\n\n\tc, err = r.Cookie(\"verifier\")\n\tif err != nil {\n\t\thttp.Error(rw, \"verifier cookie not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tcodeVerifier := c.Value\n\n\t_ = r.ParseForm()\n\tif r.Form.Get(\"state\") != state {\n\t\thttp.Error(rw, \"State invalid\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tcode := r.Form.Get(\"code\")\n\tif code == \"\" {\n\t\thttp.Error(rw, \"Code not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\t// Exchange authorization code for token with timeout\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\ttoken, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))\n\tif err != nil {\n\t\tcclog.Errorf(\"token exchange failed: %s\", err.Error())\n\t\thttp.Error(rw, \"Authentication failed during token exchange\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Get user info from OIDC provider with same timeout\n\tuserInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))\n\tif err != nil {\n\t\tcclog.Errorf(\"failed to get userinfo: %s\", err.Error())\n\t\thttp.Error(rw, \"Failed to retrieve user information\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Verify ID token and nonce to prevent replay attacks\n\trawIDToken, ok := token.Extra(\"id_token\").(string)\n\tif !ok {\n\t\thttp.Error(rw, \"ID token not found in response\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tnonceCookie, err := r.Cookie(\"nonce\")\n\tif err != nil {\n\t\thttp.Error(rw, \"nonce cookie not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tverifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})\n\tidToken, err := verifier.Verify(ctx, rawIDToken)\n\tif err != nil {\n\t\tcclog.Errorf(\"ID token verification failed: %s\", err.Error())\n\t\thttp.Error(rw, \"ID token verification failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif idToken.Nonce != nonceCookie.Value {\n\t\thttp.Error(rw, \"Nonce mismatch\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tprojects := make([]string, 0)\n\n\t// Extract custom claims from userinfo\n\tvar claims struct {\n\t\tUsername string `json:\"preferred_username\"`\n\t\tName string `json:\"name\"`\n\t\t// Keycloak realm-level roles\n\t\tRealmAccess struct {\n\t\t\tRoles []string `json:\"roles\"`\n\t\t} `json:\"realm_access\"`\n\t\t// Keycloak client-level roles\n\t\tResourceAccess struct {\n\t\t\tClient struct {\n\t\t\t\tRoles []string `json:\"roles\"`\n\t\t\t} `json:\"clustercockpit\"`\n\t\t} `json:\"resource_access\"`\n\t}\n\tif err := userInfo.Claims(&claims); err != nil {\n\t\tcclog.Errorf(\"failed to extract claims: %s\", err.Error())\n\t\thttp.Error(rw, \"Failed to extract user claims\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif claims.Username == \"\" {\n\t\thttp.Error(rw, \"Username claim missing from OIDC provider\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Merge roles from both client-level and realm-level access\n\toidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)\n\n\troleSet := make(map[string]bool)\n\tfor _, r := range oidcRoles {\n\t\tswitch r {\n\t\tcase \"user\":\n\t\t\troleSet[schema.GetRoleString(schema.RoleUser)] = true\n\t\tcase \"admin\":\n\t\t\troleSet[schema.GetRoleString(schema.RoleAdmin)] = true\n\t\tcase \"manager\":\n\t\t\troleSet[schema.GetRoleString(schema.RoleManager)] = true\n\t\tcase \"support\":\n\t\t\troleSet[schema.GetRoleString(schema.RoleSupport)] = true\n\t\t}\n\t}\n\n\tvar roles []string\n\tfor role := range roleSet {\n\t\troles = append(roles, role)\n\t}\n\n\tif len(roles) == 0 {\n\t\troles = append(roles, schema.GetRoleString(schema.RoleUser))\n\t}\n\n\tuser := &schema.User{\n\t\tUsername: claims.Username,\n\t\tName: claims.Name,\n\t\tRoles: roles,\n\t\tProjects: projects,\n\t\tAuthSource: schema.AuthViaOIDC,\n\t}\n\n\tif Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {\n\t\thandleOIDCUser(user)\n\t}\n\n\tif err := oa.authentication.SaveSession(rw, r, user); err != nil {\n\t\tcclog.Errorf(\"session save failed for user %q: %s\", user.Username, err.Error())\n\t\thttp.Error(rw, \"Failed to create session\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\tcclog.Infof(\"login successful: user: %#v (roles: %v, projects: %v)\", user.Username, user.Roles, user.Projects)\n\tuserCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)\n\thttp.RedirectHandler(\"/\", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))\n}\n\nfunc (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {\n\tstate, err := randString(16)\n\tif err != nil {\n\t\thttp.Error(rw, \"Internal error\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\tsetCallbackCookie(rw, r, \"state\", state)\n\n\t// use PKCE to protect against CSRF attacks\n\tcodeVerifier := oauth2.GenerateVerifier()\n\tsetCallbackCookie(rw, r, \"verifier\", codeVerifier)\n\n\t// Generate nonce for ID token replay protection\n\tnonce, err := randString(16)\n\tif err != nil {\n\t\thttp.Error(rw, \"Internal error\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\tsetCallbackCookie(rw, r, \"nonce\", nonce)\n\n\t// Build redirect URL from the incoming request\n\tscheme := \"https\"\n\tif r.TLS == nil && r.Header.Get(\"X-Forwarded-Proto\") != \"https\" {\n\t\tscheme = \"http\"\n\t}\n\toa.client.RedirectURL = fmt.Sprintf(\"%s://%s/oidc-callback\", scheme, r.Host)\n\n\t// Redirect user to consent page to ask for permission\n\turl := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,\n\t\toauth2.S256ChallengeOption(codeVerifier),\n\t\toidc.Nonce(nonce))\n\thttp.Redirect(rw, r, url, http.StatusFound)\n}\n","numLines":274,"startLine":1,"totalLines":274}},"sourceToolAssistantUUID":"2d2e8432-4ade-4264-a411-ada8fabf9975","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"0c9adc02-0d54-47c4-9c9e-77112635c8c6","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01GJe4KouM9onq2baPuHP21y","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EpMCClsIDBgCKkA6nIOABN0fTEW/b9Ta6w/nfDp3yfBW59dUUr+Wtt5weVvYA5CIHDg4mpiUTqwzLc75VrlZ12TXakmfdaVlaQs7MhFjbGF1ZGUtc29ubmV0LTQtNjgAEgzgmy8sV+ccyHJm9G8aDFbuhL8R9kPJCIZfJyIwmS/aIt2WX1ra0e9uLYyc67bmhiTdQSKY+4FXTD1T785AG47LD0bd39AUi2KnYGqSKmZNxr3L0cjngzmSP74nBJjg/TZYqMkYsEgN5IynP87a2JgHFfEfQwVKHDCI+znSDuhvOIh78noQk3Hc36LkP8QtNNIEkUq4+aesmMQTKsr8j0M0czsFFW71ReLduCefs3oDxwa1v4QYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3447,"cache_read_input_tokens":19420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3447},"output_tokens":35,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CZZJVhQYMTrYgMJ9qxeVP","type":"assistant","uuid":"cb0afae1-a909-498d-ab8d-13c12ca88ef8","timestamp":"2026-03-30T12:57:07.492Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"cb0afae1-a909-498d-ab8d-13c12ca88ef8","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01GJe4KouM9onq2baPuHP21y","type":"message","role":"assistant","content":[{"type":"text","text":"Let me also check the general authentication logout handling."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3447,"cache_read_input_tokens":19420,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3447},"output_tokens":35,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CZZJVhQYMTrYgMJ9qxeVP","type":"assistant","uuid":"4413cea5-3720-4c42-a6d2-a53aaa75a769","timestamp":"2026-03-30T12:57:08.159Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"4413cea5-3720-4c42-a6d2-a53aaa75a769","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01GJe4KouM9onq2baPuHP21y","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Uma2viehn7Me1gNEzhhoi1","name":"Grep","input":{"pattern":"logout|Logout|sign.?out","path":"/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth","output_mode":"content","-i":true},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3447,"cache_read_input_tokens":19420,"output_tokens":177,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":3447,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CZZJVhQYMTrYgMJ9qxeVP","type":"assistant","uuid":"b2ff2270-4887-4cb1-8a23-e7affed6aea1","timestamp":"2026-03-30T12:57:09.627Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"b2ff2270-4887-4cb1-8a23-e7affed6aea1","isSidechain":false,"promptId":"0f18784c-546f-4d81-81bd-69c25b663a3b","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Uma2viehn7Me1gNEzhhoi1","type":"tool_result","content":"internal/auth/auth.go:586:func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {"}]},"uuid":"940952cc-3274-4db8-a18a-66ac1f388593","timestamp":"2026-03-30T12:57:09.650Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"internal/auth/auth.go:586:func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {","numLines":1},"sourceToolAssistantUUID":"b2ff2270-4887-4cb1-8a23-e7affed6aea1","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"940952cc-3274-4db8-a18a-66ac1f388593","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01UphzhgrKFiiEShVY4ZVXMV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Hav8PEnCfTucMJj2tU8sc1","name":"Read","input":{"file_path":"/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go","offset":580,"limit":30},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":220,"cache_read_input_tokens":22867,"output_tokens":111,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":220,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CZZJW1MnoMmWyf2Yc9uE1","type":"assistant","uuid":"2bc70bc7-4ea7-4338-b604-5ed5264c2ce7","timestamp":"2026-03-30T12:57:12.780Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"2bc70bc7-4ea7-4338-b604-5ed5264c2ce7","isSidechain":false,"promptId":"0f18784c-546f-4d81-81bd-69c25b663a3b","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Hav8PEnCfTucMJj2tU8sc1","type":"tool_result","content":"580\t\t\t}\n581\t\t\tcclog.Info(\"auth frontend api -> authentication failed: no auth\")\n582\t\t\tonfailure(rw, r, errors.New(\"unauthorized\"))\n583\t\t})\n584\t}\n585\t\n586\tfunc (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {\n587\t\treturn http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n588\t\t\tsession, err := auth.sessionStore.Get(r, \"session\")\n589\t\t\tif err != nil {\n590\t\t\t\thttp.Error(rw, err.Error(), http.StatusInternalServerError)\n591\t\t\t\treturn\n592\t\t\t}\n593\t\n594\t\t\tif !session.IsNew {\n595\t\t\t\tsession.Options.MaxAge = -1\n596\t\t\t\tif err := auth.sessionStore.Save(r, rw, session); err != nil {\n597\t\t\t\t\thttp.Error(rw, err.Error(), http.StatusInternalServerError)\n598\t\t\t\t\treturn\n599\t\t\t\t}\n600\t\t\t}\n601\t\n602\t\t\tonsuccess.ServeHTTP(rw, r)\n603\t\t})\n604\t}\n605\t\n606\t// Helper Moved To MiddleWare Auth Handlers\n607\tfunc securedCheck(user *schema.User, r *http.Request) error {\n608\t\tif user == nil {\n609\t\t\treturn fmt.Errorf(\"no user for secured check\")\n\n\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n\n"}]},"uuid":"9aee5a75-2885-4556-b413-8172a78ffb36","timestamp":"2026-03-30T12:57:12.787Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go","content":"\t\t}\n\t\tcclog.Info(\"auth frontend api -> authentication failed: no auth\")\n\t\tonfailure(rw, r, errors.New(\"unauthorized\"))\n\t})\n}\n\nfunc (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n\t\tsession, err := auth.sessionStore.Get(r, \"session\")\n\t\tif err != nil {\n\t\t\thttp.Error(rw, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tif !session.IsNew {\n\t\t\tsession.Options.MaxAge = -1\n\t\t\tif err := auth.sessionStore.Save(r, rw, session); err != nil {\n\t\t\t\thttp.Error(rw, err.Error(), http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tonsuccess.ServeHTTP(rw, r)\n\t})\n}\n\n// Helper Moved To MiddleWare Auth Handlers\nfunc securedCheck(user *schema.User, r *http.Request) error {\n\tif user == nil {\n\t\treturn fmt.Errorf(\"no user for secured check\")","numLines":30,"startLine":580,"totalLines":644}},"sourceToolAssistantUUID":"2bc70bc7-4ea7-4338-b604-5ed5264c2ce7","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"9aee5a75-2885-4556-b413-8172a78ffb36","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01TUe74DY7rg1jxigRwPDAos","type":"message","role":"assistant","content":[{"type":"text","text":"Here's how logout works for OIDC-authenticated users:\n\n**Current behavior: session-only logout**\n\nWhen an OIDC-authenticated user hits `/logout`, the generic `Authentication.Logout` handler (`auth.go:586`) runs — it simply **expires the local session cookie** (`MaxAge = -1`). This logs the user out of cc-backend but does **not** perform an OIDC RP-initiated logout at the identity provider.\n\n**What this means in practice:**\n\n- The cc-backend session is destroyed — the user is logged out of the app.\n- The OIDC provider session (e.g., Keycloak SSO session) remains **active**.\n- If the user clicks \"OIDC Login\" again, they may be silently re-authenticated by the IdP without entering credentials (SSO still valid).\n\n**For a proper OIDC logout, you'd need RP-initiated logout:**\n\nThe OIDC spec defines an [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) flow where the client redirects the user to the IdP's `end_session_endpoint` (discoverable from the provider's `.well-known/openid-configuration`), optionally passing:\n- `id_token_hint` — the original ID token\n- `post_logout_redirect_uri` — where to redirect after IdP logout\n\nCurrently, cc-backend **doesn't implement this** — the `id_token` is not stored in the session after login (`OAuth2Callback` at line 231 only saves the user struct, not the raw token), so RP-initiated logout would require storing the ID token in the session and adding a new logout endpoint in `RegisterEndpoints`.\n\n**Summary:** Logout currently only clears the local cc-backend session. A full OIDC single-sign-out is not implemented."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":538,"cache_read_input_tokens":23087,"output_tokens":439,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":538,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CZZJWEvcofbKf5Q9vH1DK","type":"assistant","uuid":"26081cdd-8e78-40e0-b863-73a7f2b6dff7","timestamp":"2026-03-30T12:57:24.429Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"26081cdd-8e78-40e0-b863-73a7f2b6dff7","isSidechain":false,"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"entire hooks claude-code stop","durationMs":3468}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-03-30T12:57:27.970Z","uuid":"383c2a32-75da-4831-9cce-27150c400f95","toolUseID":"54fd5480-7823-47cf-828d-908be058dd0a","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"type":"file-history-snapshot","messageId":"fe28c3ff-5fb4-492b-9e76-ea106fdcef0d","snapshot":{"messageId":"fe28c3ff-5fb4-492b-9e76-ea106fdcef0d","trackedFileBackups":{},"timestamp":"2026-03-30T13:03:22.390Z"},"isSnapshotUpdate":false} {"parentUuid":"383c2a32-75da-4831-9cce-27150c400f95","isSidechain":false,"promptId":"870c65f9-3cb9-488d-8e1a-747eb4dbfd41","type":"user","message":{"role":"user","content":"Is a oidc single-sign-out common and supported?"},"uuid":"fe28c3ff-5fb4-492b-9e76-ea106fdcef0d","timestamp":"2026-03-30T13:03:21.731Z","permissionMode":"default","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"fe28c3ff-5fb4-492b-9e76-ea106fdcef0d","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01VHVFJY59QykUsyRQnHHzAt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EoYDClsIDBgCKkBVyVNA5Y5uDzh6Ez+IM5Ee98HJn6vudsUN7L+8p5SjNPm4MOpK9bhkf9AEvaAM2LQkrK+gY8F3TBWxRuRC4ANeMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwl2eYJqhc/ygdWXPgaDBVsxVYrWASGAWXfdCIwpcfhJPO+7P4nmZJDxLpj/LNVMvS6jmdHiSdis3WncX3eVmK2tRGO9CJ9uflnjzkAKtgBT0ZABuM7+SU5+AmNecow97VNtFZcSJ3aHQWO0sPItM4Eebk+MYUlDccDVKeIQqxgcRaMrfjoQMKzJWbrMQABXx1f7yFy4FoK0ui1Qr+WoFACqmomfmBbG4scab/tc8ch5u2FGotMA9oe23xAuAhfdGTwaevV9V6KZ0YRC7SrUIVk3oxP0MgczVESiRFcPNAu5zU1BObF3y73FoILAK77+ACbdNe4FOpt3CPFWlKX7NdFjqmoHU7SLo8SdXue8FmzhYHqfKo765fnsb1prFtsxYcRujIRVrsVGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":454,"cache_read_input_tokens":23625,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":454},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CZZJyWwn5bEpbPAi2oTzJ","type":"assistant","uuid":"6ce16b59-14a7-405d-848c-349e5f16d7e8","timestamp":"2026-03-30T13:03:25.774Z","userType":"external","entrypoint":"cli","cwd":"/Users/jan/prg/ClusterCockpit/cc-backend","sessionId":"ac9af4ff-f729-4da8-b598-ef3b719bbd57","version":"2.1.87","gitBranch":"hotfix","slug":"parallel-sleeping-ember"} {"parentUuid":"6ce16b59-14a7-405d-848c-349e5f16d7e8","isSidechain":false,"message":{"model":"claude-sonnet-4-6","id":"msg_01VHVFJY59QykUsyRQnHHzAt","type":"message","role":"assistant","content":[{"type":"text","text":"**Short answer: It's standardized but inconsistently supported and often deliberately skipped.**\n\n## The specs\n\nThere are three OIDC logout specifications:\n\n1. **RP-Initiated Logout** — client redirects user to IdP's `end_session_endpoint`. Widely implemented (Keycloak, Azure AD, Okta, Auth0 all support it). This is the most practical to implement.\n\n2. **Front-Channel Logout** — IdP sends logout requests to all RPs via invisible `