package auth import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "fmt" "log" "net/http" "net/url" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning" "github.com/alexedwards/scs/redisstore" "github.com/alexedwards/scs/v2" "github.com/coreos/go-oidc/v3/oidc" "github.com/gomodule/redigo/redis" "github.com/spf13/viper" "golang.org/x/oauth2" ) // Config holds all auth-related configuration type Config struct { SessionManager *scs.SessionManager OAuthConfig *oauth2.Config Verifier *oidc.IDTokenVerifier Provider *oidc.Provider Database *sql.DB // Raw DB for transactions (auto-provisioning) IdentityQ identity.Querier // Identity module queries OrgQ organization.Querier // Organization module queries } // Setup initializes the auth configuration func Setup(database *sql.DB, identityQ identity.Querier, orgQ organization.Querier) (*Config, error) { // Create Redis pool for Valkey pool := &redis.Pool{ MaxIdle: 10, IdleTimeout: 240 * time.Second, Dial: func() (redis.Conn, error) { return redis.Dial("tcp", viper.GetString("valkey-addr")) }, TestOnBorrow: func(c redis.Conn, t time.Time) error { if time.Since(t) < time.Minute { return nil } _, err := c.Do("PING") return err }, } // Test connection conn := pool.Get() defer conn.Close() if _, err := conn.Do("PING"); err != nil { return nil, fmt.Errorf("failed to connect to Valkey: %w", err) } log.Printf("Connected to Valkey at %s", viper.GetString("valkey-addr")) // Create session manager sessionManager := scs.New() sessionManager.Store = redisstore.New(pool) sessionManager.Lifetime = 7 * 24 * time.Hour // 1 week sessionManager.Cookie.Name = "session" sessionManager.Cookie.Path = "/" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.Secure = viper.GetString("env") == "production" sessionManager.Cookie.SameSite = http.SameSiteLaxMode // Initialize OIDC provider ctx := context.Background() provider, err := oidc.NewProvider(ctx, viper.GetString("oidc-idp-issuer-url")) if err != nil { return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err) } // Create OAuth2 config oauthConfig := &oauth2.Config{ ClientID: viper.GetString("oidc-sp-client-id"), ClientSecret: viper.GetString("oidc-sp-client-secret"), RedirectURL: viper.GetString("base-url") + "/callback", Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } // Create auth config config := &Config{ SessionManager: sessionManager, OAuthConfig: oauthConfig, Provider: provider, Verifier: provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID}), Database: database, IdentityQ: identityQ, OrgQ: orgQ, } return config, nil } // RegisterHandlers adds all auth-related handlers to the router func (c *Config) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/login", c.LoginHandler) mux.HandleFunc("/callback", c.CallbackHandler) mux.HandleFunc("/logout", c.LogoutHandler) mux.HandleFunc("/logout-callback", c.LogoutCallbackHandler) mux.HandleFunc("/register", c.RegistrationHandler) } // Middleware returns an auth middleware function. selfAuthenticatedPaths // are integration-declared endpoints that verify their own caller (provider // webhooks checking an HMAC signature) — they must bypass session auth or // the provider's delivery bounces off the /login redirect. The server // passes the paths collected from RouteMount.CSRFExemptPaths: a path that // authenticates by signature is exempt from both protections for the same // reason. func (c *Config) Middleware(selfAuthenticatedPaths ...string) func(http.Handler) http.Handler { publicPaths := map[string]bool{ "/login": true, "/callback": true, "/logout": true, "/logout-callback": true, "/register": true, "/favicon.ico": true, // Browser-fired in parallel with page loads; must not bounce through /login } for _, p := range selfAuthenticatedPaths { publicPaths[p] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if publicPaths[r.URL.Path] { next.ServeHTTP(w, r) return } // Skip authentication for static assets (public, no session needed) if strings.HasPrefix(r.URL.Path, "/static/") { next.ServeHTTP(w, r) return } // Check if authenticated if !c.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated) { // An HTMX request can't follow a 302 to the cross-origin // Keycloak authorize URL — the XHR fails with status 0 and // error-handler.js shows a misleading "Network error" toast // with no way back to /login (finding #15). Respond 401 with // HX-Redirect so htmx performs a same-origin client-side // redirect to /login instead, matching the HX-Request // convention used elsewhere (billing.go, operator.go). if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/login") w.WriteHeader(http.StatusUnauthorized) return } http.Redirect(w, r, "/login", http.StatusFound) return } next.ServeHTTP(w, r) }) } } // LoginHandler initiates the OIDC authentication flow func (c *Config) LoginHandler(w http.ResponseWriter, r *http.Request) { // Rotate the session token before writing OIDC flow data. // // Defends against two things: // 1. Session fixation (OAuth 2.1 BCP §4.5 — start every authn from a // fresh session ID). // 2. Stale browser cookies pointing at a session that no longer exists // in the store (e.g. after a Valkey teardown). Without this, scs // silently creates an empty session on the store miss, /login writes // state into it, but the browser keeps sending the original cookie // to /callback — savedState comes back empty and the user is stuck. // RenewToken issues a new session ID and Set-Cookie on the redirect // to the IdP, guaranteeing the cookie that returns to /callback maps // to the session we just wrote state into. if err := c.SessionManager.RenewToken(r.Context()); err != nil { log.Printf("LoginHandler: failed to renew session token: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Reuse a pending, recent OIDC flow instead of overwriting it. Parallel // /login hits in one session (a page load fanning out into a favicon // fetch, or a second tab) each used to generate fresh state and clobber // the other's; the IdP then returned a state the session no longer held, // producing "State mismatch" 400s at /callback. The state stays // single-use (CallbackHandler clears it on success) and a stale flow is // replaced wholesale. state := c.SessionManager.GetString(r.Context(), sessionKeyState) nonce := c.SessionManager.GetString(r.Context(), sessionKeyNonce) codeVerifier := c.SessionManager.GetString(r.Context(), sessionKeyCodeVerifier) issuedAt := c.SessionManager.GetInt64(r.Context(), sessionKeyStateIssuedAt) reusable := state != "" && nonce != "" && codeVerifier != "" && issuedAt > 0 && time.Since(time.Unix(issuedAt, 0)) < loginStateReuseWindow if !reusable { state = generateRandomString(32) nonce = generateRandomString(32) codeVerifier = generateRandomString(32) // Store OIDC flow data in session c.SessionManager.Put(r.Context(), sessionKeyState, state) c.SessionManager.Put(r.Context(), sessionKeyNonce, nonce) c.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, codeVerifier) c.SessionManager.Put(r.Context(), sessionKeyStateIssuedAt, time.Now().Unix()) } hashVal := sha256.Sum256([]byte(codeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(hashVal[:]) authURL := c.OAuthConfig.AuthCodeURL( state, oidc.Nonce(nonce), oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) http.Redirect(w, r, authURL, http.StatusFound) } // CallbackHandler processes the OIDC callback func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Validate state. // // A genuine mismatch (savedState present but different) stays a hard 400 — // it's a CSRF/replay signal. But savedState == "" means the session we // wrote state into at /login is gone by the time the IdP redirected back // (lost cookie, expired session, store wiped). That's a UX failure, not an // attack: send the user back through /login so they get a fresh session // and try once more transparently. state := r.URL.Query().Get("state") savedState := c.SessionManager.GetString(ctx, sessionKeyState) if savedState == "" { log.Printf("CallbackHandler: no saved state in session — redirecting to /login for a fresh attempt") http.Redirect(w, r, "/login", http.StatusFound) return } if state != savedState { log.Printf("CallbackHandler: State mismatch: got %s, expected %s", state, savedState) http.Error(w, "Invalid state", http.StatusBadRequest) return } // Exchange code code := r.URL.Query().Get("code") codeVerifier := c.SessionManager.GetString(ctx, sessionKeyCodeVerifier) if codeVerifier == "" { log.Printf("CallbackHandler: No code verifier found in session") http.Error(w, "No code verifier found", http.StatusBadRequest) return } token, err := c.OAuthConfig.Exchange( ctx, code, oauth2.VerifierOption(codeVerifier), ) if err != nil { log.Printf("CallbackHandler: Token exchange failed: %v", err) http.Error(w, "Token exchange failed", http.StatusInternalServerError) return } // Verify ID token rawIDToken, ok := token.Extra("id_token").(string) if !ok { log.Printf("CallbackHandler: Missing ID token from token extra") http.Error(w, "Missing ID token", http.StatusInternalServerError) return } idToken, err := c.Verifier.Verify(ctx, rawIDToken) if err != nil { log.Printf("CallbackHandler: ID token verification failed: %v", err) http.Error(w, "Invalid ID token", http.StatusInternalServerError) return } // Verify nonce var nonceClaims struct { Nonce string `json:"nonce"` } if err := idToken.Claims(&nonceClaims); err != nil { log.Printf("CallbackHandler: Could not parse nonce claims: %v", err) http.Error(w, "Could not parse claims for nonce", http.StatusInternalServerError) return } savedNonce := c.SessionManager.GetString(ctx, sessionKeyNonce) if nonceClaims.Nonce != savedNonce { log.Printf("CallbackHandler: Invalid nonce") http.Error(w, "Invalid nonce", http.StatusBadRequest) return } // ---- Database Interaction & User Info Extraction ---- var userInfoClaims struct { Subject string `json:"sub"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` } if err := idToken.Claims(&userInfoClaims); err != nil { log.Printf("CallbackHandler: Error parsing user info claims: %v", err) http.Error(w, "Could not parse user info claims", http.StatusInternalServerError) return } // Session variables to populate var personID, orgID, workspaceID string user, err := c.IdentityQ.GetUserByOIDCSubject(ctx, userInfoClaims.Subject) if err != nil { if err == sql.ErrNoRows { // New user — auto-provision all governance structures result, errProv := provisioning.AutoProvision(ctx, c.Database, provisioning.OIDCClaims{ Subject: userInfoClaims.Subject, Email: userInfoClaims.Email, EmailVerified: userInfoClaims.EmailVerified, Name: userInfoClaims.Name, PreferredUsername: userInfoClaims.PreferredUsername, }) if errProv != nil { log.Printf("CallbackHandler: Error auto-provisioning user: %v", errProv) http.Error(w, "Failed to create user record", http.StatusInternalServerError) return } user = result.User personID = result.Person.PersonID orgID = result.Org.OrgID workspaceID = result.Workspace.WorkspaceID log.Printf("CallbackHandler: New user provisioned: %s (person=%s, org=%s)", userInfoClaims.Email, personID, orgID) } else { log.Printf("CallbackHandler: Error fetching user by OIDC Subject ID '%s': %v", userInfoClaims.Subject, err) http.Error(w, "Database error while fetching user", http.StatusInternalServerError) return } } else { // Returning user — load existing governance records and update login _, errLogin := c.IdentityQ.UpdateUserLogin(ctx, identity.UpdateUserLoginParams{ LastLoginAt: sql.NullTime{Time: time.Now(), Valid: true}, LastLoginIp: sql.NullString{String: r.RemoteAddr, Valid: true}, UserID: user.UserID, }) if errLogin != nil { log.Printf("CallbackHandler: Error updating user login: %v", errLogin) } // Load person person, errPerson := c.IdentityQ.GetPersonByUserID(ctx, user.UserID) if errPerson != nil { log.Printf("CallbackHandler: Error loading person for user %s: %v", user.UserID, errPerson) http.Error(w, "Failed to load user profile", http.StatusInternalServerError) return } personID = person.PersonID // Update person if OIDC claims changed if person.DisplayName != userInfoClaims.Name || person.PrimaryEmail != userInfoClaims.Email { _, errUpdate := c.IdentityQ.UpdatePerson(ctx, identity.UpdatePersonParams{ DisplayName: userInfoClaims.Name, PrimaryEmail: userInfoClaims.Email, PrimaryEmailVerified: userInfoClaims.EmailVerified, PersonID: person.PersonID, }) if errUpdate != nil { log.Printf("CallbackHandler: Error updating person %s: %v", person.PersonID, errUpdate) } } // Load personal org (first org owned by this person) orgs, errOrgs := c.OrgQ.GetOrganizationsByOwner(ctx, personID) if errOrgs != nil || len(orgs) == 0 { log.Printf("CallbackHandler: Error loading org for person %s: %v", personID, errOrgs) http.Error(w, "Failed to load organization", http.StatusInternalServerError) return } orgID = orgs[0].OrgID // Load default workspace workspaces, errWS := c.OrgQ.GetWorkspacesByOrgID(ctx, orgID) if errWS != nil || len(workspaces) == 0 { log.Printf("CallbackHandler: Error loading workspace for org %s: %v", orgID, errWS) http.Error(w, "Failed to load workspace", http.StatusInternalServerError) return } workspaceID = workspaces[0].WorkspaceID log.Printf("CallbackHandler: Returning user: %s (person=%s, org=%s)", userInfoClaims.Email, personID, orgID) } // ---- Extract roles from access token (IDP-agnostic) ---- var roleClaims rolesClaims accessToken := token.AccessToken if accessToken != "" { if parts := splitJWT(accessToken); len(parts) == 3 { if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil { if err := json.Unmarshal(payload, &roleClaims); err != nil { log.Printf("CallbackHandler: Could not parse roles from access token: %v", err) } } } } roles := extractRoles(roleClaims, c.OAuthConfig.ClientID) log.Printf("CallbackHandler: User %s has IdP roles: %v", userInfoClaims.Email, roles) // Clear OIDC flow data and set authenticated session data c.SessionManager.Remove(ctx, sessionKeyState) c.SessionManager.Remove(ctx, sessionKeyNonce) c.SessionManager.Remove(ctx, sessionKeyCodeVerifier) c.SessionManager.Remove(ctx, sessionKeyStateIssuedAt) c.SessionManager.Put(ctx, sessionKeyAuthenticated, true) c.SessionManager.Put(ctx, sessionKeyIDToken, rawIDToken) c.SessionManager.Put(ctx, sessionKeyPersonID, personID) c.SessionManager.Put(ctx, sessionKeyOrgID, orgID) c.SessionManager.Put(ctx, sessionKeyWorkspaceID, workspaceID) c.SessionManager.Put(ctx, sessionKeyOIDCSubject, user.OidcSubject) c.SessionManager.Put(ctx, sessionKeyEmail, userInfoClaims.Email) c.SessionManager.Put(ctx, sessionKeyName, userInfoClaims.Name) c.SessionManager.Put(ctx, sessionKeyUsername, userInfoClaims.PreferredUsername) c.SessionManager.Put(ctx, sessionKeyRoles, roles) log.Printf("CallbackHandler: User %s authenticated successfully. Redirecting to /", userInfoClaims.Email) http.Redirect(w, r, "/", http.StatusFound) } // LogoutHandler handles user logout func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Generate logout state for verification state := generateRandomString(32) c.SessionManager.Put(ctx, sessionKeyLogoutState, state) // Build logout URL keycloakLogoutURL, err := url.Parse(viper.GetString("oidc-idp-issuer-url") + "/protocol/openid-connect/logout") if err != nil { log.Printf("Error parsing logout URL: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Add query parameters q := keycloakLogoutURL.Query() q.Set("post_logout_redirect_uri", viper.GetString("base-url")+"/logout-callback") q.Set("client_id", viper.GetString("oidc-sp-client-id")) q.Set("state", state) // Add id_token_hint if available if idToken := c.SessionManager.GetString(ctx, sessionKeyIDToken); idToken != "" { q.Set("id_token_hint", idToken) } keycloakLogoutURL.RawQuery = q.Encode() log.Printf("Redirecting to logout URL: %s", keycloakLogoutURL.String()) http.Redirect(w, r, keycloakLogoutURL.String(), http.StatusFound) } // LogoutCallbackHandler handles the redirect after Keycloak logout func (c *Config) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Verify state parameter returnedState := r.URL.Query().Get("state") savedState := c.SessionManager.GetString(ctx, sessionKeyLogoutState) if savedState == "" { log.Printf("No logout state found in session") http.Error(w, "Invalid session state", http.StatusBadRequest) return } if returnedState != savedState { log.Printf("State mismatch: %s != %s", returnedState, savedState) http.Error(w, "Invalid state", http.StatusBadRequest) return } // Destroy the session if err := c.SessionManager.Destroy(ctx); err != nil { log.Printf("Error destroying session: %v", err) http.Error(w, "Server error", http.StatusInternalServerError) return } // Redirect to login page http.Redirect(w, r, "/login", http.StatusFound) } // RegistrationHandler redirects to the OIDC registration page func (c *Config) RegistrationHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() state := generateRandomString(32) nonce := generateRandomString(32) codeVerifier := generateRandomString(32) hashVal := sha256.Sum256([]byte(codeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(hashVal[:]) c.SessionManager.Put(ctx, sessionKeyState, state) c.SessionManager.Put(ctx, sessionKeyNonce, nonce) c.SessionManager.Put(ctx, sessionKeyCodeVerifier, codeVerifier) registrationEndpoint := viper.GetString("oidc-idp-issuer-url") + "/protocol/openid-connect/registrations" parsedRegistrationURL, err := url.Parse(registrationEndpoint) if err != nil { log.Printf("RegistrationHandler: Error parsing registration URL '%s': %v", registrationEndpoint, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } q := parsedRegistrationURL.Query() q.Set("client_id", viper.GetString("oidc-sp-client-id")) q.Set("response_type", "code") q.Set("scope", "openid email profile") q.Set("redirect_uri", viper.GetString("base-url")+"/callback") q.Set("state", state) q.Set("nonce", nonce) q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") parsedRegistrationURL.RawQuery = q.Encode() log.Printf("RegistrationHandler: Redirecting to registration URL: %s", parsedRegistrationURL.String()) http.Redirect(w, r, parsedRegistrationURL.String(), http.StatusFound) } // Session key constants - single source of truth for session data const ( sessionKeyAuthenticated = "authenticated" sessionKeyIDToken = "id_token" sessionKeyPersonID = "person_id" sessionKeyOrgID = "org_id" sessionKeyWorkspaceID = "workspace_id" sessionKeyOIDCSubject = "oidc_subject" sessionKeyEmail = "email" sessionKeyName = "name" sessionKeyUsername = "username" sessionKeyRoles = "roles" // OIDC flow keys (temporary) sessionKeyState = "state" sessionKeyNonce = "nonce" sessionKeyCodeVerifier = "code_verifier" sessionKeyStateIssuedAt = "state_issued_at" sessionKeyLogoutState = "logout_state" ) // loginStateReuseWindow bounds how long a pending (unconsumed) OIDC flow is // reused by subsequent /login hits instead of being regenerated. Long enough // to cover parallel redirects from one navigation plus a user pausing at the // IdP form; short enough that an abandoned flow's state has a bounded life. const loginStateReuseWindow = 5 * time.Minute // UserSession contains the authenticated user's session data. // This provides type-safe access to session values. type UserSession struct { PersonID string OrgID string WorkspaceID string OIDCSubject string Email string Name string Username string Roles []string } // GetUserSession retrieves the authenticated user's session data. // Returns nil if the user is not authenticated. func (c *Config) GetUserSession(ctx context.Context) *UserSession { if !c.SessionManager.GetBool(ctx, sessionKeyAuthenticated) { return nil } return &UserSession{ PersonID: c.SessionManager.GetString(ctx, sessionKeyPersonID), OrgID: c.SessionManager.GetString(ctx, sessionKeyOrgID), WorkspaceID: c.SessionManager.GetString(ctx, sessionKeyWorkspaceID), OIDCSubject: c.SessionManager.GetString(ctx, sessionKeyOIDCSubject), Email: c.SessionManager.GetString(ctx, sessionKeyEmail), Name: c.SessionManager.GetString(ctx, sessionKeyName), Username: c.SessionManager.GetString(ctx, sessionKeyUsername), Roles: c.getRoles(ctx), } } // GetPersonID returns the person UUID of the authenticated user. // Returns empty string if the user is not authenticated. func (c *Config) GetPersonID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyPersonID) } // GetOrgID returns the active organization UUID. func (c *Config) GetOrgID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyOrgID) } // GetWorkspaceID returns the active workspace UUID. func (c *Config) GetWorkspaceID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyWorkspaceID) } // GetUserEmail returns the email of the authenticated user. func (c *Config) GetUserEmail(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyEmail) } // GetUserName returns the display name of the authenticated user. func (c *Config) GetUserName(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyName) } // GetUsername returns the username of the authenticated user. func (c *Config) GetUsername(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyUsername) } // IsAuthenticated returns true if the user has an active session. func (c *Config) IsAuthenticated(ctx context.Context) bool { return c.SessionManager.GetBool(ctx, sessionKeyAuthenticated) } // HasRole checks if the current user has the specified role. func (c *Config) HasRole(r *http.Request, role string) bool { roles := c.getRoles(r.Context()) for _, r := range roles { if r == role { return true } } return false } // getRoles retrieves the roles from session. func (c *Config) getRoles(ctx context.Context) []string { roles, ok := c.SessionManager.Get(ctx, sessionKeyRoles).([]string) if !ok { return nil } return roles } // Helper function to generate random strings func generateRandomString(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { log.Fatalf("Failed to generate random string: %v", err) panic(err) } return base64.RawURLEncoding.EncodeToString(b) } // splitJWT splits a JWT into its parts without validation func splitJWT(token string) []string { return strings.Split(token, ".") } // rolesClaims is the subset of access-token claims that may carry roles, // across IdPs: top-level roles/groups (generic), realm_access (Keycloak // realm roles), and resource_access (Keycloak client-scoped roles, keyed by // client ID). type rolesClaims struct { Roles []string `json:"roles"` Groups []string `json:"groups"` RealmAccess struct { Roles []string `json:"roles"` } `json:"realm_access"` ResourceAccess map[string]struct { Roles []string `json:"roles"` } `json:"resource_access"` } // extractRoles merges every claim location an IdP may put roles in, rather // than letting the first non-empty location mask the rest: a deployment's // role names are its contract, and they count wherever the IdP carries them. // The result is deduplicated and order-stable (source order above, then // claim order). clientID scopes the resource_access lookup to this app's // OIDC client; other clients' roles never leak in. func extractRoles(claims rolesClaims, clientID string) []string { var merged []string seen := make(map[string]struct{}) add := func(roles []string) { for _, role := range roles { if _, dup := seen[role]; dup { continue } seen[role] = struct{}{} merged = append(merged, role) } } add(claims.Roles) add(claims.Groups) add(claims.RealmAccess.Roles) add(claims.ResourceAccess[clientID].Roles) return merged }