How can you logout if logged in in openid connect?


1	// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
2	// All rights reserved. This file is part of cc-backend.
3	// Use of this source code is governed by a MIT-style
4	// license that can be found in the LICENSE file.
5
6	package auth
7
8	import (
9		"context"
10		"crypto/rand"
11		"encoding/base64"
12		"fmt"
13		"io"
14		"net/http"
15		"os"
16		"time"
17
18		"github.com/ClusterCockpit/cc-backend/internal/repository"
19		cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
20		"github.com/ClusterCockpit/cc-lib/v2/schema"
21		"github.com/coreos/go-oidc/v3/oidc"
22		"github.com/go-chi/chi/v5"
23		"golang.org/x/oauth2"
24	)
25
26	type OpenIDConfig struct {
27		Provider          string `json:"provider"`
28		SyncUserOnLogin   bool   `json:"sync-user-on-login"`
29		UpdateUserOnLogin bool   `json:"update-user-on-login"`
30	}
31
32	type OIDC struct {
33		client         *oauth2.Config
34		provider       *oidc.Provider
35		authentication *Authentication
36		clientID       string
37	}
38
39	func randString(nByte int) (string, error) {
40		b := make([]byte, nByte)
41		if _, err := io.ReadFull(rand.Reader, b); err != nil {
42			return "", err
43		}
44		return base64.RawURLEncoding.EncodeToString(b), nil
45	}
46
47	func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
48		c := &http.Cookie{
49			Name:     name,
50			Value:    value,
51			MaxAge:   int(time.Hour.Seconds()),
52			Secure:   r.TLS != nil,
53			HttpOnly: true,
54			SameSite: http.SameSiteLaxMode,
55		}
56		http.SetCookie(w, c)
57	}
58
59	// NewOIDC creates a new OIDC authenticator with the configured provider
60	func NewOIDC(a *Authentication) *OIDC {
61		// Use context with timeout for provider initialization
62		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
63		defer cancel()
64
65		provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
66		if err != nil {
67			cclog.Fatal(err)
68		}
69		clientID := os.Getenv("OID_CLIENT_ID")
70		if clientID == "" {
71			cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
72		}
73		clientSecret := os.Getenv("OID_CLIENT_SECRET")
74		if clientSecret == "" {
75			cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
76		}
77
78		client := &oauth2.Config{
79			ClientID:     clientID,
80			ClientSecret: clientSecret,
81			Endpoint:     provider.Endpoint(),
82			Scopes:       []string{oidc.ScopeOpenID, "profile"},
83		}
84
85		oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}
86
87		return oa
88	}
89
90	func (oa *OIDC) RegisterEndpoints(r chi.Router) {
91		r.HandleFunc("/oidc-login", oa.OAuth2Login)
92		r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
93	}
94
95	func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
96		c, err := r.Cookie("state")
97		if err != nil {
98			http.Error(rw, "state cookie not found", http.StatusBadRequest)
99			return
100		}
101		state := c.Value
102
103		c, err = r.Cookie("verifier")
104		if err != nil {
105			http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
106			return
107		}
108		codeVerifier := c.Value
109
110		_ = r.ParseForm()
111		if r.Form.Get("state") != state {
112			http.Error(rw, "State invalid", http.StatusBadRequest)
113			return
114		}
115		code := r.Form.Get("code")
116		if code == "" {
117			http.Error(rw, "Code not found", http.StatusBadRequest)
118			return
119		}
120		// Exchange authorization code for token with timeout
121		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
122		defer cancel()
123
124		token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
125		if err != nil {
126			cclog.Errorf("token exchange failed: %s", err.Error())
127			http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
128			return
129		}
130
131		// Get user info from OIDC provider with same timeout
132		userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
133		if err != nil {
134			cclog.Errorf("failed to get userinfo: %s", err.Error())
135			http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
136			return
137		}
138
139		// Verify ID token and nonce to prevent replay attacks
140		rawIDToken, ok := token.Extra("id_token").(string)
141		if !ok {
142			http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
143			return
144		}
145
146		nonceCookie, err := r.Cookie("nonce")
147		if err != nil {
148			http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
149			return
150		}
151
152		verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
153		idToken, err := verifier.Verify(ctx, rawIDToken)
154		if err != nil {
155			cclog.Errorf("ID token verification failed: %s", err.Error())
156			http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
157			return
158		}
159
160		if idToken.Nonce != nonceCookie.Value {
161			http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
162			return
163		}
164
165		projects := make([]string, 0)
166
167		// Extract custom claims from userinfo
168		var claims struct {
169			Username string `json:"preferred_username"`
170			Name     string `json:"name"`
171			// Keycloak realm-level roles
172			RealmAccess struct {
173				Roles []string `json:"roles"`
174			} `json:"realm_access"`
175			// Keycloak client-level roles
176			ResourceAccess struct {
177				Client struct {
178					Roles []string `json:"roles"`
179				} `json:"clustercockpit"`
180			} `json:"resource_access"`
181		}
182		if err := userInfo.Claims(&claims); err != nil {
183			cclog.Errorf("failed to extract claims: %s", err.Error())
184			http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
185			return
186		}
187
188		if claims.Username == "" {
189			http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
190			return
191		}
192
193		// Merge roles from both client-level and realm-level access
194		oidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)
195
196		roleSet := make(map[string]bool)
197		for _, r := range oidcRoles {
198			switch r {
199			case "user":
200				roleSet[schema.GetRoleString(schema.RoleUser)] = true
201			case "admin":
202				roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
203			case "manager":
204				roleSet[schema.GetRoleString(schema.RoleManager)] = true
205			case "support":
206				roleSet[schema.GetRoleString(schema.RoleSupport)] = true
207			}
208		}
209
210		var roles []string
211		for role := range roleSet {
212			roles = append(roles, role)
213		}
214
215		if len(roles) == 0 {
216			roles = append(roles, schema.GetRoleString(schema.RoleUser))
217		}
218
219		user := &schema.User{
220			Username:   claims.Username,
221			Name:       claims.Name,
222			Roles:      roles,
223			Projects:   projects,
224			AuthSource: schema.AuthViaOIDC,
225		}
226
227		if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
228			handleOIDCUser(user)
229		}
230
231		if err := oa.authentication.SaveSession(rw, r, user); err != nil {
232			cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
233			http.Error(rw, "Failed to create session", http.StatusInternalServerError)
234			return
235		}
236		cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
237		userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
238		http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
239	}
240
241	func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
242		state, err := randString(16)
243		if err != nil {
244			http.Error(rw, "Internal error", http.StatusInternalServerError)
245			return
246		}
247		setCallbackCookie(rw, r, "state", state)
248
249		// use PKCE to protect against CSRF attacks
250		codeVerifier := oauth2.GenerateVerifier()
251		setCallbackCookie(rw, r, "verifier", codeVerifier)
252
253		// Generate nonce for ID token replay protection
254		nonce, err := randString(16)
255		if err != nil {
256			http.Error(rw, "Internal error", http.StatusInternalServerError)
257			return
258		}
259		setCallbackCookie(rw, r, "nonce", nonce)
260
261		// Build redirect URL from the incoming request
262		scheme := "https"
263		if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
264			scheme = "http"
265		}
266		oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)
267
268		// Redirect user to consent page to ask for permission
269		url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
270			oauth2.S256ChallengeOption(codeVerifier),
271			oidc.Nonce(nonce))
272		http.Redirect(rw, r, url, http.StatusFound)
273	}
274

<system-reminder>
Whenever 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.
</system-reminder>

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
)

type OpenIDConfig struct {
	Provider          string `json:"provider"`
	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
	UpdateUserOnLogin bool   `json:"update-user-on-login"`
}

type OIDC struct {
	client         *oauth2.Config
	provider       *oidc.Provider
	authentication *Authentication
	clientID       string
}

func randString(nByte int) (string, error) {
	b := make([]byte, nByte)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
	c := &http.Cookie{
		Name:     name,
		Value:    value,
		MaxAge:   int(time.Hour.Seconds()),
		Secure:   r.TLS != nil,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, c)
}

// NewOIDC creates a new OIDC authenticator with the configured provider
func NewOIDC(a *Authentication) *OIDC {
	// Use context with timeout for provider initialization
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
	if err != nil {
		cclog.Fatal(err)
	}
	clientID := os.Getenv("OID_CLIENT_ID")
	if clientID == "" {
		cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
	}
	clientSecret := os.Getenv("OID_CLIENT_SECRET")
	if clientSecret == "" {
		cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
	}

	client := &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "profile"},
	}

	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}

	return oa
}

func (oa *OIDC) RegisterEndpoints(r chi.Router) {
	r.HandleFunc("/oidc-login", oa.OAuth2Login)
	r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
}

func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("state")
	if err != nil {
		http.Error(rw, "state cookie not found", http.StatusBadRequest)
		return
	}
	state := c.Value

	c, err = r.Cookie("verifier")
	if err != nil {
		http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
		return
	}
	codeVerifier := c.Value

	_ = r.ParseForm()
	if r.Form.Get("state") != state {
		http.Error(rw, "State invalid", http.StatusBadRequest)
		return
	}
	code := r.Form.Get("code")
	if code == "" {
		http.Error(rw, "Code not found", http.StatusBadRequest)
		return
	}
	// Exchange authorization code for token with timeout
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
	if err != nil {
		cclog.Errorf("token exchange failed: %s", err.Error())
		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
		return
	}

	// Get user info from OIDC provider with same timeout
	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
	if err != nil {
		cclog.Errorf("failed to get userinfo: %s", err.Error())
		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
		return
	}

	// Verify ID token and nonce to prevent replay attacks
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
		return
	}

	nonceCookie, err := r.Cookie("nonce")
	if err != nil {
		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
		return
	}

	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
	idToken, err := verifier.Verify(ctx, rawIDToken)
	if err != nil {
		cclog.Errorf("ID token verification failed: %s", err.Error())
		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
		return
	}

	if idToken.Nonce != nonceCookie.Value {
		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
		return
	}

	projects := make([]string, 0)

	// Extract custom claims from userinfo
	var claims struct {
		Username string `json:"preferred_username"`
		Name     string `json:"name"`
		// Keycloak realm-level roles
		RealmAccess struct {
			Roles []string `json:"roles"`
		} `json:"realm_access"`
		// Keycloak client-level roles
		ResourceAccess struct {
			Client struct {
				Roles []string `json:"roles"`
			} `json:"clustercockpit"`
		} `json:"resource_access"`
	}
	if err := userInfo.Claims(&claims); err != nil {
		cclog.Errorf("failed to extract claims: %s", err.Error())
		http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
		return
	}

	if claims.Username == "" {
		http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
		return
	}

	// Merge roles from both client-level and realm-level access
	oidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)

	roleSet := make(map[string]bool)
	for _, r := range oidcRoles {
		switch r {
		case "user":
			roleSet[schema.GetRoleString(schema.RoleUser)] = true
		case "admin":
			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
		case "manager":
			roleSet[schema.GetRoleString(schema.RoleManager)] = true
		case "support":
			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
		}
	}

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   claims.Username,
		Name:       claims.Name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)
	}

	if err := oa.authentication.SaveSession(rw, r, user); err != nil {
		cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
		http.Error(rw, "Failed to create session", http.StatusInternalServerError)
		return
	}
	cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
	http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
}

func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
	state, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "state", state)

	// use PKCE to protect against CSRF attacks
	codeVerifier := oauth2.GenerateVerifier()
	setCallbackCookie(rw, r, "verifier", codeVerifier)

	// Generate nonce for ID token replay protection
	nonce, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "nonce", nonce)

	// Build redirect URL from the incoming request
	scheme := "https"
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
		scheme = "http"
	}
	oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)

	// Redirect user to consent page to ask for permission
	url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
		oauth2.S256ChallengeOption(codeVerifier),
		oidc.Nonce(nonce))
	http.Redirect(rw, r, url, http.StatusFound)
}


Let me also check the general authentication logout handling.


internal/auth/auth.go:586:func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {


580			}
581			cclog.Info("auth frontend api -> authentication failed: no auth")
582			onfailure(rw, r, errors.New("unauthorized"))
583		})
584	}
585
586	func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
587		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
588			session, err := auth.sessionStore.Get(r, "session")
589			if err != nil {
590				http.Error(rw, err.Error(), http.StatusInternalServerError)
591				return
592			}
593
594			if !session.IsNew {
595				session.Options.MaxAge = -1
596				if err := auth.sessionStore.Save(r, rw, session); err != nil {
597					http.Error(rw, err.Error(), http.StatusInternalServerError)
598					return
599				}
600			}
601
602			onsuccess.ServeHTTP(rw, r)
603		})
604	}
605
606	// Helper Moved To MiddleWare Auth Handlers
607	func securedCheck(user *schema.User, r *http.Request) error {
608		if user == nil {
609			return fmt.Errorf("no user for secured check")

<system-reminder>
Whenever 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.
</system-reminder>

		}
		cclog.Info("auth frontend api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		session, err := auth.sessionStore.Get(r, "session")
		if err != nil {
			http.Error(rw, err.Error(), http.StatusInternalServerError)
			return
		}

		if !session.IsNew {
			session.Options.MaxAge = -1
			if err := auth.sessionStore.Save(r, rw, session); err != nil {
				http.Error(rw, err.Error(), http.StatusInternalServerError)
				return
			}
		}

		onsuccess.ServeHTTP(rw, r)
	})
}

// Helper Moved To MiddleWare Auth Handlers
func securedCheck(user *schema.User, r *http.Request) error {
	if user == nil {
		return fmt.Errorf("no user for secured check")


Here's how logout works for OIDC-authenticated users:

**Current behavior: session-only logout**

When 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.

**What this means in practice:**

- The cc-backend session is destroyed — the user is logged out of the app.
- The OIDC provider session (e.g., Keycloak SSO session) remains **active**.
- If the user clicks "OIDC Login" again, they may be silently re-authenticated by the IdP without entering credentials (SSO still valid).

**For a proper OIDC logout, you'd need RP-initiated logout:**

The 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:
- `id_token_hint` — the original ID token
- `post_logout_redirect_uri` — where to redirect after IdP logout

Currently, 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`.

**Summary:** Logout currently only clears the local cc-backend session. A full OIDC single-sign-out is not implemented.


Is a oidc single-sign-out common and supported?


**Short answer: It's standardized but inconsistently supported and often deliberately skipped.**

## The specs

There are three OIDC logout specifications:

1. **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.

2. **Front-Channel Logout** — IdP sends logout requests to all RPs via invisible `<iframe>` tags in the user's browser. Fragile — blocked by browsers that restrict third-party cookies/iframes (Safari ITP, Firefox ETP). Increasingly broken.

3. **Back-Channel Logout** — IdP sends a signed JWT (logout token) via HTTP POST directly to each RP's logout endpoint (server-to-server, no browser involved). Most robust but requires the RP to maintain a token revocation store. Not universally supported by IdPs.

## Reality in practice

- **Many apps skip it intentionally.** Session-only logout (what cc-backend does) is extremely common, especially for internal tools. The argument is: if your IdP session outlives your app session, that's an acceptable tradeoff for most threat models.
- **RP-initiated logout is the sweet spot** — low implementation cost, widely supported, and covers the most common user expectation ("clicking logout actually logs me out of SSO too").
- **Back-channel logout is rare** — mainly seen in high-security or financial applications where you need guaranteed propagation across multiple services.
- **Front-channel logout is declining** — browser privacy changes have made it unreliable.

## For cc-backend specifically

RP-initiated logout would be the natural next step if needed — Keycloak supports it, and it only requires:
1. Storing the `id_token` raw string in the session at login
2. On logout, redirecting to the IdP's `end_session_endpoint` instead of just expiring the cookie

Whether it's worth it depends on the deployment context — for an HPC cluster management tool, session-only logout is likely sufficient for most sites.


For an oidc initiated user session no logout button is shown in the frontend ui. Ivestigate the issue and fix it


Agent

Explore logout button and auth source in frontend

Prompt:
In the cc-backend project at /Users/jan/prg/ClusterCockpit/cc-backend, investigate why no logout button is shown in the frontend UI for OIDC-authenticated users.

Please explore:
1. Frontend Svelte components related to logout, user menu, or navigation (web/frontend/)
2. How the user's auth source (AuthViaOIDC, AuthViaLocal, etc.) is passed to the frontend
3. Any conditional rendering logic around the logout button based on auth source
4. Go templates in web/templates/ that might render logout UI
5. The schema.User struct fields, especially AuthSource

Report all relevant file paths, line numbers, and code snippets.


Perfect! Now I have everything I need. Let me create a final comprehensive report:

## Investigation Report: Missing Logout Button for OIDC Users

I've completed a thorough investigation of why no logout button is shown in the frontend UI for OIDC-authenticated users in the cc-backend project. Here are my findings:

### Root Cause Analysis

**The problem is NOT that the logout button is missing from OIDC users.** The actual issue is more subtle: **The `AuthSource` field is never stored in the session and never passed to the frontend, so the frontend cannot make auth-source-aware decisions about showing/hiding UI elements.**

### Key Findings

#### 1. Frontend Logout Button Implementation
**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/header/NavbarTools.svelte`

Lines 104-122 (desktop view) and 154-165 (mobile view):
```svelte
{#if username}
  <NavItem>
    <form method="POST" action="/logout">
      <Button
        outline
        color="success"
        type="submit"
        style="margin-left: 10px;"
        title="Logout {username}"
      >
        {#if screenSize > 1630}
          <Icon name="box-arrow-right" /> Logout {username}
        {:else}
          <Icon name="box-arrow-right" />
        {/if}
      </Button>
    </form>
  </NavItem>
{/if}
```

**Issue:** The logout button renders based on a simple check: `{#if username}`. There is NO conditional logic based on `AuthSource`.

#### 2. Username/AuthLevel Passed to Frontend
**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/web/templates/base.tmpl` (lines 16-23)
```html
<script>
    // Used for header.entrypoint.js mount and filters/Resources.svelte
    const hUsername = {{ .User.Username }};
    const hAuthlevel = {{ .User.GetAuthLevel }};
    const hClusterNames = {{ .Clusters }};
    const hSubclusterMap = {{ .SubClusters }};
    const hRoles = {{ .Roles }};
</script>
```

The template passes `hUsername` and `hAuthlevel`, but **there is NO `AuthSource` being passed to the frontend**.

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/header.entrypoint.js` (lines 7-15)
```javascript
const headerDomTarget = document.getElementById('svelte-header');

if (headerDomTarget != null) {
    mount(Header, {
        target: headerDomTarget,
        props: {
            username: hUsername,
            authlevel: hAuthlevel,
            clusterNames: hClusterNames,
            subclusterMap: hSubclusterMap,
            roles: hRoles,
        },
    });
}
```

Only `username`, `authlevel`, `clusterNames`, `subclusterMap`, and `roles` are passed. No `AuthSource` information.

#### 3. Session Storage Does NOT Include AuthSource
**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go` (lines 302-333)

The `SaveSession` function saves session data:
```go
session.Values["username"] = user.Username
session.Values["projects"] = user.Projects
session.Values["roles"] = user.Roles
```

**Critical Missing Line:** There is NO `session.Values["authSource"] = user.AuthSource`

The corresponding `AuthViaSession` function (lines 128-174) reconstructs the user:
```go
return &schema.User{
    Username:   username,
    Projects:   projects,
    Roles:      roles,
    AuthType:   schema.AuthSession,
    AuthSource: -1,  // <-- ALWAYS SET TO -1, NEVER RETRIEVED FROM SESSION
}, nil
```

The `AuthSource` is hardcoded to `-1` instead of being retrieved from the session!

#### 4. OIDC Sets AuthSource Correctly
**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go` (line 224)

When OIDC authenticates a user, the `AuthSource` is set:
```go
user := &schema.User{
    Username:   claims.Username,
    Name:       claims.Name,
    Roles:      roles,
    Projects:   projects,
    AuthSource: schema.AuthViaOIDC,  // <-- CORRECTLY SET
}
```

But this value is never persisted to the session!

#### 5. AuthSource Constants Definition
**File:** `/Users/jan/prg/ClusterCockpit/cc-lib/schema/user.go` (lines 28-37)

```go
// AuthSource identifies the authentication backend that validated a user.
type AuthSource int

const (
    AuthViaLocalPassword AuthSource = iota // 0
    AuthViaLDAP                            // 1
    AuthViaToken                           // 2
    AuthViaOIDC                            // 3
    AuthViaAll                             // 4
)
```

#### 6. User Struct Definition
**File:** `/Users/jan/prg/ClusterCockpit/cc-lib/schema/user.go` (lines 51-60)

```go
type User struct {
    Username   string     `json:"username"`
    Password   string     `json:"-"`
    Name       string     `json:"name"`
    Email      string     `json:"email"`
    Roles      []string   `json:"roles"`
    Projects   []string   `json:"projects"`
    AuthType   AuthType   `json:"authType"`
    AuthSource AuthSource `json:"authSource"`  // <-- DEFINED BUT NEVER PASSED TO FRONTEND
}
```

#### 7. Logout Endpoint
**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go` (lines 180-191)

The logout endpoint is simple and doesn't differentiate by auth source:
```go
s.router.Post("/logout", authHandle.Logout(
    http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
        rw.Header().Add("Content-Type", "text/html; charset=utf-8")
        rw.WriteHeader(http.StatusOK)
        web.RenderTemplate(rw, "login.tmpl", &web.Page{
            Title:   "Bye - ClusterCockpit",
            MsgType: "alert-info",
            Message: "Logout successful",
            Build:   buildInfo,
            Infos:   info,
        })
    })).ServeHTTP)
```

The logout handler (in `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`, lines 586-604) just invalidates the session cookie - it doesn't handle OIDC-specific logout (e.g., calling the OIDC provider's logout endpoint).

### Summary of Issues

| Issue | Location | Details |
|-------|----------|---------|
| **AuthSource not stored in session** | `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go` lines 323-325 | Session only saves username, projects, roles - not AuthSource |
| **AuthSource not retrieved from session** | `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go` line 172 | AuthSource hardcoded to -1 instead of reading from session |
| **AuthSource not passed to frontend** | `/Users/jan/prg/ClusterCockpit/cc-backend/web/templates/base.tmpl` lines 16-23 | Template doesn't expose AuthSource in JavaScript |
| **No conditional logout UI** | `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/header/NavbarTools.svelte` lines 104-165 | Logout button shows for any non-empty username, no auth-source checks |
| **No OIDC-specific logout** | `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go` lines 586-604 | Logout endpoint doesn't call OIDC provider's logout endpoint |

### Technical Flow

1. **OIDC Login** → User authenticated, `AuthSource` set to `AuthViaOIDC` ✅
2. **Save Session** → `AuthSource` NOT saved to session ❌
3. **Retrieve Session** → `AuthSource` set to -1 (default) ❌
4. **Pass to Frontend** → `AuthSource` never exposed in template ❌
5. **Render UI** → No conditional logic to hide/show logout for OIDC ❌

### Potential Solutions (Not Implemented)

To properly support OIDC logout, the following would need to be done:

1. Store `AuthSource` in the session cookie
2. Retrieve `AuthSource` from the session and pass it to frontend
3. Add conditional rendering in the logout button component based on `AuthSource`
4. Implement OIDC provider logout (logout endpoint call, redirect)

Prompt:
In the cc-backend project at /Users/jan/prg/ClusterCockpit/cc-backend, investigate why no logout button is shown in the frontend UI for OIDC-authenticated users.

Please explore:
1. Frontend Svelte components related to logout, user menu, or navigation (web/frontend/)
2. How the user's auth source (AuthViaOIDC, AuthViaLocal, etc.) is passed to the frontend
3. Any conditional rendering logic around the logout button based on auth source
4. Go templates in web/templates/ that might render logout UI
5. The schema.User struct fields, especially AuthSource

Report all relevant file paths, line numbers, and code snippets.


Good findings from the exploration. Let me read the critical files directly to understand the exact rendering logic.


1	<!DOCTYPE html>
2
3
4	        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
5	        <meta name='viewport' content='width=device-width,initial-scale=1'>
6	        <title>{{.Title}}</title>
7
8	        <link rel='icon' type='image/png' href='/favicon.png'>
9	        <link rel="stylesheet" href="/bootstrap.min.css">
10	        <link rel="stylesheet" href="/bootstrap-icons.css">
11	        <link rel='stylesheet' href='/global.css'>
12	        <link rel='stylesheet' href='/uPlot.min.css'>
13
14	        {{block "stylesheets" .}}
15	        {{end}}
16
24
25
26	        {{if eq .Infos.displayType "PUBLIC"}}
27
28

29	                    {{block "content-public" .}}
30	                        Whoops, you should not see this... [PUBLIC]
31	                    {{end}}
32

33
34
35	            {{block "javascript-public" .}}
36	                Whoops, you should not see this... [JS PUBLIC]
37	            {{end}}
38
39	        {{else}}
40	            {{block "navigation" .}}
41
42	            {{end}}
43
44
45

46	                    {{block "content" .}}
47	                        Whoops, you should not see this... [MAIN]
48	                    {{end}}
49

50
51
52	            {{block "footer" .}}
53
54

55
Imprint

56
Privacy Policy

57

58

59
Version {{ .Build.Version }}

60
Hash {{ .Build.Hash }}

61
Built {{ .Build.Buildtime }}

62

63
64	            {{end}}
65
66	            {{block "javascript" .}}
67
68	            {{end}}
69	        {{end}}
70
71
72

<system-reminder>
Whenever 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.
</system-reminder>

<!DOCTYPE html>

        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <meta name='viewport' content='width=device-width,initial-scale=1'>
        <title>{{.Title}}</title>

        <link rel='icon' type='image/png' href='/favicon.png'>
        <link rel="stylesheet" href="/bootstrap.min.css">
        <link rel="stylesheet" href="/bootstrap-icons.css">
        <link rel='stylesheet' href='/global.css'>
        <link rel='stylesheet' href='/uPlot.min.css'>

        {{block "stylesheets" .}}
        {{end}}

        {{if eq .Infos.displayType "PUBLIC"}}

                    {{block "content-public" .}}
                        Whoops, you should not see this... [PUBLIC]
                    {{end}}

            {{block "javascript-public" .}}
                Whoops, you should not see this... [JS PUBLIC]
            {{end}}

        {{else}}
            {{block "navigation" .}}

            {{end}}

                    {{block "content" .}}
                        Whoops, you should not see this... [MAIN]
                    {{end}}

            {{block "footer" .}}

Imprint

Privacy Policy

Version {{ .Build.Version }}

Hash {{ .Build.Hash }}

Built {{ .Build.Buildtime }}

            {{end}}

            {{block "javascript" .}}

            {{end}}
        {{end}}


1	<!--
2	  @component Navbar component; renders in app resource links and user dropdown
3
4	  Properties:
5	  - `username String!`: Empty string if auth. is disabled, otherwise the username as string
6	  - `authlevel Number`: The current users authentication level
7	  - `roles [Number]`: Enum containing available roles
8	  - `screenSize Number`: The current window size, will trigger different render variants
9	-->
10
11	<script>
12	  import {
13	    Icon,
14	    Nav,
15	    NavItem,
16	    InputGroup,
17	    Input,
18	    Button,
19	    InputGroupText,
20	    Container,
21	    Dropdown,
22	    DropdownToggle,
23	    DropdownMenu,
24	    Row,
25	    Col,
26	  } from "@sveltestrap/sveltestrap";
27
28	  /* Svelte 5 Props */
29	  let {
30	    username,
31	    authlevel,
32	    roles,
33	    screenSize
34	  } = $props();
35	</script>
36
37	<Nav navbar>
38	  {#if screenSize >= 768}
39	    {#if screenSize <= 992}
40	      <NavItem>
41	        <form method="GET" action="/search">
42	          <Dropdown >
43	            <DropdownToggle outline color="secondary"><Icon name="search" /> Search</DropdownToggle>
44	            <DropdownMenu style="width:200px;">
45	              <InputGroup>
46	                <Input
47	                  type="search"
48	                  placeholder="type:<query> ..."
49	                  name="searchId"
50	                  class="mx-2"
51	                />
52	              </InputGroup>
53	            </DropdownMenu>
54	          </Dropdown>
55	        </form>
56	      </NavItem>
57	    {:else}
58	      <NavItem>
59	        <form method="GET" action="/search">
60	          <InputGroup>
61	            <Input
62	              type="search"
63	              placeholder="Search 'type:<query>' ..."
64	              name="searchId"
65	              style="margin-left: 10px;"
66	            />
67	            <!-- bootstrap classes w/o effect -->
68	            <Button outline type="submit" title="Search"
69	              ><Icon name="search" /></Button
70	            >
71	            <InputGroupText
72	              style="cursor:help;"
73	              title={authlevel >= roles.support
74	                ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | arrayJobId | username | name"
75	                : "Example: 'jobName:myjob', Types are jobId | jobName | projectId | arrayJobId "}
76	              ><Icon name="info-circle" /></InputGroupText
77	            >
78	          </InputGroup>
79	        </form>
80	      </NavItem>
81	    {/if}
82	    <NavItem>
83	      <a
84	        href="https://www.clustercockpit.org/docs/webinterface/"
85	        title="Documentation"
86	        rel="nofollow"
87	        target="_blank"
88	      >
89	        <Button outline style="margin-left: 10px;">
90	          <Icon name="book" />
91	        </Button>
92	      </a>
93	    </NavItem>
94	    <NavItem>
95	      <Button
96	        outline
97	        onclick={() => (window.location.href = "/config")}
98	        style="margin-left: 10px;"
99	        title="Settings"
100	      >
101	        <Icon name="gear" />
102	      </Button>
103	    </NavItem>
104	    {#if username}
105	      <NavItem>
106	        <form method="POST" action="/logout">
107	          <Button
108	            outline
109	            color="success"
110	            type="submit"
111	            style="margin-left: 10px;"
112	            title="Logout {username}"
113	          >
114	            {#if screenSize > 1630}
115	              <Icon name="box-arrow-right" /> Logout {username}
116	            {:else}
117	              <Icon name="box-arrow-right" />
118	            {/if}
119	          </Button>
120	        </form>
121	      </NavItem>
122	    {/if}
123	  {:else}
124	    <NavItem>
125	      <Container>
126	        <Row cols={3}>
127	          <Col xs="4">
128	            <a
129	              href="https://www.clustercockpit.org/docs/webinterface/"
130	              title="Documentation"
131	              rel="nofollow"
132	              target="_blank"
133	            >
134	              <Button outline size="sm" class="my-2 w-100">
135	                <Icon name="box-arrow-up-right" /> Documentation
136	              </Button>
137	            </a>
138	          </Col>
139	          <Col xs="4">
140	            <Button
141	              outline
142	              onclick={() => (window.location.href = "/config")}
143	              size="sm"
144	              class="my-2 w-100"
145	            >
146	              {#if authlevel >= roles.admin}
147	                <Icon name="gear" /> Admin Settings
148	              {:else}
149	                <Icon name="gear" /> Plotting Options
150	              {/if}
151	            </Button>
152	          </Col>
153	          <Col xs="4">
154	            <form method="POST" action="/logout">
155	              <Button
156	                outline
157	                color="success"
158	                type="submit"
159	                size="sm"
160	                class="my-2 w-100"
161	              >
162	                <Icon name="box-arrow-right" /> Logout {username}
163	              </Button>
164	            </form>
165	          </Col>
166	        </Row>
167	      </Container>
168	    </NavItem>
169	    <NavItem style="margin-left: 10px; margin-right:10px;">
170	      <form method="GET" action="/search">
171	        <InputGroup>
172	          <Input
173	            type="text"
174	            placeholder="Search 'type:<query>' ..."
175	            name="searchId"
176	          />
177	          <Button outline type="submit"><Icon name="search" /></Button>
178	          <InputGroupText
179	            style="cursor:help;"
180	            title={authlevel >= roles.support
181	              ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | arrayJobId | username | name"
182	              : "Example: 'jobName:myjob', Types are jobId | jobName | projectId | arrayJobId "}
183	            ><Icon name="info-circle" /></InputGroupText
184	          >
185	        </InputGroup>
186	      </form>
187	    </NavItem>
188	  {/if}
189	</Nav>
190

<system-reminder>
Whenever 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.
</system-reminder>

<!--
  @component Navbar component; renders in app resource links and user dropdown

  Properties:
  - `username String!`: Empty string if auth. is disabled, otherwise the username as string
  - `authlevel Number`: The current users authentication level
  - `roles [Number]`: Enum containing available roles
  - `screenSize Number`: The current window size, will trigger different render variants
-->

<script>
  import {
    Icon,
    Nav,
    NavItem,
    InputGroup,
    Input,
    Button,
    InputGroupText,
    Container,
    Dropdown,
    DropdownToggle,
    DropdownMenu,
    Row,
    Col,
  } from "@sveltestrap/sveltestrap";

  /* Svelte 5 Props */
  let {
    username,
    authlevel,
    roles,
    screenSize
  } = $props();
</script>

<Nav navbar>
  {#if screenSize >= 768}
    {#if screenSize <= 992}
      <NavItem>
        <form method="GET" action="/search">
          <Dropdown >
            <DropdownToggle outline color="secondary"><Icon name="search" /> Search</DropdownToggle>
            <DropdownMenu style="width:200px;">
              <InputGroup>
                <Input
                  type="search"
                  placeholder="type:<query> ..."
                  name="searchId"
                  class="mx-2"
                />
              </InputGroup>
            </DropdownMenu>
          </Dropdown>
        </form>
      </NavItem>
    {:else}
      <NavItem>
        <form method="GET" action="/search">
          <InputGroup>
            <Input
              type="search"
              placeholder="Search 'type:<query>' ..."
              name="searchId"
              style="margin-left: 10px;"
            />
            <!-- bootstrap classes w/o effect -->
            <Button outline type="submit" title="Search"
              ><Icon name="search" /></Button
            >
            <InputGroupText
              style="cursor:help;"
              title={authlevel >= roles.support
                ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | arrayJobId | username | name"
                : "Example: 'jobName:myjob', Types are jobId | jobName | projectId | arrayJobId "}
              ><Icon name="info-circle" /></InputGroupText
            >
          </InputGroup>
        </form>
      </NavItem>
    {/if}
    <NavItem>
      <a
        href="https://www.clustercockpit.org/docs/webinterface/"
        title="Documentation"
        rel="nofollow"
        target="_blank"
      >
        <Button outline style="margin-left: 10px;">
          <Icon name="book" />
        </Button>
      </a>
    </NavItem>
    <NavItem>
      <Button
        outline
        onclick={() => (window.location.href = "/config")}
        style="margin-left: 10px;"
        title="Settings"
      >
        <Icon name="gear" />
      </Button>
    </NavItem>
    {#if username}
      <NavItem>
        <form method="POST" action="/logout">
          <Button
            outline
            color="success"
            type="submit"
            style="margin-left: 10px;"
            title="Logout {username}"
          >
            {#if screenSize > 1630}
              <Icon name="box-arrow-right" /> Logout {username}
            {:else}
              <Icon name="box-arrow-right" />
            {/if}
          </Button>
        </form>
      </NavItem>
    {/if}
  {:else}
    <NavItem>
      <Container>
        <Row cols={3}>
          <Col xs="4">
            <a
              href="https://www.clustercockpit.org/docs/webinterface/"
              title="Documentation"
              rel="nofollow"
              target="_blank"
            >
              <Button outline size="sm" class="my-2 w-100">
                <Icon name="box-arrow-up-right" /> Documentation
              </Button>
            </a>
          </Col>
          <Col xs="4">
            <Button
              outline
              onclick={() => (window.location.href = "/config")}
              size="sm"
              class="my-2 w-100"
            >
              {#if authlevel >= roles.admin}
                <Icon name="gear" /> Admin Settings
              {:else}
                <Icon name="gear" /> Plotting Options
              {/if}
            </Button>
          </Col>
          <Col xs="4">
            <form method="POST" action="/logout">
              <Button
                outline
                color="success"
                type="submit"
                size="sm"
                class="my-2 w-100"
              >
                <Icon name="box-arrow-right" /> Logout {username}
              </Button>
            </form>
          </Col>
        </Row>
      </Container>
    </NavItem>
    <NavItem style="margin-left: 10px; margin-right:10px;">
      <form method="GET" action="/search">
        <InputGroup>
          <Input
            type="text"
            placeholder="Search 'type:<query>' ..."
            name="searchId"
          />
          <Button outline type="submit"><Icon name="search" /></Button>
          <InputGroupText
            style="cursor:help;"
            title={authlevel >= roles.support
              ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | arrayJobId | username | name"
              : "Example: 'jobName:myjob', Types are jobId | jobName | projectId | arrayJobId "}
            ><Icon name="info-circle" /></InputGroupText
          >
        </InputGroup>
      </form>
    </NavItem>
  {/if}
</Nav>


Let me look at the web.Page struct and how the user is set in template context.


web/web.go:255:type Page struct {

web/web.go:255:type Page struct {


110		LdapConfig   *LdapConfig    `json:"ldap"`
111		JwtConfig    *JWTAuthConfig `json:"jwts"`
112		OpenIDConfig *OpenIDConfig  `json:"oidc"`
113	}
114
115	// Keys holds the global authentication configuration
116	var Keys AuthConfig
117
118	// Authentication manages all authentication methods and session handling
119	type Authentication struct {
120		sessionStore   *sessions.CookieStore
121		LdapAuth       *LdapAuthenticator
122		JwtAuth        *JWTAuthenticator
123		LocalAuth      *LocalAuthenticator
124		authenticators []Authenticator
125		SessionMaxAge  time.Duration
126	}
127
128	func (auth *Authentication) AuthViaSession(
129		rw http.ResponseWriter,
130		r *http.Request,
131	) (*schema.User, error) {
132		session, err := auth.sessionStore.Get(r, "session")
133		if err != nil {
134			cclog.Error("Error while getting session store")
135			return nil, err
136		}
137
138		if session.IsNew {
139			return nil, nil
140		}
141
142		// Validate session data with proper type checking
143		username, ok := session.Values["username"].(string)
144		if !ok || username == "" {
145			cclog.Warn("Invalid session: missing or invalid username")
146			// Invalidate the corrupted session
147			session.Options.MaxAge = -1
148			_ = auth.sessionStore.Save(r, rw, session)
149			return nil, errors.New("invalid session data")
150		}
151
152		projects, ok := session.Values["projects"].([]string)
153		if !ok {
154			cclog.Warn("Invalid session: projects not found or invalid type, using empty list")
155			projects = []string{}
156		}
157
158		roles, ok := session.Values["roles"].([]string)
159		if !ok || len(roles) == 0 {
160			cclog.Warn("Invalid session: missing or invalid roles")
161			// Invalidate the corrupted session
162			session.Options.MaxAge = -1
163			_ = auth.sessionStore.Save(r, rw, session)
164			return nil, errors.New("invalid session data")
165		}
166
167		return &schema.User{
168			Username:   username,
169			Projects:   projects,
170			Roles:      roles,
171			AuthType:   schema.AuthSession,
172			AuthSource: -1,
173		}, nil
174	}
175
176	func Init(authCfg *json.RawMessage) {
177		initOnce.Do(func() {
178			authInstance = &Authentication{}
179
180			// Start background cleanup of rate limiters
181			startRateLimiterCleanup()
182
183			sessKey := os.Getenv("SESSION_KEY")
184			if sessKey == "" {
185				cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
186				bytes := make([]byte, 32)
187				if _, err := rand.Read(bytes); err != nil {
188					cclog.Fatal("Error while initializing authentication -> failed to generate random bytes for session key")
189				}
190				authInstance.sessionStore = sessions.NewCookieStore(bytes)
191			} else {
192				bytes, err := base64.StdEncoding.DecodeString(sessKey)
193				if err != nil {
194					cclog.Fatal("Error while initializing authentication -> decoding session key failed")
195				}
196				authInstance.sessionStore = sessions.NewCookieStore(bytes)
197			}
198
199			if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil {
200				authInstance.SessionMaxAge = d
201			}
202
203			if authCfg == nil {
204				return
205			}
206
207			config.Validate(configSchema, *authCfg)
208			dec := json.NewDecoder(bytes.NewReader(*authCfg))
209			dec.DisallowUnknownFields()

<system-reminder>
Whenever 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.
</system-reminder>

	LdapConfig   *LdapConfig    `json:"ldap"`
	JwtConfig    *JWTAuthConfig `json:"jwts"`
	OpenIDConfig *OpenIDConfig  `json:"oidc"`
}

// Keys holds the global authentication configuration
var Keys AuthConfig

// Authentication manages all authentication methods and session handling
type Authentication struct {
	sessionStore   *sessions.CookieStore
	LdapAuth       *LdapAuthenticator
	JwtAuth        *JWTAuthenticator
	LocalAuth      *LocalAuthenticator
	authenticators []Authenticator
	SessionMaxAge  time.Duration
}

func (auth *Authentication) AuthViaSession(
	rw http.ResponseWriter,
	r *http.Request,
) (*schema.User, error) {
	session, err := auth.sessionStore.Get(r, "session")
	if err != nil {
		cclog.Error("Error while getting session store")
		return nil, err
	}

	if session.IsNew {
		return nil, nil
	}

	// Validate session data with proper type checking
	username, ok := session.Values["username"].(string)
	if !ok || username == "" {
		cclog.Warn("Invalid session: missing or invalid username")
		// Invalidate the corrupted session
		session.Options.MaxAge = -1
		_ = auth.sessionStore.Save(r, rw, session)
		return nil, errors.New("invalid session data")
	}

	projects, ok := session.Values["projects"].([]string)
	if !ok {
		cclog.Warn("Invalid session: projects not found or invalid type, using empty list")
		projects = []string{}
	}

	roles, ok := session.Values["roles"].([]string)
	if !ok || len(roles) == 0 {
		cclog.Warn("Invalid session: missing or invalid roles")
		// Invalidate the corrupted session
		session.Options.MaxAge = -1
		_ = auth.sessionStore.Save(r, rw, session)
		return nil, errors.New("invalid session data")
	}

	return &schema.User{
		Username:   username,
		Projects:   projects,
		Roles:      roles,
		AuthType:   schema.AuthSession,
		AuthSource: -1,
	}, nil
}

func Init(authCfg *json.RawMessage) {
	initOnce.Do(func() {
		authInstance = &Authentication{}

		// Start background cleanup of rate limiters
		startRateLimiterCleanup()

		sessKey := os.Getenv("SESSION_KEY")
		if sessKey == "" {
			cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
			bytes := make([]byte, 32)
			if _, err := rand.Read(bytes); err != nil {
				cclog.Fatal("Error while initializing authentication -> failed to generate random bytes for session key")
			}
			authInstance.sessionStore = sessions.NewCookieStore(bytes)
		} else {
			bytes, err := base64.StdEncoding.DecodeString(sessKey)
			if err != nil {
				cclog.Fatal("Error while initializing authentication -> decoding session key failed")
			}
			authInstance.sessionStore = sessions.NewCookieStore(bytes)
		}

		if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil {
			authInstance.SessionMaxAge = d
		}

		if authCfg == nil {
			return
		}

		config.Validate(configSchema, *authCfg)
		dec := json.NewDecoder(bytes.NewReader(*authCfg))
		dec.DisallowUnknownFields()


250		Version   string
251		Hash      string
252		Buildtime string
253	}
254
255	type Page struct {
256		Title         string                 // Page title
257		MsgType       string                 // For generic use in message boxes
258		Message       string                 // For generic use in message boxes
259		User          schema.User            // Information about the currently logged in user (Full User Info)
260		Roles         map[string]schema.Role // Available roles for frontend render checks
261		Build         Build                  // Latest information about the application
262		Clusters      []string               // List of all cluster names
263		SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
264		FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
265		Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
266		Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
267		Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
268		Redirect      string                 // The originally requested URL, for intermediate login handling
269	}
270
271	func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
272		t, ok := templates[file]
273		if !ok {
274			cclog.Errorf("WEB/WEB > template '%s' not found", file)
275		}
276
277		if page.Clusters == nil {
278			page.Clusters = make([]string, 0)
279		}
280
281		if page.SubClusters == nil {
282			page.SubClusters = make(map[string][]string)
283			for _, cluster := range archive.Clusters {
284				page.Clusters = append(page.Clusters, cluster.Name)
285
286				for _, sc := range cluster.SubClusters {
287					page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
288				}
289			}
290		}
291
292		cclog.Debugf("Page config : %v\n", page.Config)
293		if err := t.Execute(rw, page); err != nil {
294			cclog.Errorf("Template error: %s", err.Error())
295		}
296	}
297

<system-reminder>
Whenever 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.
</system-reminder>

	Version   string
	Hash      string
	Buildtime string
}

type Page struct {
	Title         string                 // Page title
	MsgType       string                 // For generic use in message boxes
	Message       string                 // For generic use in message boxes
	User          schema.User            // Information about the currently logged in user (Full User Info)
	Roles         map[string]schema.Role // Available roles for frontend render checks
	Build         Build                  // Latest information about the application
	Clusters      []string               // List of all cluster names
	SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
	FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
	Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
	Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
	Redirect      string                 // The originally requested URL, for intermediate login handling
}

func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
	t, ok := templates[file]
	if !ok {
		cclog.Errorf("WEB/WEB > template '%s' not found", file)
	}

	if page.Clusters == nil {
		page.Clusters = make([]string, 0)
	}

	if page.SubClusters == nil {
		page.SubClusters = make(map[string][]string)
		for _, cluster := range archive.Clusters {
			page.Clusters = append(page.Clusters, cluster.Name)

			for _, sc := range cluster.SubClusters {
				page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
			}
		}
	}

	cclog.Debugf("Page config : %v\n", page.Config)
	if err := t.Execute(rw, page); err != nil {
		cclog.Errorf("Template error: %s", err.Error())
	}
}


internal/graph/generated/generated.go:864:		if e.ComplexityRoot.Job.User == nil {
internal/graph/generated/generated.go:1830:		if e.ComplexityRoot.Query.User == nil {
internal/graph/generated/generated.go:2177:		if e.ComplexityRoot.User.Username == nil {
internal/graph/generated/generated.go:2181:		return e.ComplexityRoot.User.Username(childComplexity), true
internal/graph/generated/generated.go:13375:			it.User = data
web/templates/config.tmpl:13:        const username = {{ .User.Username }};
internal/repository/repository_test.go:92:	filter.User = &model.StringInput{Eq: &user}
internal/repository/repository_test.go:114:	filter.User = &model.StringInput{Eq: &user}
web/templates/monitoring/taglist.tmpl:9:        const username = {{ .User.Username }};
web/templates/base.tmpl:18:            const hUsername = {{ .User.Username }};
web/templates/monitoring/job.tmpl:14:        const username = {{ .User.Username }};


internal/routerConfig/routes.go:496:			page := web.Page{
internal/routerConfig/routes.go:498:				User:       *user,
internal/routerConfig/routes.go:545:					web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
internal/routerConfig/routes.go:556:						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
internal/routerConfig/routes.go:560:				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
internal/routerConfig/routes.go:579:				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
internal/routerConfig/routes.go:583:			web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo})
internal/routerConfig/routes.go:586:		web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})


480
481				title := route.Title
482				infos := route.Setup(map[string]any{}, r)
483				if id, ok := infos["id"]; ok {
484					title = strings.Replace(route.Title, "<ID>", id.(string), 1)
485					if sid, ok := infos["sid"]; ok { // 2nd ID element
486						title = strings.Replace(title, "<SID>", sid.(string), 1)
487					}
488				}
489
490				// Get User -> What if NIL?
491				user := repository.GetUserFromContext(r.Context())
492
493				// Get Roles
494				availableRoles, _ := schema.GetValidRolesMap(user)
495
496				page := web.Page{
497					Title:      title,
498					User:       *user,
499					Roles:      availableRoles,
500					Build:      buildInfo,
501					Config:     conf,
502					Resampling: config.Keys.EnableResampling,
503					Infos:      infos,
504				}
505
506				if route.Filter {
507					page.FilterPresets = buildFilterPresets(r.URL.Query())
508				}
509
510				web.RenderTemplate(rw, route.Template, &page)
511			})
512		}
513	}
514
515	func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
516		user := repository.GetUserFromContext(r.Context())
517		availableRoles, _ := schema.GetValidRolesMap(user)
518
519		if search := r.URL.Query().Get("searchId"); search != "" {
520			repo := repository.GetJobRepository()
521			splitSearch := strings.Split(search, ":")
522
523			if len(splitSearch) == 2 {
524				switch strings.Trim(splitSearch[0], " ") {
525				case "jobId":
526					http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
527				case "jobName":
528					// Add Last 30 Days to migitate timeouts
529					untilTime := strconv.FormatInt(time.Now().Unix(), 10)
530					fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
531
532					http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
533				case "projectId":
534					http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
535				case "arrayJobId":
536					// Add Last 30 Days to migitate timeouts
537					untilTime := strconv.FormatInt(time.Now().Unix(), 10)
538					fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
539

<system-reminder>
Whenever 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.
</system-reminder>

			title := route.Title
			infos := route.Setup(map[string]any{}, r)
			if id, ok := infos["id"]; ok {
				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
				if sid, ok := infos["sid"]; ok { // 2nd ID element
					title = strings.Replace(title, "<SID>", sid.(string), 1)
				}
			}

			// Get User -> What if NIL?
			user := repository.GetUserFromContext(r.Context())

			// Get Roles
			availableRoles, _ := schema.GetValidRolesMap(user)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: config.Keys.EnableResampling,
				Infos:      infos,
			}

			if route.Filter {
				page.FilterPresets = buildFilterPresets(r.URL.Query())
			}

			web.RenderTemplate(rw, route.Template, &page)
		})
	}
}

func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
	user := repository.GetUserFromContext(r.Context())
	availableRoles, _ := schema.GetValidRolesMap(user)

	if search := r.URL.Query().Get("searchId"); search != "" {
		repo := repository.GetJobRepository()
		splitSearch := strings.Split(search, ":")

		if len(splitSearch) == 2 {
			switch strings.Trim(splitSearch[0], " ") {
			case "jobId":
				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "jobName":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "projectId":
				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "arrayJobId":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)


290	}
291
292	// handleOIDCUser syncs OIDC user with database
293	func handleOIDCUser(OIDCUser *schema.User) {
294		handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
295	}
296
297	// handleLdapUser syncs LDAP user with database
298	func handleLdapUser(ldapUser *schema.User) {
299		handleUserSync(ldapUser, Keys.LdapConfig.SyncUserOnLogin, Keys.LdapConfig.UpdateUserOnLogin)
300	}
301
302	func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
303		session, err := auth.sessionStore.New(r, "session")
304		if err != nil {
305			cclog.Errorf("session creation failed: %s", err.Error())
306			http.Error(rw, err.Error(), http.StatusInternalServerError)
307			return err
308		}
309
310		if auth.SessionMaxAge != 0 {
311			session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
312		}
313		if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
314			// If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure.
315			cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)")
316			if r.Header.Get("X-Forwarded-Proto") == "" {
317				// This warning will not be printed if e.g. X-Forwarded-Proto == http
318				cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set")
319			}
320			session.Options.Secure = false
321		}
322		session.Options.SameSite = http.SameSiteStrictMode
323		session.Values["username"] = user.Username
324		session.Values["projects"] = user.Projects
325		session.Values["roles"] = user.Roles
326		if err := auth.sessionStore.Save(r, rw, session); err != nil {
327			cclog.Warnf("session save failed: %s", err.Error())
328			http.Error(rw, err.Error(), http.StatusInternalServerError)
329			return err
330		}
331
332		return nil
333	}
334
335	func (auth *Authentication) Login(
336		onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
337	) http.Handler {
338		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
339			ip, _, err := net.SplitHostPort(r.RemoteAddr)
340			if err != nil {
341				ip = r.RemoteAddr
342			}
343
344			username := r.FormValue("username")
345
346			limiter := getIPUserLimiter(ip, username)
347			if !limiter.Allow() {
348				cclog.Warnf("AUTH/RATE > Too many login attempts for combination IP: %s, Username: %s", ip, username)
349				onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes"))
350				return
351			}
352
353			var dbUser *schema.User
354			if username != "" {
355				var err error
356				dbUser, err = repository.GetUserRepository().GetUser(username)
357				if err != nil && err != sql.ErrNoRows {
358					cclog.Errorf("Error while loading user '%v'", username)
359				}

<system-reminder>
Whenever 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.
</system-reminder>

}

// handleOIDCUser syncs OIDC user with database
func handleOIDCUser(OIDCUser *schema.User) {
	handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
}

// handleLdapUser syncs LDAP user with database
func handleLdapUser(ldapUser *schema.User) {
	handleUserSync(ldapUser, Keys.LdapConfig.SyncUserOnLogin, Keys.LdapConfig.UpdateUserOnLogin)
}

func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
	session, err := auth.sessionStore.New(r, "session")
	if err != nil {
		cclog.Errorf("session creation failed: %s", err.Error())
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return err
	}

	if auth.SessionMaxAge != 0 {
		session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
	}
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
		// If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure.
		cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)")
		if r.Header.Get("X-Forwarded-Proto") == "" {
			// This warning will not be printed if e.g. X-Forwarded-Proto == http
			cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set")
		}
		session.Options.Secure = false
	}
	session.Options.SameSite = http.SameSiteStrictMode
	session.Values["username"] = user.Username
	session.Values["projects"] = user.Projects
	session.Values["roles"] = user.Roles
	if err := auth.sessionStore.Save(r, rw, session); err != nil {
		cclog.Warnf("session save failed: %s", err.Error())
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return err
	}

	return nil
}

func (auth *Authentication) Login(
	onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}

		username := r.FormValue("username")

		limiter := getIPUserLimiter(ip, username)
		if !limiter.Allow() {
			cclog.Warnf("AUTH/RATE > Too many login attempts for combination IP: %s, Username: %s", ip, username)
			onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes"))
			return
		}

		var dbUser *schema.User
		if username != "" {
			var err error
			dbUser, err = repository.GetUserRepository().GetUser(username)
			if err != nil && err != sql.ErrNoRows {
				cclog.Errorf("Error while loading user '%v'", username)
			}


internal/routerConfig/routes.go:195:	tags, counts, err := jobRepo.CountTags(repository.GetUserFromContext(r.Context()))
internal/routerConfig/routes.go:203:	userAuthlevel := repository.GetUserFromContext(r.Context()).GetAuthLevel()
internal/routerConfig/routes.go:475:			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
internal/routerConfig/routes.go:491:			user := repository.GetUserFromContext(r.Context())
internal/routerConfig/routes.go:516:	user := repository.GetUserFromContext(r.Context())
internal/api/cluster.go:38:	if user := repository.GetUserFromContext(r.Context()); user != nil &&
internal/api/user.go:44:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/user.go:79:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/user.go:151:	me := repository.GetUserFromContext(r.Context())
internal/api/user.go:209:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/rest.go:232:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/rest.go:277:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/rest.go:289:	if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
internal/api/rest.go:328:	me := repository.GetUserFromContext(r.Context())
internal/api/rest.go:365:	user := repository.GetUserFromContext(r.Context())
internal/api/rest.go:399:	if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
internal/api/job.go:201:		job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
internal/api/job.go:274:	job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
internal/api/job.go:367:	job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
internal/api/job.go:558:	job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
internal/api/job.go:571:		tagID, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), *job.ID, tag.Type, tag.Name, tag.Scope)
internal/api/job.go:622:	job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
internal/api/job.go:641:		remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), *job.ID, rtag.Type, rtag.Name, rtag.Scope)
internal/api/job.go:773:		if _, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), id, tag.Type, tag.Name, tag.Scope); err != nil {
internal/api/job.go:1121:	if user := repository.GetUserFromContext(r.Context()); user != nil &&
internal/api/log.go:33:	user := repository.GetUserFromContext(r.Context())
internal/auth/auth.go:383:			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:419:			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:452:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:458:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:488:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:494:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:524:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:530:					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:556:			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/auth/auth.go:577:			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/graph/schema.resolvers.go:43:	return r.Repo.GetTags(repository.GetUserFromContext(ctx), obj.ID)
internal/graph/schema.resolvers.go:137:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:167:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:214:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:263:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:303:	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
internal/graph/schema.resolvers.go:363:	return r.Repo.GetTags(repository.GetUserFromContext(ctx), nil)
internal/graph/schema.resolvers.go:368:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:491:	if user := repository.GetUserFromContext(ctx); user != nil &&
internal/graph/schema.resolvers.go:824:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:891:	user := repository.GetUserFromContext(ctx)
internal/graph/schema.resolvers.go:954:	user := repository.GetUserFromContext(ctx)
internal/auth/oidc.go:237:	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
internal/repository/jobQuery.go:174:	user := GetUserFromContext(ctx)
internal/repository/node.go:869:	user := GetUserFromContext(ctx)
internal/repository/repository_test.go:147:	return context.WithValue(ctx, ContextUserKey, user)
internal/repository/user_test.go:576:func TestGetUserFromContext(t *testing.T) {
internal/repository/user_test.go:583:		ctx := context.WithValue(context.Background(), ContextUserKey, user)
internal/repository/user_test.go:584:		retrieved := GetUserFromContext(ctx)
internal/repository/user_test.go:592:		retrieved := GetUserFromContext(ctx)
internal/repository/job_test.go:63:	tags, counts, err := r.CountTags(GetUserFromContext(ctx))
internal/repository/user.go:487:const ContextUserKey ContextKey = "user"
internal/repository/user.go:489:func GetUserFromContext(ctx context.Context) *schema.User {
internal/repository/user.go:490:	x := ctx.Value(ContextUserKey)
internal/repository/user.go:500:	me := GetUserFromContext(ctx)


Let me look at the auth middleware that handles subsequent requests after login.


370
371				user, err := authenticator.Login(user, rw, r)
372				if err != nil {
373					cclog.Warnf("user login failed: %s", err.Error())
374					onfailure(rw, r, err)
375					return
376				}
377
378				if err := auth.SaveSession(rw, r, user); err != nil {
379					return
380				}
381
382				cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
383				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
384
385				if r.FormValue("redirect") != "" {
386					http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
387					return
388				}
389
390				http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
391				return
392			}
393
394			cclog.Debugf("login failed: no authenticator applied")
395			onfailure(rw, r, errors.New("no authenticator applied"))
396		})
397	}
398
399	func (auth *Authentication) Auth(
400		onsuccess http.Handler,
401		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
402	) http.Handler {
403		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
404			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
405			if err != nil {
406				cclog.Infof("auth -> authentication failed: %s", err.Error())
407				http.Error(rw, err.Error(), http.StatusUnauthorized)
408				return
409			}
410			if user == nil {
411				user, err = auth.AuthViaSession(rw, r)
412				if err != nil {
413					cclog.Infof("auth -> authentication failed: %s", err.Error())
414					http.Error(rw, err.Error(), http.StatusUnauthorized)
415					return
416				}
417			}
418			if user != nil {
419				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
420				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
421				return
422			}
423
424			cclog.Infof("auth -> authentication failed: no valid session or JWT for %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
425			onfailure(rw, r, errors.New("unauthorized (please login first)"))
426		})
427	}
428
429	func (auth *Authentication) AuthAPI(
430		onsuccess http.Handler,
431		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
432	) http.Handler {
433		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
434			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
435			if err != nil {
436				cclog.Infof("auth api -> authentication failed: %s", err.Error())
437				onfailure(rw, r, err)
438				return
439			}
440
441			ipErr := securedCheck(user, r)
442			if ipErr != nil {
443				cclog.Infof("auth api -> secured check failed: %s", ipErr.Error())
444				onfailure(rw, r, ipErr)
445				return
446			}
447
448			if user != nil {
449				switch {
450				case len(user.Roles) == 1:
451					if user.HasRole(schema.RoleAPI) {
452						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
453						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
454						return
455					}
456				case len(user.Roles) >= 2:
457					if user.HasAllRoles([]schema.Role{schema.RoleAdmin, schema.RoleAPI}) {
458						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
459						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
460						return
461					}
462				default:
463					cclog.Info("auth api -> authentication failed: missing role")
464					onfailure(rw, r, errors.New("unauthorized"))
465				}
466			}
467			cclog.Info("auth api -> authentication failed: no auth")
468			onfailure(rw, r, errors.New("unauthorized"))
469		})
470	}
471
472	func (auth *Authentication) AuthUserAPI(
473		onsuccess http.Handler,
474		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
475	) http.Handler {
476		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
477			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
478			if err != nil {
479				cclog.Infof("auth user api -> authentication failed: %s", err.Error())
480				onfailure(rw, r, err)
481				return
482			}
483
484			if user != nil {
485				switch {
486				case len(user.Roles) == 1:
487					if user.HasRole(schema.RoleAPI) {
488						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
489						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
490						return
491					}
492				case len(user.Roles) >= 2:
493					if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleSupport, schema.RoleAdmin}) {
494						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
495						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
496						return
497					}
498				default:
499					cclog.Info("auth user api -> authentication failed: missing role")
500					onfailure(rw, r, errors.New("unauthorized"))
501				}
502			}
503			cclog.Info("auth user api -> authentication failed: no auth")
504			onfailure(rw, r, errors.New("unauthorized"))
505		})
506	}
507
508	func (auth *Authentication) AuthMetricStoreAPI(
509		onsuccess http.Handler,
510		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
511	) http.Handler {
512		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
513			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
514			if err != nil {
515				cclog.Infof("auth metricstore api -> authentication failed: %s", err.Error())
516				onfailure(rw, r, err)
517				return
518			}
519
520			if user != nil {
521				switch {
522				case len(user.Roles) == 1:
523					if user.HasRole(schema.RoleAPI) {
524						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
525						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
526						return
527					}
528				case len(user.Roles) >= 2:
529					if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) {
530						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
531						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
532						return
533					}
534				default:
535					cclog.Info("auth metricstore api -> authentication failed: missing role")
536					onfailure(rw, r, errors.New("unauthorized"))
537				}
538			}
539			cclog.Info("auth metricstore api -> authentication failed: no auth")
540			onfailure(rw, r, errors.New("unauthorized"))
541		})
542	}
543
544	func (auth *Authentication) AuthConfigAPI(
545		onsuccess http.Handler,
546		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
547	) http.Handler {
548		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
549			user, err := auth.AuthViaSession(rw, r)
550			if err != nil {
551				cclog.Infof("auth config api -> authentication failed: %s", err.Error())
552				onfailure(rw, r, err)
553				return
554			}
555			if user != nil && user.HasRole(schema.RoleAdmin) {
556				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
557				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
558				return
559			}
560			cclog.Info("auth config api -> authentication failed: no auth")
561			onfailure(rw, r, errors.New("unauthorized"))
562		})
563	}
564
565	func (auth *Authentication) AuthFrontendAPI(
566		onsuccess http.Handler,
567		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
568	) http.Handler {
569		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
570			user, err := auth.AuthViaSession(rw, r)
571			if err != nil {
572				cclog.Infof("auth frontend api -> authentication failed: %s", err.Error())
573				onfailure(rw, r, err)
574				return
575			}
576			if user != nil {
577				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
578				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
579				return
580			}
581			cclog.Info("auth frontend api -> authentication failed: no auth")
582			onfailure(rw, r, errors.New("unauthorized"))
583		})
584	}
585
586	func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
587		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
588			session, err := auth.sessionStore.Get(r, "session")
589			if err != nil {

<system-reminder>
Whenever 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.
</system-reminder>

			user, err := authenticator.Login(user, rw, r)
			if err != nil {
				cclog.Warnf("user login failed: %s", err.Error())
				onfailure(rw, r, err)
				return
			}

			if err := auth.SaveSession(rw, r, user); err != nil {
				return
			}

			cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)

			if r.FormValue("redirect") != "" {
				http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
				return
			}

			http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
			return
		}

		cclog.Debugf("login failed: no authenticator applied")
		onfailure(rw, r, errors.New("no authenticator applied"))
	})
}

func (auth *Authentication) Auth(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth -> authentication failed: %s", err.Error())
			http.Error(rw, err.Error(), http.StatusUnauthorized)
			return
		}
		if user == nil {
			user, err = auth.AuthViaSession(rw, r)
			if err != nil {
				cclog.Infof("auth -> authentication failed: %s", err.Error())
				http.Error(rw, err.Error(), http.StatusUnauthorized)
				return
			}
		}
		if user != nil {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}

		cclog.Infof("auth -> authentication failed: no valid session or JWT for %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
		onfailure(rw, r, errors.New("unauthorized (please login first)"))
	})
}

func (auth *Authentication) AuthAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		ipErr := securedCheck(user, r)
		if ipErr != nil {
			cclog.Infof("auth api -> secured check failed: %s", ipErr.Error())
			onfailure(rw, r, ipErr)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasAllRoles([]schema.Role{schema.RoleAdmin, schema.RoleAPI}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthUserAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth user api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleSupport, schema.RoleAdmin}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth user api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth user api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthMetricStoreAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth metricstore api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth metricstore api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth metricstore api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthConfigAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.AuthViaSession(rw, r)
		if err != nil {
			cclog.Infof("auth config api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}
		if user != nil && user.HasRole(schema.RoleAdmin) {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}
		cclog.Info("auth config api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthFrontendAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.AuthViaSession(rw, r)
		if err != nil {
			cclog.Infof("auth frontend api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}
		if user != nil {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}
		cclog.Info("auth frontend api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		session, err := auth.sessionStore.Get(r, "session")
		if err != nil {


Let me look at server.go for the route setup and the `displayType` handling.