// SPDX-License-Identifier: EUPL-1.2 package api import ( "context" "net/http" "strings" "sync" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" ) // AuthentikConfig holds settings for the Authentik forward-auth integration. type AuthentikConfig struct { // Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/). Issuer string // ClientID is the OAuth2 client identifier. ClientID string // TrustedProxy enables reading X-authentik-* headers set by a reverse proxy. // When false, headers are ignored to prevent spoofing from untrusted sources. TrustedProxy bool // PublicPaths lists additional paths that do not require authentication. // /health and /swagger are always public. PublicPaths []string } // AuthentikUser represents an authenticated user extracted from Authentik // forward-auth headers or a validated JWT. type AuthentikUser struct { Username string `json:"username"` Email string `json:"email"` Name string `json:"name"` UID string `json:"uid"` Groups []string `json:"groups,omitempty"` Entitlements []string `json:"entitlements,omitempty"` JWT string `json:"-"` } // HasGroup reports whether the user belongs to the named group. func (u *AuthentikUser) HasGroup(group string) bool { for _, g := range u.Groups { if g == group { return true } } return false } // authentikUserKey is the Gin context key used to store the authenticated user. const authentikUserKey = "authentik_user" // GetUser retrieves the AuthentikUser from the Gin context. // Returns nil when no user has been set (unauthenticated request or // middleware not active). func GetUser(c *gin.Context) *AuthentikUser { val, exists := c.Get(authentikUserKey) if !exists { return nil } user, ok := val.(*AuthentikUser) if !ok { return nil } return user } // oidcProviderMu guards the provider cache. var oidcProviderMu sync.Mutex // oidcProviders caches OIDC providers by issuer URL to avoid repeated // discovery requests. var oidcProviders = make(map[string]*oidc.Provider) // getOIDCProvider returns a cached OIDC provider for the given issuer, // performing discovery on first access. func getOIDCProvider(ctx context.Context, issuer string) (*oidc.Provider, error) { oidcProviderMu.Lock() defer oidcProviderMu.Unlock() if p, ok := oidcProviders[issuer]; ok { return p, nil } p, err := oidc.NewProvider(ctx, issuer) if err != nil { return nil, err } oidcProviders[issuer] = p return p, nil } // validateJWT verifies a raw JWT against the configured OIDC issuer and // extracts user claims on success. func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*AuthentikUser, error) { provider, err := getOIDCProvider(ctx, cfg.Issuer) if err != nil { return nil, err } verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) idToken, err := verifier.Verify(ctx, rawToken) if err != nil { return nil, err } var claims struct { PreferredUsername string `json:"preferred_username"` Email string `json:"email"` Name string `json:"name"` Sub string `json:"sub"` Groups []string `json:"groups"` } if err := idToken.Claims(&claims); err != nil { return nil, err } return &AuthentikUser{ Username: claims.PreferredUsername, Email: claims.Email, Name: claims.Name, UID: claims.Sub, Groups: claims.Groups, JWT: rawToken, }, nil } // authentikMiddleware returns Gin middleware that extracts user identity from // X-authentik-* headers set by a trusted reverse proxy (e.g. Traefik with // Authentik forward-auth) or from a JWT in the Authorization header. // // The middleware is PERMISSIVE: it populates the context when credentials are // present but never rejects unauthenticated requests. Downstream handlers // use GetUser to check authentication. func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc { // Build the set of public paths that skip header extraction entirely. public := map[string]bool{ "/health": true, "/swagger": true, } for _, p := range cfg.PublicPaths { public[p] = true } return func(c *gin.Context) { // Skip public paths. path := c.Request.URL.Path for p := range public { if strings.HasPrefix(path, p) { c.Next() return } } // Block 1: Extract user from X-authentik-* forward-auth headers. if cfg.TrustedProxy { username := c.GetHeader("X-authentik-username") if username != "" { user := &AuthentikUser{ Username: username, Email: c.GetHeader("X-authentik-email"), Name: c.GetHeader("X-authentik-name"), UID: c.GetHeader("X-authentik-uid"), JWT: c.GetHeader("X-authentik-jwt"), } if groups := c.GetHeader("X-authentik-groups"); groups != "" { user.Groups = strings.Split(groups, "|") } if ent := c.GetHeader("X-authentik-entitlements"); ent != "" { user.Entitlements = strings.Split(ent, "|") } c.Set(authentikUserKey, user) } } // Block 2: Attempt JWT validation for direct API clients. // Only when OIDC is configured and no user was extracted from headers. if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil { if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { rawToken := strings.TrimPrefix(auth, "Bearer ") if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil { c.Set(authentikUserKey, user) } // On failure: continue without user (fail open / permissive). } } c.Next() } } // RequireAuth is Gin middleware that rejects unauthenticated requests. // It checks for a user set by the Authentik middleware and returns 401 // when none is present. func RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if GetUser(c) == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "Authentication required")) return } c.Next() } } // RequireGroup is Gin middleware that rejects requests from users who do // not belong to the specified group. Returns 401 when no user is present // and 403 when the user lacks the required group membership. func RequireGroup(group string) gin.HandlerFunc { return func(c *gin.Context) { user := GetUser(c) if user == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "Authentication required")) return } if !user.HasGroup(group) { c.AbortWithStatusJSON(http.StatusForbidden, Fail("forbidden", "Insufficient permissions")) return } c.Next() } }