Files
member-console/internal/auth/auth.go
T
cgalo5758 8e7e0dd04d Harden container, OIDC auth, and error handling
Run Docker runtime stage as non-root user app (UID 65532).

Add styled full-page 404/500 error rendering for navigation requests
while preserving plain-text responses for HTMX partials.

Reuse recent unconsumed OIDC login state to avoid state mismatch on
parallel login hits, and merge resource_access in role extraction.

Re-level template headings, add autocomplete tokens, and resolve
catalog resource display names.

Self-label test-stack secrets and document CSRF secret rotation.
2026-07-31 23:27:15 -05:00

712 lines
25 KiB
Go

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
}