package main import ( "context" "net/http" "strings" ) type authContextKey string const userClaimsKey authContextKey = "userClaims" const tenantContextKey authContextKey = "tenantContext" type accessClaims struct { Sub string `json:"sub"` Email string `json:"email"` PreferredUsername string `json:"preferred_username"` AuthorizedParty string `json:"azp"` TenantID string `json:"tenant_id"` TenantIDAlt string `json:"tenantId"` OrganizationID string `json:"organization_id"` OrganizationIDAlt string `json:"organizationId"` OrganisationID string `json:"organisation_id"` OrganisationIDAlt string `json:"organisationId"` OrgID string `json:"org_id"` OrgIDAlt string `json:"orgId"` Permissions []string `json:"permissions"` Scope string `json:"scope"` Scopes []string `json:"scopes"` RealmAccess struct { Roles []string `json:"roles"` } `json:"realm_access"` } func hasOAuthScope(claims *accessClaims, required string) bool { if claims == nil { return false } for _, scope := range append(strings.Fields(claims.Scope), claims.Scopes...) { if scope == required { return true } } return false } type tenantContext struct { ID string BypassScoping bool RequestedByOps bool } func (a *app) authMiddleware(next http.Handler) http.Handler { if a.cfg.AuthDisabled { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if a.oidcVerifier == nil { writeError(w, http.StatusServiceUnavailable, "oidc verifier not configured") return } authHeader := r.Header.Get("Authorization") if authHeader == "" { writeError(w, http.StatusUnauthorized, "missing authorization header") return } tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) if tokenString == "" { writeError(w, http.StatusUnauthorized, "invalid bearer token") return } idToken, err := a.oidcVerifier.Verify(r.Context(), tokenString) if err != nil { writeError(w, http.StatusUnauthorized, "invalid token") return } claims := &accessClaims{} if err := idToken.Claims(claims); err != nil { writeError(w, http.StatusUnauthorized, "invalid token claims") return } if expected := strings.TrimSpace(a.cfg.KeycloakAudience); expected != "" { if !contains(idToken.Audience, expected) && strings.TrimSpace(claims.AuthorizedParty) != expected { writeError(w, http.StatusUnauthorized, "token client is not authorized for this application") return } } tenantID := resolveClaimsTenantID(claims) isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") requestedTenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id")) if requestedTenantID != "" { if !isPlatformRole && tenantID != requestedTenantID { writeError(w, http.StatusForbidden, "tenant override requires platform role") return } tenantID = requestedTenantID } ctx := context.WithValue(r.Context(), userClaimsKey, claims) ctx = context.WithValue(ctx, tenantContextKey, tenantContext{ ID: tenantID, BypassScoping: isPlatformRole, RequestedByOps: requestedTenantID != "", }) next.ServeHTTP(w, r.WithContext(ctx)) }) } func withCORS(allowedOrigins []string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if isAllowedOrigin(origin, allowedOrigins) { w.Header().Set("Access-Control-Allow-Origin", origin) } else if contains(allowedOrigins, "*") { w.Header().Set("Access-Control-Allow-Origin", "*") } w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func isAllowedOrigin(origin string, allowed []string) bool { if origin == "" { return false } for _, entry := range allowed { if entry == "*" || entry == origin { return true } } return false } func contains(values []string, needle string) bool { for _, value := range values { if value == needle { return true } } return false } func hasRole(claims *accessClaims, role string) bool { for _, candidate := range claims.RealmAccess.Roles { if candidate == role { return true } } return false } func resolveClaimsTenantID(claims *accessClaims) string { if claims == nil { return "" } for _, candidate := range []string{ claims.TenantID, claims.TenantIDAlt, claims.OrganizationID, claims.OrganizationIDAlt, claims.OrganisationID, claims.OrganisationIDAlt, claims.OrgID, claims.OrgIDAlt, } { if trimmed := strings.TrimSpace(candidate); trimmed != "" { return trimmed } } return "" } func resolveClaimsUserID(claims *accessClaims) string { if claims == nil { return "" } for _, candidate := range []string{claims.Sub, claims.PreferredUsername, claims.Email} { if trimmed := strings.TrimSpace(candidate); trimmed != "" { return trimmed } } return "" }