go-api/authentik_test.go

461 lines
14 KiB
Go
Raw Permalink Normal View History

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"strings"
"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)
}
}
// ── JWT validation ────────────────────────────────────────────────────
func TestJWTValidation_Bad_InvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use a fake issuer that won't resolve — JWT validation should fail open.
cfg := api.AuthentikConfig{
Issuer: "https://fake-issuer.invalid",
ClientID: "test-client",
}
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("Authorization", "Bearer invalid-jwt-token")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (permissive), got %d", w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil for invalid JWT, got %+v", gotUser)
}
}
func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
// Engine with BOTH bearer auth AND authentik middleware.
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(
api.WithBearerAuth("secret-token"),
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("Authorization", "Bearer secret-token")
req.Header.Set("X-authentik-username", "carol")
req.Header.Set("X-authentik-email", "carol@example.com")
req.Header.Set("X-authentik-name", "Carol White")
req.Header.Set("X-authentik-uid", "uid-789")
req.Header.Set("X-authentik-groups", "developers|admins")
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 != "carol" {
t.Fatalf("expected Username=%q, got %q", "carol", gotUser.Username)
}
if gotUser.Email != "carol@example.com" {
t.Fatalf("expected Email=%q, got %q", "carol@example.com", gotUser.Email)
}
if len(gotUser.Groups) != 2 || gotUser.Groups[0] != "developers" || gotUser.Groups[1] != "admins" {
t.Fatalf("expected groups [developers admins], got %v", gotUser.Groups)
}
}
// ── RequireAuth / RequireGroup ────────────────────────────────────────
func TestRequireAuth_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
req.Header.Set("X-authentik-username", "alice")
req.Header.Set("X-authentik-email", "alice@example.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireAuth_Bad_NoUser(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"unauthorised"`) {
t.Fatalf("expected error code 'unauthorised' in body, got %s", body)
}
}
func TestRequireAuth_Bad_NoAuthentikMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
// Engine without WithAuthentik — RequireAuth should still reject.
e, _ := api.New()
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireGroup_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&groupRequireGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "admin-user")
req.Header.Set("X-authentik-groups", "admins|staff")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireGroup_Bad_WrongGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&groupRequireGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "dev-user")
req.Header.Set("X-authentik-groups", "developers")
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"forbidden"`) {
t.Fatalf("expected error code 'forbidden' in body, got %s", body)
}
}
// ── 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)
}
// protectedGroup provides a /v1/protected/data endpoint guarded by RequireAuth.
type protectedGroup struct{}
func (g *protectedGroup) Name() string { return "protected" }
func (g *protectedGroup) BasePath() string { return "/v1/protected" }
func (g *protectedGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/data", api.RequireAuth(), func(c *gin.Context) {
user := api.GetUser(c)
c.JSON(200, api.OK(user.Username))
})
}
// groupRequireGroup provides a /v1/admin/panel endpoint guarded by RequireGroup.
type groupRequireGroup struct{}
func (g *groupRequireGroup) Name() string { return "adminonly" }
func (g *groupRequireGroup) BasePath() string { return "/v1/admin" }
func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/panel", api.RequireGroup("admins"), func(c *gin.Context) {
c.JSON(200, api.OK("admin panel"))
})
}