commit fd9b76c6a74af3b21c783fc52fadf31795e2651d
Author: Jan Eitzinger <jan@moebiusband.org>
Date:   Mon Feb 9 09:12:06 2026 +0100

    Security hardening of ldap and oicd auth implementations

diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index df618a3f..8a2073b5 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -294,6 +294,11 @@ 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 {


Bash

git show fd9b76c6^:internal/auth/oidc.go | head -200

Show previous version of oidc.go before security hardening


// 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"
	"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,
	}
	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(),
		RedirectURL:  "oidc-callback",
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
	}

	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 {
		http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
		return
	}

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

	// // Extract the ID Token from OAuth2 token.
	// rawIDToken, ok := token.Extra("id_token").(string)
	// if !ok {
	// 	http.Error(rw, "Cannot access idToken", http.StatusInternalServerError)
	// }
	//
	// verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
	// // Parse and verify ID Token payload.
	// idToken, err := verifier.Verify(context.Background(), rawIDToken)
	// if err != nil {
	// 	http.Error(rw, "Failed to extract idToken: "+err.Error(), http.StatusInternalServerError)
	// }

	projects := make([]string, 0)

	// Extract custom claims
	var claims struct {
		Username string `json:"preferred_username"`
		Name     string `json:"name"`
		Profile  struct {
			Client struct {
				Roles []string `json:"roles"`
			} `json:"clustercockpit"`
		} `json:"resource_access"`
	}
	if err := userInfo.Claims(&claims); err != nil {
		http.Error(rw, "Failed to extract Claims: "+err.Error(), http.StatusInternalServerError)
	}

	var roles []string
	for _, r := range claims.Profile.Client.Roles {
		switch r {
		case "user":
			roles = append(roles, schema.GetRoleString(schema.RoleUser))
		case "admin":
			roles = append(roles, schema.GetRoleString(schema.RoleAdmin))
		}
	}

	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)
	}

	oa.authentication.SaveSession(rw, r, user)
	cclog.Infof("login successfull: 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)


Now I have enough information to design a plan. Let me write it.


Agent

Design fix for OIDC logout button issue

Prompt:
Design an implementation plan to fix a bug in cc-backend (ClusterCockpit) where OIDC-authenticated users do not see the logout button in the frontend UI.

## Background

The project is a Go backend with a Svelte 5 frontend. The backend uses Gorilla sessions for session management and supports OIDC (OpenID Connect) authentication via `internal/auth/oidc.go`.

### How the logout button works

In `web/frontend/src/header/NavbarTools.svelte` (line 104), the logout button is shown only when:
```svelte
{#if username}
  <NavItem>
    <form method="POST" action="/logout">
      ...
    </form>
  </NavItem>
{/if}
```

`username` comes from `hUsername` in `web/frontend/src/header.entrypoint.js`, which comes from the Go template `base.tmpl`:
```javascript
const hUsername = {{ .User.Username }};
```

So the logout button is shown ONLY when `User.Username` is non-empty (truthy in JavaScript).

### OIDC Authentication Flow

1. User clicks "OpenID Connect Login" → `/oidc-login`
2. Browser redirected to IdP (cross-site navigation)
3. IdP redirects back to `/oidc-callback` (cross-site navigation TO cc-backend)
4. Callback verifies token, extracts user info, calls `SaveSession`, redirects to `/`
5. Request to `/` goes through `Auth` middleware → `AuthViaSession` → user in context
6. Route handler renders template with username

### Root Cause: SameSite=Strict breaks OIDC redirect chain

In `internal/auth/auth.go` (line ~322), `SaveSession` sets:
```go
session.Options.SameSite = http.SameSiteStrictMode
```

This is the key bug. With `SameSite=Strict`:
- The session cookie IS correctly set in the `/oidc-callback` response
- BUT when the browser follows the 307 redirect to `/`, some browsers (particularly Chrome with strict same-site enforcement) consider the request as part of a cross-site redirect chain (because the chain started from the IdP, a cross-site origin)
- The `SameSite=Strict` cookie is NOT sent with the request to `/`
- `AuthViaSession` finds no session (`session.IsNew = true`) → returns nil
- Auth middleware calls `onfailure` → renders `login.tmpl` (login page)
- The login page has its own `navigation` block override that doesn't include `<header id="svelte-header">`, so the Svelte header (with logout button) is never mounted

**This affects OIDC users specifically** because:
- Local/LDAP users authenticate via direct POST to `/login`, which is same-site — no cross-site redirect chain
- OIDC users authenticate via cross-site redirect chain (cc-backend → IdP → cc-backend)

The state/verifier/nonce cookies are correctly set to `SameSite=Lax` (in `setCallbackCookie`), which IS compatible with cross-site redirects. But the session cookie uses `Strict`.

### Industry Standard

`SameSite=Lax` is the recommended setting for session cookies in applications that use OAuth/OIDC flows. The IETF OAuth 2.0 for Browser-Based Apps RFC specifically notes that `SameSite=Strict` is incompatible with redirect-based flows.

## Additional Issue

In `internal/auth/auth.go` line 172, `AuthSource` is hardcoded to `-1` when restoring from session:
```go
return &schema.User{
    Username:   username,
    Projects:   projects,
    Roles:      roles,
    AuthType:   schema.AuthSession,
    AuthSource: -1,  // wrong: should restore from session
}, nil
```

This means after any login (not just OIDC), the `AuthSource` is lost. This is a secondary issue.

## Files to Modify

1. `internal/auth/auth.go` — Change session SameSite from Strict to Lax; also store/restore AuthSource in session
2. `internal/auth/auth.go` — `SaveSession`: save AuthSource; `AuthViaSession`: restore AuthSource

## Plan

Please design a minimal, focused implementation plan that:
1. Fixes the `SameSite=Strict` → `SameSite=Lax` for session cookies
2. Fixes the AuthSource not being stored/restored in sessions
3. Keeps changes minimal — no extra features, no refactoring beyond what's needed

Include exact line references and what to change. Also include how to verify the fix works.


Good. There are no OIDC-specific tests, so we will need to describe manual verification steps. Now I have everything needed for a complete, precise implementation plan.

---

## Implementation Plan: Fix OIDC Logout Button Visibility

### Summary of Changes

Two surgical changes to `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`:

1. **Line 322**: Change `SameSite=Strict` to `SameSite=Lax` in `SaveSession`
2. **Lines 323-325 and 167-173**: Store and restore `AuthSource` in session values

Both changes are confined to a single file. No frontend changes, no new dependencies, no refactoring.

---

### Change 1 — Fix SameSite Cookie Mode (Primary Bug)

**File**: `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`

**Location**: `SaveSession` function, line 322

**Current code**:
```go
session.Options.SameSite = http.SameSiteStrictMode
```

**Replace with**:
```go
session.Options.SameSite = http.SameSiteLaxMode
```

**Why this is correct and safe**: `SameSite=Lax` allows the session cookie to be sent on top-level cross-site GET navigations (like the browser following a redirect from the IdP back to cc-backend). It still blocks the cookie on cross-site subrequests (AJAX, images, iframes), which is the CSRF protection that `Strict` was providing for. The meaningful CSRF risk here is a forged POST — browsers block `Lax` cookies on cross-site POST requests, so CSRF protection for the `/logout` form POST and any other state-changing POST remains intact. The state, verifier, and nonce cookies are already correctly set to `SameSite=Lax` in `setCallbackCookie` (oidc.go line 54), so this aligns the session cookie with the pattern already used in the same file.

---

### Change 2 — Store AuthSource in Session (Secondary Bug)

**File**: `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`

#### 2a. In `SaveSession`, lines 323-325

**Current code** (lines 323-325):
```go
session.Values["username"] = user.Username
session.Values["projects"] = user.Projects
session.Values["roles"] = user.Roles
```

**Replace with** (add one line after roles):
```go
session.Values["username"] = user.Username
session.Values["projects"] = user.Projects
session.Values["roles"] = user.Roles
session.Values["authSource"] = int(user.AuthSource)
```

**Why `int(user.AuthSource)` not `user.AuthSource` directly**: gorilla/securecookie uses `encoding/gob` to serialize session values. The `session.Values` map is `map[interface{}]interface{}`. Gob can handle named types but only if they are registered with `gob.Register()` first; there is no such registration in this codebase. Storing the value as a plain `int` avoids gob registration entirely and is the minimal, safe approach consistent with how the other integer-like data could be stored. The type round-trip is `schema.AuthSource` (named int) → `int` (stored) → `schema.AuthSource` (restored via cast).

#### 2b. In `AuthViaSession`, lines 167-173

**Current code** (lines 167-173):
```go
return &schema.User{
    Username:   username,
    Projects:   projects,
    Roles:      roles,
    AuthType:   schema.AuthSession,
    AuthSource: -1,
}, nil
```

**Replace with**:
```go
authSourceInt, ok := session.Values["authSource"].(int)
if !ok {
    authSourceInt = int(schema.AuthViaLocalPassword)
}

return &schema.User{
    Username:   username,
    Projects:   projects,
    Roles:      roles,
    AuthType:   schema.AuthSession,
    AuthSource: schema.AuthSource(authSourceInt),
}, nil
```

**Why fall back to `AuthViaLocalPassword` (0) rather than -1**: Existing sessions created before this fix will not have an `"authSource"` key. A fallback of `-1` is not a defined `AuthSource` constant and could cause unexpected behavior in any code that switches on `AuthSource`. `AuthViaLocalPassword = 0` (the zero value of `schema.AuthSource`) is the safest fallback for legacy sessions: it matches what a local user would have, and it is the iota zero value so it is the natural uninitialized state. This only affects sessions that were created before deployment of the fix; all new sessions will carry the correct value.

---

### Full Diff View (for reference)

In `SaveSession` (around line 320-326):
```diff
-    session.Options.SameSite = http.SameSiteStrictMode
+    session.Options.SameSite = http.SameSiteLaxMode
     session.Values["username"] = user.Username
     session.Values["projects"] = user.Projects
     session.Values["roles"] = user.Roles
+    session.Values["authSource"] = int(user.AuthSource)
```

In `AuthViaSession` (around line 165-174):
```diff
-    return &schema.User{
-        Username:   username,
-        Projects:   projects,
-        Roles:      roles,
-        AuthType:   schema.AuthSession,
-        AuthSource: -1,
-    }, nil
+    authSourceInt, ok := session.Values["authSource"].(int)
+    if !ok {
+        authSourceInt = int(schema.AuthViaLocalPassword)
+    }
+
+    return &schema.User{
+        Username:   username,
+        Projects:   projects,
+        Roles:      roles,
+        AuthType:   schema.AuthSession,
+        AuthSource: schema.AuthSource(authSourceInt),
+    }, nil
```

---

### No Other Files Need Modification

- `internal/auth/oidc.go`: No changes. `OAuth2Callback` already sets `AuthSource: schema.AuthViaOIDC` on the user before calling `SaveSession`. Once `SaveSession` persists it, the fix is complete.
- Frontend (`NavbarTools.svelte`, `header.entrypoint.js`, `base.tmpl`): No changes. The root cause is the session cookie not being sent at all; fixing `SameSite` means the session is found, the user is authenticated, the template receives a non-empty `User.Username`, and the logout button appears.
- No new imports required — `http.SameSiteLaxMode` is already in the `net/http` package which is already imported, and `schema` is already imported.

---

### Verification Steps

**Manual verification (integration test)**:

1. Configure a test OIDC provider (e.g., local Keycloak or `dex`).
2. Start cc-backend with the fix deployed.
3. Open a browser with DevTools → Network tab and Application → Cookies.
4. Navigate to cc-backend and click "OpenID Connect Login".
5. Complete login at the IdP.
6. After redirect back to `/`, observe:
   - The session cookie (`session`) is present in the browser cookie store.
   - The cookie has `SameSite=Lax` (visible in DevTools → Application → Cookies).
   - The page shows the authenticated view (not the login page).
   - The logout button is visible in the navigation bar.
7. Click logout — confirm the session is cleared and login page returns.

**Browser-specific check**: Reproduce with Chrome (most aggressive SameSite enforcement). The bug was specifically triggered by Chrome's handling of `SameSite=Strict` cookies in cross-site redirect chains.

**Unit test addition (optional but recommended)**: Add a test to `auth_test.go` in the `auth` package that:
- Creates an `Authentication` with a test `CookieStore`.
- Calls `SaveSession` with a user that has `AuthSource: schema.AuthViaOIDC`.
- Creates a new `httptest.Request` with the response's `Set-Cookie` header replayed as a `Cookie` header.
- Calls `AuthViaSession` and asserts the returned user has `AuthSource == schema.AuthViaOIDC`.
- Asserts the cookie's `SameSite` attribute is `Lax`.

**Regression check**: Verify local password and LDAP logins still work correctly after the change, confirming `SameSite=Lax` does not break same-site login flows (it does not — `Lax` is a superset of `Strict` for same-site requests).

---

### Critical Files for Implementation

- `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`
- `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go`
- `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth_test.go`

Prompt:
Design an implementation plan to fix a bug in cc-backend (ClusterCockpit) where OIDC-authenticated users do not see the logout button in the frontend UI.

## Background

The project is a Go backend with a Svelte 5 frontend. The backend uses Gorilla sessions for session management and supports OIDC (OpenID Connect) authentication via `internal/auth/oidc.go`.

### How the logout button works

In `web/frontend/src/header/NavbarTools.svelte` (line 104), the logout button is shown only when:
```svelte
{#if username}
  <NavItem>
    <form method="POST" action="/logout">
      ...
    </form>
  </NavItem>
{/if}
```

`username` comes from `hUsername` in `web/frontend/src/header.entrypoint.js`, which comes from the Go template `base.tmpl`:
```javascript
const hUsername = {{ .User.Username }};
```

So the logout button is shown ONLY when `User.Username` is non-empty (truthy in JavaScript).

### OIDC Authentication Flow

1. User clicks "OpenID Connect Login" → `/oidc-login`
2. Browser redirected to IdP (cross-site navigation)
3. IdP redirects back to `/oidc-callback` (cross-site navigation TO cc-backend)
4. Callback verifies token, extracts user info, calls `SaveSession`, redirects to `/`
5. Request to `/` goes through `Auth` middleware → `AuthViaSession` → user in context
6. Route handler renders template with username

### Root Cause: SameSite=Strict breaks OIDC redirect chain

In `internal/auth/auth.go` (line ~322), `SaveSession` sets:
```go
session.Options.SameSite = http.SameSiteStrictMode
```

This is the key bug. With `SameSite=Strict`:
- The session cookie IS correctly set in the `/oidc-callback` response
- BUT when the browser follows the 307 redirect to `/`, some browsers (particularly Chrome with strict same-site enforcement) consider the request as part of a cross-site redirect chain (because the chain started from the IdP, a cross-site origin)
- The `SameSite=Strict` cookie is NOT sent with the request to `/`
- `AuthViaSession` finds no session (`session.IsNew = true`) → returns nil
- Auth middleware calls `onfailure` → renders `login.tmpl` (login page)
- The login page has its own `navigation` block override that doesn't include `<header id="svelte-header">`, so the Svelte header (with logout button) is never mounted

**This affects OIDC users specifically** because:
- Local/LDAP users authenticate via direct POST to `/login`, which is same-site — no cross-site redirect chain
- OIDC users authenticate via cross-site redirect chain (cc-backend → IdP → cc-backend)

The state/verifier/nonce cookies are correctly set to `SameSite=Lax` (in `setCallbackCookie`), which IS compatible with cross-site redirects. But the session cookie uses `Strict`.

### Industry Standard

`SameSite=Lax` is the recommended setting for session cookies in applications that use OAuth/OIDC flows. The IETF OAuth 2.0 for Browser-Based Apps RFC specifically notes that `SameSite=Strict` is incompatible with redirect-based flows.

## Additional Issue

In `internal/auth/auth.go` line 172, `AuthSource` is hardcoded to `-1` when restoring from session:
```go
return &schema.User{
    Username:   username,
    Projects:   projects,
    Roles:      roles,
    AuthType:   schema.AuthSession,
    AuthSource: -1,  // wrong: should restore from session
}, nil
```

This means after any login (not just OIDC), the `AuthSource` is lost. This is a secondary issue.

## Files to Modify

1. `internal/auth/auth.go` — Change session SameSite from Strict to Lax; also store/restore AuthSource in session
2. `internal/auth/auth.go` — `SaveSession`: save AuthSource; `AuthViaSession`: restore AuthSource

## Plan

Please design a minimal, focused implementation plan that:
1. Fixes the `SameSite=Strict` → `SameSite=Lax` for session cookies
2. Fixes the AuthSource not being stored/restored in sessions
3. Keeps changes minimal — no extra features, no refactoring beyond what's needed

Include exact line references and what to change. Also include how to verify the fix works.


Now I have a clear, well-reasoned plan. Let me write it to the plan file.


Write

# Fix: OIDC Users Missing Logout Button

## Context

OIDC-authenticated users see no logout button in the frontend navbar. The logout button in `NavbarTools.svelte` (line 104) is gated on `{#if username}`, where `username` comes from `hUsername = {{ .User.Username }}` in `base.tmpl`. When this is falsy (empty string), the button is hidden.

**Root cause**: The session cookie in `SaveSession` (`auth.go`) is set to `SameSite=Strict`. OIDC login involves a cross-site redirect chain: cc-backend → IdP → `/oidc-callback` → `/`. Chrome (and some other browsers) will not send a `SameSite=Strict` cookie when following a redirect that is part of a cross-site chain — even though the final destination is same-site. This means the session cookie is not sent on the `GET /` request, `AuthViaSession` finds no session, the `Auth` middleware calls `onfailure`, and the **login page** is rendered (which overrides the Svelte navigation block, so no logout button exists).

Local and LDAP users are unaffected because they authenticate via a direct POST to `/login` — a same-site request with no cross-site redirect chain.

**Secondary issue**: `AuthSource` is hardcoded to `-1` in `AuthViaSession`, meaning the auth source is lost after every session restore.

Note: The state/verifier/nonce cookies in `oidc.go` are already correctly set to `SameSite=Lax` (added in the security hardening commit `fd9b76c`). This fix aligns the session cookie with the same pattern.

## Changes

**Single file**: `internal/auth/auth.go`

### Change 1 — `SaveSession`: fix SameSite and store AuthSource

Around line 322–325:

```diff
-    session.Options.SameSite = http.SameSiteStrictMode
+    session.Options.SameSite = http.SameSiteLaxMode
     session.Values["username"] = user.Username
     session.Values["projects"] = user.Projects
     session.Values["roles"] = user.Roles
+    session.Values["authSource"] = int(user.AuthSource)
```

`SameSite=Lax` allows the cookie on top-level cross-site GET navigations (the OIDC callback redirect) while still blocking it on cross-site subrequests (AJAX/images/iframes) — CSRF protection for POST endpoints is preserved.

`int(user.AuthSource)` avoids needing `gob.Register` for the named `schema.AuthSource` type (no gob registration exists in this codebase).

### Change 2 — `AuthViaSession`: restore AuthSource from session

Around lines 167–173, replace the return statement:

```diff
+    authSourceInt, ok := session.Values["authSource"].(int)
+    if !ok {
+        authSourceInt = int(schema.AuthViaLocalPassword)
+    }
+
     return &schema.User{
         Username:   username,
         Projects:   projects,
         Roles:      roles,
         AuthType:   schema.AuthSession,
-        AuthSource: -1,
+        AuthSource: schema.AuthSource(authSourceInt),
     }, nil
```

Falls back to `AuthViaLocalPassword` (0, the zero value) for sessions that predate this fix — preferable to the invalid `-1`.

## No Other Files Modified

- `internal/auth/oidc.go`: already sets `AuthSource: schema.AuthViaOIDC` before calling `SaveSession`; no change needed
- Frontend templates/Svelte: no changes; the fix restores the session, which makes `User.Username` non-empty, which makes the logout button appear

## Verification

1. Configure OIDC provider (Keycloak/dex), start cc-backend
2. Open DevTools → Application → Cookies
3. Click "OpenID Connect Login", complete IdP login
4. After redirect to `/`: confirm the `session` cookie has `SameSite=Lax`, the page shows authenticated view, and the logout button is visible in the navbar
5. Click logout — confirm session is cleared and login page returns
6. Verify local password login still works (regression check — `Lax` is a superset of `Strict` for same-site requests)


File created successfully at: /Users/jan/.claude/plans/parallel-sleeping-ember.md

# Fix: OIDC Users Missing Logout Button

## Context

OIDC-authenticated users see no logout button in the frontend navbar. The logout button in `NavbarTools.svelte` (line 104) is gated on `{#if username}`, where `username` comes from `hUsername = {{ .User.Username }}` in `base.tmpl`. When this is falsy (empty string), the button is hidden.

**Root cause**: The session cookie in `SaveSession` (`auth.go`) is set to `SameSite=Strict`. OIDC login involves a cross-site redirect chain: cc-backend → IdP → `/oidc-callback` → `/`. Chrome (and some other browsers) will not send a `SameSite=Strict` cookie when following a redirect that is part of a cross-site chain — even though the final destination is same-site. This means the session cookie is not sent on the `GET /` request, `AuthViaSession` finds no session, the `Auth` middleware calls `onfailure`, and the **login page** is rendered (which overrides the Svelte navigation block, so no logout button exists).

Local and LDAP users are unaffected because they authenticate via a direct POST to `/login` — a same-site request with no cross-site redirect chain.

**Secondary issue**: `AuthSource` is hardcoded to `-1` in `AuthViaSession`, meaning the auth source is lost after every session restore.

Note: The state/verifier/nonce cookies in `oidc.go` are already correctly set to `SameSite=Lax` (added in the security hardening commit `fd9b76c`). This fix aligns the session cookie with the same pattern.

## Changes

**Single file**: `internal/auth/auth.go`

### Change 1 — `SaveSession`: fix SameSite and store AuthSource

Around line 322–325:

```diff
-    session.Options.SameSite = http.SameSiteStrictMode
+    session.Options.SameSite = http.SameSiteLaxMode
     session.Values["username"] = user.Username
     session.Values["projects"] = user.Projects
     session.Values["roles"] = user.Roles
+    session.Values["authSource"] = int(user.AuthSource)
```

`SameSite=Lax` allows the cookie on top-level cross-site GET navigations (the OIDC callback redirect) while still blocking it on cross-site subrequests (AJAX/images/iframes) — CSRF protection for POST endpoints is preserved.

`int(user.AuthSource)` avoids needing `gob.Register` for the named `schema.AuthSource` type (no gob registration exists in this codebase).

### Change 2 — `AuthViaSession`: restore AuthSource from session

Around lines 167–173, replace the return statement:

```diff
+    authSourceInt, ok := session.Values["authSource"].(int)
+    if !ok {
+        authSourceInt = int(schema.AuthViaLocalPassword)
+    }
+
     return &schema.User{
         Username:   username,
         Projects:   projects,
         Roles:      roles,
         AuthType:   schema.AuthSession,
-        AuthSource: -1,
+        AuthSource: schema.AuthSource(authSourceInt),
     }, nil
```

Falls back to `AuthViaLocalPassword` (0, the zero value) for sessions that predate this fix — preferable to the invalid `-1`.

## No Other Files Modified

- `internal/auth/oidc.go`: already sets `AuthSource: schema.AuthViaOIDC` before calling `SaveSession`; no change needed
- Frontend templates/Svelte: no changes; the fix restores the session, which makes `User.Username` non-empty, which makes the logout button appear

## Verification

1. Configure OIDC provider (Keycloak/dex), start cc-backend
2. Open DevTools → Application → Cookies
3. Click "OpenID Connect Login", complete IdP login
4. After redirect to `/`: confirm the `session` cookie has `SameSite=Lax`, the page shows authenticated view, and the logout button is visible in the navbar
5. Click logout — confirm session is cleared and login page returns
6. Verify local password login still works (regression check — `Lax` is a superset of `Strict` for same-site requests)


ToolSearch

Question: select:ExitPlanMode


Question: select:ExitPlanMode


ExitPlanMode

# Fix: OIDC Users Missing Logout Button

## Context

OIDC-authenticated users see no logout button in the frontend navbar. The logout button in `NavbarTools.svelte` (line 104) is gated on `{#if username}`, where `username` comes from `hUsername = {{ .User.Username }}` in `base.tmpl`. When this is falsy (empty string), the button is hidden.

**Root cause**: The session cookie in `SaveSession` (`auth.go`) is set to `SameSite=Strict`. OIDC login involves a cross-site redirect chain: cc-backend → IdP → `/oidc-callback` → `/`. Chrome (and some other browsers) will not send a `SameSite=Strict` cookie when following a redirect that is part of a cross-site chain — even though the final destination is same-site. This means the session cookie is not sent on the `GET /` request, `AuthViaSession` finds no session, the `Auth` middleware calls `onfailure`, and the **login page** is rendered (which overrides the Svelte navigation block, so no logout button exists).

Local and LDAP users are unaffected because they authenticate via a direct POST to `/login` — a same-site request with no cross-site redirect chain.

**Secondary issue**: `AuthSource` is hardcoded to `-1` in `AuthViaSession`, meaning the auth source is lost after every session restore.

Note: The state/verifier/nonce cookies in `oidc.go` are already correctly set to `SameSite=Lax` (added in the security hardening commit `fd9b76c`). This fix aligns the session cookie with the same pattern.

## Changes

**Single file**: `internal/auth/auth.go`

### Change 1 — `SaveSession`: fix SameSite and store AuthSource

Around line 322–325:

```diff
-    session.Options.SameSite = http.SameSiteStrictMode
+    session.Options.SameSite = http.SameSiteLaxMode
     session.Values["username"] = user.Username
     session.Values["projects"] = user.Projects
     session.Values["roles"] = user.Roles
+    session.Values["authSource"] = int(user.AuthSource)
```

`SameSite=Lax` allows the cookie on top-level cross-site GET navigations (the OIDC callback redirect) while still blocking it on cross-site subrequests (AJAX/images/iframes) — CSRF protection for POST endpoints is preserved.

`int(user.AuthSource)` avoids needing `gob.Register` for the named `schema.AuthSource` type (no gob registration exists in this codebase).

### Change 2 — `AuthViaSession`: restore AuthSource from session

Around lines 167–173, replace the return statement:

```diff
+    authSourceInt, ok := session.Values["authSource"].(int)
+    if !ok {
+        authSourceInt = int(schema.AuthViaLocalPassword)
+    }
+
     return &schema.User{
         Username:   username,
         Projects:   projects,
         Roles:      roles,
         AuthType:   schema.AuthSession,
-        AuthSource: -1,
+        AuthSource: schema.AuthSource(authSourceInt),
     }, nil
```

Falls back to `AuthViaLocalPassword` (0, the zero value) for sessions that predate this fix — preferable to the invalid `-1`.

## No Other Files Modified

- `internal/auth/oidc.go`: already sets `AuthSource: schema.AuthViaOIDC` before calling `SaveSession`; no change needed
- Frontend templates/Svelte: no changes; the fix restores the session, which makes `User.Username` non-empty, which makes the logout button appear

## Verification

1. Configure OIDC provider (Keycloak/dex), start cc-backend
2. Open DevTools → Application → Cookies
3. Click "OpenID Connect Login", complete IdP login
4. After redirect to `/`: confirm the `session` cookie has `SameSite=Lax`, the page shows authenticated view, and the logout button is visible in the navbar
5. Click logout — confirm session is cleared and login page returns
6. Verify local password login still works (regression check — `Lax` is a superset of `Strict` for same-site requests)


User has approved your plan. You can now start coding. Start with updating your todo list if applicable

Your plan has been saved to: /Users/jan/.claude/plans/parallel-sleeping-ember.md
You can refer back to it if needed during implementation.

## Approved Plan:
# Fix: OIDC Users Missing Logout Button

## Context

OIDC-authenticated users see no logout button in the frontend navbar. The logout button in `NavbarTools.svelte` (line 104) is gated on `{#if username}`, where `username` comes from `hUsername = {{ .User.Username }}` in `base.tmpl`. When this is falsy (empty string), the button is hidden.

**Root cause**: The session cookie in `SaveSession` (`auth.go`) is set to `SameSite=Strict`. OIDC login involves a cross-site redirect chain: cc-backend → IdP → `/oidc-callback` → `/`. Chrome (and some other browsers) will not send a `SameSite=Strict` cookie when following a redirect that is part of a cross-site chain — even though the final destination is same-site. This means the session cookie is not sent on the `GET /` request, `AuthViaSession` finds no session, the `Auth` middleware calls `onfailure`, and the **login page** is rendered (which overrides the Svelte navigation block, so no logout button exists).

Local and LDAP users are unaffected because they authenticate via a direct POST to `/login` — a same-site request with no cross-site redirect chain.

**Secondary issue**: `AuthSource` is hardcoded to `-1` in `AuthViaSession`, meaning the auth source is lost after every session restore.

Note: The state/verifier/nonce cookies in `oidc.go` are already correctly set to `SameSite=Lax` (added in the security hardening commit `fd9b76c`). This fix aligns the session cookie with the same pattern.

## Changes

**Single file**: `internal/auth/auth.go`

### Change 1 — `SaveSession`: fix SameSite and store AuthSource

Around line 322–325:

```diff
-    session.Options.SameSite = http.SameSiteStrictMode
+    session.Options.SameSite = http.SameSiteLaxMode
     session.Values["username"] = user.Username
     session.Values["projects"] = user.Projects
     session.Values["roles"] = user.Roles
+    session.Values["authSource"] = int(user.AuthSource)
```

`SameSite=Lax` allows the cookie on top-level cross-site GET navigations (the OIDC callback redirect) while still blocking it on cross-site subrequests (AJAX/images/iframes) — CSRF protection for POST endpoints is preserved.

`int(user.AuthSource)` avoids needing `gob.Register` for the named `schema.AuthSource` type (no gob registration exists in this codebase).

### Change 2 — `AuthViaSession`: restore AuthSource from session

Around lines 167–173, replace the return statement:

```diff
+    authSourceInt, ok := session.Values["authSource"].(int)
+    if !ok {
+        authSourceInt = int(schema.AuthViaLocalPassword)
+    }
+
     return &schema.User{
         Username:   username,
         Projects:   projects,
         Roles:      roles,
         AuthType:   schema.AuthSession,
-        AuthSource: -1,
+        AuthSource: schema.AuthSource(authSourceInt),
     }, nil
```

Falls back to `AuthViaLocalPassword` (0, the zero value) for sessions that predate this fix — preferable to the invalid `-1`.

## No Other Files Modified

- `internal/auth/oidc.go`: already sets `AuthSource: schema.AuthViaOIDC` before calling `SaveSession`; no change needed
- Frontend templates/Svelte: no changes; the fix restores the session, which makes `User.Username` non-empty, which makes the logout button appear

## Verification

1. Configure OIDC provider (Keycloak/dex), start cc-backend
2. Open DevTools → Application → Cookies
3. Click "OpenID Connect Login", complete IdP login
4. After redirect to `/`: confirm the `session` cookie has `SameSite=Lax`, the page shows authenticated view, and the logout button is visible in the navbar
5. Click logout — confirm session is cleared and login page returns
6. Verify local password login still works (regression check — `Lax` is a superset of `Strict` for same-site requests)


Now applying both changes to `internal/auth/auth.go`.


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

<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.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{}


Edit

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

	authSourceInt, ok := session.Values["authSource"].(int)
	if !ok {
		authSourceInt = int(schema.AuthViaLocalPassword)
	}

	return &schema.User{
		Username:   username,
		Projects:   projects,
		Roles:      roles,
		AuthType:   schema.AuthSession,
		AuthSource: schema.AuthSource(authSourceInt),
	}, nil
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go has been updated successfully.

	authSourceInt, ok := session.Values["authSource"].(int)
	if !ok {
		authSourceInt = int(schema.AuthViaLocalPassword)
	}

	return &schema.User{
		Username:   username,
		Projects:   projects,
		Roles:      roles,
		AuthType:   schema.AuthSession,
		AuthSource: schema.AuthSource(authSourceInt),
	}, nil
}

// 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 implements various authentication methods
package auth

import (
	"bytes"
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"sync"
	"time"

	"golang.org/x/time/rate"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/gorilla/sessions"
)

// Authenticator is the interface for all authentication methods.
// Each authenticator determines if it can handle a login request (CanLogin)
// and performs the actual authentication (Login).
type Authenticator interface {
	// CanLogin determines if this authenticator can handle the login request.
	// It returns the user object if available and a boolean indicating if this
	// authenticator should attempt the login. This method should not perform
	// expensive operations or actual authentication.
	CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)

	// Login performs the actually authentication for the user.
	// It returns the authenticated user or an error if authentication fails.
	// The user parameter may be nil if the user doesn't exist in the database yet.
	Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
}

var (
	initOnce     sync.Once
	authInstance *Authentication
)

// rateLimiterEntry tracks a rate limiter and its last use time for cleanup
type rateLimiterEntry struct {
	limiter  *rate.Limiter
	lastUsed time.Time
}

var ipUserLimiters sync.Map

// getIPUserLimiter returns a rate limiter for the given IP and username combination.
// Rate limiters are created on demand and track 5 attempts per 15 minutes.
func getIPUserLimiter(ip, username string) *rate.Limiter {
	key := ip + ":" + username
	now := time.Now()

	if entry, ok := ipUserLimiters.Load(key); ok {
		rle := entry.(*rateLimiterEntry)
		rle.lastUsed = now
		return rle.limiter
	}

	// More aggressive rate limiting: 5 attempts per 15 minutes
	newLimiter := rate.NewLimiter(rate.Every(15*time.Minute/5), 5)
	ipUserLimiters.Store(key, &rateLimiterEntry{
		limiter:  newLimiter,
		lastUsed: now,
	})
	return newLimiter
}

// cleanupOldRateLimiters removes rate limiters that haven't been used recently
func cleanupOldRateLimiters(olderThan time.Time) {
	ipUserLimiters.Range(func(key, value any) bool {
		entry := value.(*rateLimiterEntry)
		if entry.lastUsed.Before(olderThan) {
			ipUserLimiters.Delete(key)
			cclog.Debugf("Cleaned up rate limiter for %v", key)
		}
		return true
	})
}

// startRateLimiterCleanup starts a background goroutine to clean up old rate limiters
func startRateLimiterCleanup() {
	go func() {
		ticker := time.NewTicker(1 * time.Hour)
		defer ticker.Stop()
		for range ticker.C {
			// Clean up limiters not used in the last 24 hours
			cleanupOldRateLimiters(time.Now().Add(-24 * time.Hour))
		}
	}()
}

// AuthConfig contains configuration for all authentication methods
type AuthConfig struct {
	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()
		if err := dec.Decode(&Keys); err != nil {
			cclog.Errorf("error while decoding ldap config: %v", err)
		}

		if Keys.LdapConfig != nil {
			ldapAuth := &LdapAuthenticator{}
			if err := ldapAuth.Init(); err != nil {
				cclog.Warn("Error while initializing authentication -> ldapAuth init failed")
			} else {
				authInstance.LdapAuth = ldapAuth
				authInstance.authenticators = append(authInstance.authenticators, authInstance.LdapAuth)
			}
		} else {
			cclog.Info("Missing LDAP configuration: No LDAP support!")
		}

		if Keys.JwtConfig != nil {
			authInstance.JwtAuth = &JWTAuthenticator{}
			if err := authInstance.JwtAuth.Init(); err != nil {
				cclog.Fatal("Error while initializing authentication -> jwtAuth init failed")
			}

			jwtSessionAuth := &JWTSessionAuthenticator{}
			if err := jwtSessionAuth.Init(); err != nil {
				cclog.Info("jwtSessionAuth init failed: No JWT login support!")
			} else {
				authInstance.authenticators = append(authInstance.authenticators, jwtSessionAuth)
			}

			jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{}
			if err := jwtCookieSessionAuth.Init(); err != nil {
				cclog.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!")
			} else {
				authInstance.authenticators = append(authInstance.authenticators, jwtCookieSessionAuth)
			}
		} else {
			cclog.Info("Missing JWT configuration: No JWT token support!")
		}

		authInstance.LocalAuth = &LocalAuthenticator{}
		if err := authInstance.LocalAuth.Init(); err != nil {
			cclog.Fatal("Error while initializing authentication -> localAuth init failed")
		}
		authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth)
	})
}

func GetAuthInstance() *Authentication {
	if authInstance == nil {
		cclog.Fatal("Authentication module not initialized!")
	}

	return authInstance
}

// handleUserSync syncs or updates a user in the database based on configuration.
// This is used for LDAP, JWT and OIDC authentications when syncUserOnLogin or updateUserOnLogin is enabled.
func handleUserSync(user *schema.User, syncUserOnLogin, updateUserOnLogin bool) {
	r := repository.GetUserRepository()
	dbUser, err := r.GetUser(user.Username)

	if err != nil && err != sql.ErrNoRows {
		cclog.Errorf("Error while loading user '%s': %v", user.Username, err)
		return
	}

	if err == sql.ErrNoRows && syncUserOnLogin { // Add new user
		if err := r.AddUser(user); err != nil {
			cclog.Errorf("Error while adding user '%s' to DB: %v", user.Username, err)
		}
	} else if err == nil && updateUserOnLogin { // Update existing user
		if err := r.UpdateUser(dbUser, user); err != nil {
			cclog.Errorf("Error while updating user '%s' in DB: %v", dbUser.Username, err)
		}
	}
}

// handleTokenUser syncs JWT token user with database
func handleTokenUser(tokenUser *schema.User) {
	handleUserSync(tokenUser, Keys.JwtConfig.SyncUserOnLogin, Keys.JwtConfig.UpdateUserOnLogin)
}

// 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)
			}
		}

		for _, authenticator := range auth.authenticators {
			var ok bool
			var user *schema.User
			if user, ok = authenticator.CanLogin(dbUser, username, rw, r); !ok {
				continue
			} else {
				cclog.Debugf("Can login with user %v", user)
			}

			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 {
			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")
	}

	// extract IP address for checking
	IPAddress := r.Header.Get("X-Real-Ip")
	if IPAddress == "" {
		IPAddress = r.Header.Get("X-Forwarded-For")
	}
	if IPAddress == "" {
		IPAddress = r.RemoteAddr
	}

	// Handle both IPv4 and IPv6 addresses properly
	// For IPv6, this will strip the port and brackets
	// For IPv4, this will strip the port
	if host, _, err := net.SplitHostPort(IPAddress); err == nil {
		IPAddress = host
	}
	// If SplitHostPort fails, IPAddress is already just a host (no port)

	// If nothing declared in config: Continue
	if len(config.Keys.APIAllowedIPs) == 0 {
		return nil
	}
	// If wildcard declared in config: Continue
	if config.Keys.APIAllowedIPs[0] == "*" {
		return nil
	}
	// check if IP is allowed
	if !util.Contains(config.Keys.APIAllowedIPs, IPAddress) {
		return fmt.Errorf("unknown ip: %v", IPAddress)
	}

	return nil
}

+  authSourceInt, ok := session.Values["authSource"].(int)

+    authSourceInt = int(schema.AuthViaLocalPassword)

+    AuthSource: schema.AuthSource(authSourceInt),


	session.Options.SameSite = http.SameSiteStrictMode
	session.Values["username"] = user.Username
	session.Values["projects"] = user.Projects
	session.Values["roles"] = user.Roles

	session.Options.SameSite = http.SameSiteLaxMode
	session.Values["username"] = user.Username
	session.Values["projects"] = user.Projects
	session.Values["roles"] = user.Roles
	session.Values["authSource"] = int(user.AuthSource)


	session.Options.SameSite = http.SameSiteLaxMode
	session.Values["username"] = user.Username
	session.Values["projects"] = user.Projects
	session.Values["roles"] = user.Roles
	session.Values["authSource"] = int(user.AuthSource)

// 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 implements various authentication methods
package auth

import (
	"bytes"
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"sync"
	"time"

	"golang.org/x/time/rate"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/gorilla/sessions"
)

// Authenticator is the interface for all authentication methods.
// Each authenticator determines if it can handle a login request (CanLogin)
// and performs the actual authentication (Login).
type Authenticator interface {
	// CanLogin determines if this authenticator can handle the login request.
	// It returns the user object if available and a boolean indicating if this
	// authenticator should attempt the login. This method should not perform
	// expensive operations or actual authentication.
	CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)

	// Login performs the actually authentication for the user.
	// It returns the authenticated user or an error if authentication fails.
	// The user parameter may be nil if the user doesn't exist in the database yet.
	Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
}

var (
	initOnce     sync.Once
	authInstance *Authentication
)

// rateLimiterEntry tracks a rate limiter and its last use time for cleanup
type rateLimiterEntry struct {
	limiter  *rate.Limiter
	lastUsed time.Time
}

var ipUserLimiters sync.Map

// getIPUserLimiter returns a rate limiter for the given IP and username combination.
// Rate limiters are created on demand and track 5 attempts per 15 minutes.
func getIPUserLimiter(ip, username string) *rate.Limiter {
	key := ip + ":" + username
	now := time.Now()

	if entry, ok := ipUserLimiters.Load(key); ok {
		rle := entry.(*rateLimiterEntry)
		rle.lastUsed = now
		return rle.limiter
	}

	// More aggressive rate limiting: 5 attempts per 15 minutes
	newLimiter := rate.NewLimiter(rate.Every(15*time.Minute/5), 5)
	ipUserLimiters.Store(key, &rateLimiterEntry{
		limiter:  newLimiter,
		lastUsed: now,
	})
	return newLimiter
}

// cleanupOldRateLimiters removes rate limiters that haven't been used recently
func cleanupOldRateLimiters(olderThan time.Time) {
	ipUserLimiters.Range(func(key, value any) bool {
		entry := value.(*rateLimiterEntry)
		if entry.lastUsed.Before(olderThan) {
			ipUserLimiters.Delete(key)
			cclog.Debugf("Cleaned up rate limiter for %v", key)
		}
		return true
	})
}

// startRateLimiterCleanup starts a background goroutine to clean up old rate limiters
func startRateLimiterCleanup() {
	go func() {
		ticker := time.NewTicker(1 * time.Hour)
		defer ticker.Stop()
		for range ticker.C {
			// Clean up limiters not used in the last 24 hours
			cleanupOldRateLimiters(time.Now().Add(-24 * time.Hour))
		}
	}()
}

// AuthConfig contains configuration for all authentication methods
type AuthConfig struct {
	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")
	}

	authSourceInt, ok := session.Values["authSource"].(int)
	if !ok {
		authSourceInt = int(schema.AuthViaLocalPassword)
	}

	return &schema.User{
		Username:   username,
		Projects:   projects,
		Roles:      roles,
		AuthType:   schema.AuthSession,
		AuthSource: schema.AuthSource(authSourceInt),
	}, 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()
		if err := dec.Decode(&Keys); err != nil {
			cclog.Errorf("error while decoding ldap config: %v", err)
		}

		if Keys.LdapConfig != nil {
			ldapAuth := &LdapAuthenticator{}
			if err := ldapAuth.Init(); err != nil {
				cclog.Warn("Error while initializing authentication -> ldapAuth init failed")
			} else {
				authInstance.LdapAuth = ldapAuth
				authInstance.authenticators = append(authInstance.authenticators, authInstance.LdapAuth)
			}
		} else {
			cclog.Info("Missing LDAP configuration: No LDAP support!")
		}

		if Keys.JwtConfig != nil {
			authInstance.JwtAuth = &JWTAuthenticator{}
			if err := authInstance.JwtAuth.Init(); err != nil {
				cclog.Fatal("Error while initializing authentication -> jwtAuth init failed")
			}

			jwtSessionAuth := &JWTSessionAuthenticator{}
			if err := jwtSessionAuth.Init(); err != nil {
				cclog.Info("jwtSessionAuth init failed: No JWT login support!")
			} else {
				authInstance.authenticators = append(authInstance.authenticators, jwtSessionAuth)
			}

			jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{}
			if err := jwtCookieSessionAuth.Init(); err != nil {
				cclog.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!")
			} else {
				authInstance.authenticators = append(authInstance.authenticators, jwtCookieSessionAuth)
			}
		} else {
			cclog.Info("Missing JWT configuration: No JWT token support!")
		}

		authInstance.LocalAuth = &LocalAuthenticator{}
		if err := authInstance.LocalAuth.Init(); err != nil {
			cclog.Fatal("Error while initializing authentication -> localAuth init failed")
		}
		authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth)
	})
}

func GetAuthInstance() *Authentication {
	if authInstance == nil {
		cclog.Fatal("Authentication module not initialized!")
	}

	return authInstance
}

// handleUserSync syncs or updates a user in the database based on configuration.
// This is used for LDAP, JWT and OIDC authentications when syncUserOnLogin or updateUserOnLogin is enabled.
func handleUserSync(user *schema.User, syncUserOnLogin, updateUserOnLogin bool) {
	r := repository.GetUserRepository()
	dbUser, err := r.GetUser(user.Username)

	if err != nil && err != sql.ErrNoRows {
		cclog.Errorf("Error while loading user '%s': %v", user.Username, err)
		return
	}

	if err == sql.ErrNoRows && syncUserOnLogin { // Add new user
		if err := r.AddUser(user); err != nil {
			cclog.Errorf("Error while adding user '%s' to DB: %v", user.Username, err)
		}
	} else if err == nil && updateUserOnLogin { // Update existing user
		if err := r.UpdateUser(dbUser, user); err != nil {
			cclog.Errorf("Error while updating user '%s' in DB: %v", dbUser.Username, err)
		}
	}
}

// handleTokenUser syncs JWT token user with database
func handleTokenUser(tokenUser *schema.User) {
	handleUserSync(tokenUser, Keys.JwtConfig.SyncUserOnLogin, Keys.JwtConfig.UpdateUserOnLogin)
}

// 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)
			}
		}

		for _, authenticator := range auth.authenticators {
			var ok bool
			var user *schema.User
			if user, ok = authenticator.CanLogin(dbUser, username, rw, r); !ok {
				continue
			} else {
				cclog.Debugf("Can login with user %v", user)
			}

			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 {
			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")
	}

	// extract IP address for checking
	IPAddress := r.Header.Get("X-Real-Ip")
	if IPAddress == "" {
		IPAddress = r.Header.Get("X-Forwarded-For")
	}
	if IPAddress == "" {
		IPAddress = r.RemoteAddr
	}

	// Handle both IPv4 and IPv6 addresses properly
	// For IPv6, this will strip the port and brackets
	// For IPv4, this will strip the port
	if host, _, err := net.SplitHostPort(IPAddress); err == nil {
		IPAddress = host
	}
	// If SplitHostPort fails, IPAddress is already just a host (no port)

	// If nothing declared in config: Continue
	if len(config.Keys.APIAllowedIPs) == 0 {
		return nil
	}
	// If wildcard declared in config: Continue
	if config.Keys.APIAllowedIPs[0] == "*" {
		return nil
	}
	// check if IP is allowed
	if !util.Contains(config.Keys.APIAllowedIPs, IPAddress) {
		return fmt.Errorf("unknown ip: %v", IPAddress)
	}

	return nil
}

-  session.Options.SameSite = http.SameSiteStrictMode

+  session.Options.SameSite = http.SameSiteLaxMode

+  session.Values["authSource"] = int(user.AuthSource)


Let me verify both changes look correct:


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		authSourceInt, ok := session.Values["authSource"].(int)
168		if !ok {
169			authSourceInt = int(schema.AuthViaLocalPassword)
170		}
171
172		return &schema.User{
173			Username:   username,
174			Projects:   projects,
175			Roles:      roles,
176			AuthType:   schema.AuthSession,
177			AuthSource: schema.AuthSource(authSourceInt),
178		}, nil
179	}
180
181	func Init(authCfg *json.RawMessage) {
182		initOnce.Do(func() {
183			authInstance = &Authentication{}
184

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

		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")
	}

	authSourceInt, ok := session.Values["authSource"].(int)
	if !ok {
		authSourceInt = int(schema.AuthViaLocalPassword)
	}

	return &schema.User{
		Username:   username,
		Projects:   projects,
		Roles:      roles,
		AuthType:   schema.AuthSession,
		AuthSource: schema.AuthSource(authSourceInt),
	}, nil
}

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


318		if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
319			// If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure.
320			cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)")
321			if r.Header.Get("X-Forwarded-Proto") == "" {
322				// This warning will not be printed if e.g. X-Forwarded-Proto == http
323				cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set")
324			}
325			session.Options.Secure = false
326		}
327		session.Options.SameSite = http.SameSiteLaxMode
328		session.Values["username"] = user.Username
329		session.Values["projects"] = user.Projects
330		session.Values["roles"] = user.Roles
331		session.Values["authSource"] = int(user.AuthSource)
332		if err := auth.sessionStore.Save(r, rw, session); 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>

	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.SameSiteLaxMode
	session.Values["username"] = user.Username
	session.Values["projects"] = user.Projects
	session.Values["roles"] = user.Roles
	session.Values["authSource"] = int(user.AuthSource)
	if err := auth.sessionStore.Save(r, rw, session); err != nil {


Both changes are correct. Let me do a quick build check:


Bash

Build auth package to verify no compile errors


(Bash completed with no output)


Builds cleanly. Here's a summary of what was changed:

**`internal/auth/auth.go` — 2 changes:**

1. **`SaveSession` (line 327)**: `SameSiteStrictMode` → `SameSiteLaxMode`
   The session cookie now travels with cross-site top-level navigations (like the final redirect in the OIDC flow: IdP → `/oidc-callback` → `/`). Chrome and other modern browsers refuse to send `SameSite=Strict` cookies when the redirect chain includes a cross-site hop, which caused the Auth middleware to find no session and render the login page instead of the authenticated home page. CSRF protection for POST endpoints is unchanged — `Lax` still blocks the cookie on cross-site subrequests.

2. **`SaveSession` (line 331) + `AuthViaSession` (lines 167–178)**: `authSource` is now persisted in the session and correctly restored. Previously the value was hardcoded to `-1` (not a valid `AuthSource` constant) on every session restore.