Add permissive forward-auth middleware that extracts user identity from X-authentik-* headers when TrustedProxy is enabled. Headers are ignored when TrustedProxy is false to prevent spoofing from untrusted sources. - GetUser(c) helper retrieves AuthentikUser from Gin context - authentikMiddleware splits groups/entitlements on pipe delimiter - /health and /swagger bypass header extraction - WithAuthentik option wires middleware into the Engine Co-Authored-By: Virgil <virgil@lethean.io>
259 lines
7.6 KiB
Go
259 lines
7.6 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
api "forge.lthn.ai/core/go-api"
|
|
)
|
|
|
|
// ── AuthentikUser ──────────────────────────────────────────────────────
|
|
|
|
func TestAuthentikUser_Good(t *testing.T) {
|
|
u := api.AuthentikUser{
|
|
Username: "alice",
|
|
Email: "alice@example.com",
|
|
Name: "Alice Smith",
|
|
UID: "abc-123",
|
|
Groups: []string{"editors", "admins"},
|
|
Entitlements: []string{"premium"},
|
|
JWT: "tok.en.here",
|
|
}
|
|
|
|
if u.Username != "alice" {
|
|
t.Fatalf("expected Username=%q, got %q", "alice", u.Username)
|
|
}
|
|
if u.Email != "alice@example.com" {
|
|
t.Fatalf("expected Email=%q, got %q", "alice@example.com", u.Email)
|
|
}
|
|
if u.Name != "Alice Smith" {
|
|
t.Fatalf("expected Name=%q, got %q", "Alice Smith", u.Name)
|
|
}
|
|
if u.UID != "abc-123" {
|
|
t.Fatalf("expected UID=%q, got %q", "abc-123", u.UID)
|
|
}
|
|
if len(u.Groups) != 2 || u.Groups[0] != "editors" {
|
|
t.Fatalf("expected Groups=[editors admins], got %v", u.Groups)
|
|
}
|
|
if len(u.Entitlements) != 1 || u.Entitlements[0] != "premium" {
|
|
t.Fatalf("expected Entitlements=[premium], got %v", u.Entitlements)
|
|
}
|
|
if u.JWT != "tok.en.here" {
|
|
t.Fatalf("expected JWT=%q, got %q", "tok.en.here", u.JWT)
|
|
}
|
|
}
|
|
|
|
func TestAuthentikUserHasGroup_Good(t *testing.T) {
|
|
u := api.AuthentikUser{
|
|
Groups: []string{"editors", "admins"},
|
|
}
|
|
|
|
if !u.HasGroup("admins") {
|
|
t.Fatal("expected HasGroup(admins) = true")
|
|
}
|
|
if !u.HasGroup("editors") {
|
|
t.Fatal("expected HasGroup(editors) = true")
|
|
}
|
|
}
|
|
|
|
func TestAuthentikUserHasGroup_Bad_Empty(t *testing.T) {
|
|
u := api.AuthentikUser{}
|
|
|
|
if u.HasGroup("admins") {
|
|
t.Fatal("expected HasGroup(admins) = false for empty user")
|
|
}
|
|
}
|
|
|
|
func TestAuthentikConfig_Good(t *testing.T) {
|
|
cfg := api.AuthentikConfig{
|
|
Issuer: "https://auth.example.com",
|
|
ClientID: "my-client",
|
|
TrustedProxy: true,
|
|
PublicPaths: []string{"/public", "/docs"},
|
|
}
|
|
|
|
if cfg.Issuer != "https://auth.example.com" {
|
|
t.Fatalf("expected Issuer=%q, got %q", "https://auth.example.com", cfg.Issuer)
|
|
}
|
|
if cfg.ClientID != "my-client" {
|
|
t.Fatalf("expected ClientID=%q, got %q", "my-client", cfg.ClientID)
|
|
}
|
|
if !cfg.TrustedProxy {
|
|
t.Fatal("expected TrustedProxy=true")
|
|
}
|
|
if len(cfg.PublicPaths) != 2 {
|
|
t.Fatalf("expected 2 public paths, got %d", len(cfg.PublicPaths))
|
|
}
|
|
}
|
|
|
|
// ── Forward auth middleware ────────────────────────────────────────────
|
|
|
|
func TestForwardAuthHeaders_Good(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := api.AuthentikConfig{TrustedProxy: true}
|
|
e, _ := api.New(api.WithAuthentik(cfg))
|
|
|
|
var gotUser *api.AuthentikUser
|
|
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
|
|
gotUser = api.GetUser(c)
|
|
c.JSON(http.StatusOK, api.OK("ok"))
|
|
}})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
|
|
req.Header.Set("X-authentik-username", "bob")
|
|
req.Header.Set("X-authentik-email", "bob@example.com")
|
|
req.Header.Set("X-authentik-name", "Bob Jones")
|
|
req.Header.Set("X-authentik-uid", "uid-456")
|
|
req.Header.Set("X-authentik-jwt", "jwt.tok.en")
|
|
req.Header.Set("X-authentik-groups", "staff|admins|ops")
|
|
req.Header.Set("X-authentik-entitlements", "read|write")
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotUser == nil {
|
|
t.Fatal("expected GetUser to return a user, got nil")
|
|
}
|
|
if gotUser.Username != "bob" {
|
|
t.Fatalf("expected Username=%q, got %q", "bob", gotUser.Username)
|
|
}
|
|
if gotUser.Email != "bob@example.com" {
|
|
t.Fatalf("expected Email=%q, got %q", "bob@example.com", gotUser.Email)
|
|
}
|
|
if gotUser.Name != "Bob Jones" {
|
|
t.Fatalf("expected Name=%q, got %q", "Bob Jones", gotUser.Name)
|
|
}
|
|
if gotUser.UID != "uid-456" {
|
|
t.Fatalf("expected UID=%q, got %q", "uid-456", gotUser.UID)
|
|
}
|
|
if gotUser.JWT != "jwt.tok.en" {
|
|
t.Fatalf("expected JWT=%q, got %q", "jwt.tok.en", gotUser.JWT)
|
|
}
|
|
if len(gotUser.Groups) != 3 {
|
|
t.Fatalf("expected 3 groups, got %d: %v", len(gotUser.Groups), gotUser.Groups)
|
|
}
|
|
if gotUser.Groups[0] != "staff" || gotUser.Groups[1] != "admins" || gotUser.Groups[2] != "ops" {
|
|
t.Fatalf("expected groups [staff admins ops], got %v", gotUser.Groups)
|
|
}
|
|
if len(gotUser.Entitlements) != 2 {
|
|
t.Fatalf("expected 2 entitlements, got %d: %v", len(gotUser.Entitlements), gotUser.Entitlements)
|
|
}
|
|
if gotUser.Entitlements[0] != "read" || gotUser.Entitlements[1] != "write" {
|
|
t.Fatalf("expected entitlements [read write], got %v", gotUser.Entitlements)
|
|
}
|
|
}
|
|
|
|
func TestForwardAuthHeaders_Good_NoHeaders(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := api.AuthentikConfig{TrustedProxy: true}
|
|
e, _ := api.New(api.WithAuthentik(cfg))
|
|
|
|
var gotUser *api.AuthentikUser
|
|
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
|
|
gotUser = api.GetUser(c)
|
|
c.JSON(http.StatusOK, api.OK("ok"))
|
|
}})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotUser != nil {
|
|
t.Fatalf("expected GetUser to return nil without headers, got %+v", gotUser)
|
|
}
|
|
}
|
|
|
|
func TestForwardAuthHeaders_Bad_NotTrusted(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := api.AuthentikConfig{TrustedProxy: false}
|
|
e, _ := api.New(api.WithAuthentik(cfg))
|
|
|
|
var gotUser *api.AuthentikUser
|
|
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
|
|
gotUser = api.GetUser(c)
|
|
c.JSON(http.StatusOK, api.OK("ok"))
|
|
}})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
|
|
req.Header.Set("X-authentik-username", "mallory")
|
|
req.Header.Set("X-authentik-email", "mallory@evil.com")
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotUser != nil {
|
|
t.Fatalf("expected GetUser to return nil when TrustedProxy=false, got %+v", gotUser)
|
|
}
|
|
}
|
|
|
|
func TestHealthBypassesAuthentik_Good(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := api.AuthentikConfig{TrustedProxy: true}
|
|
e, _ := api.New(api.WithAuthentik(cfg))
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for /health, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetUser_Good_NilContext(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Engine without WithAuthentik — GetUser should return nil.
|
|
e, _ := api.New()
|
|
|
|
var gotUser *api.AuthentikUser
|
|
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
|
|
gotUser = api.GetUser(c)
|
|
c.JSON(http.StatusOK, api.OK("ok"))
|
|
}})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotUser != nil {
|
|
t.Fatalf("expected GetUser to return nil without middleware, got %+v", gotUser)
|
|
}
|
|
}
|
|
|
|
// ── Test helpers ───────────────────────────────────────────────────────
|
|
|
|
// authTestGroup provides a /v1/check endpoint that calls a custom handler.
|
|
type authTestGroup struct {
|
|
onRequest func(c *gin.Context)
|
|
}
|
|
|
|
func (a *authTestGroup) Name() string { return "auth-test" }
|
|
func (a *authTestGroup) BasePath() string { return "/v1" }
|
|
func (a *authTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
rg.GET("/check", a.onRequest)
|
|
}
|