199 lines
5.1 KiB
Go
199 lines
5.1 KiB
Go
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 ""
|
|
}
|