go-api/authentik_test.go
Snider d279343491 feat(authentik): add RequireAuth and RequireGroup middleware
Add two route-level middleware helpers for enforcing authentication and
group membership. RequireAuth returns 401 when no user is in context.
RequireGroup returns 401 for unauthenticated requests and 403 when the
user lacks the specified group. Both use UK English error codes
("unauthorised", "forbidden") consistent with existing bearer auth.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 16:43:55 +00:00

460 lines
14 KiB
Go

// 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"))
})
}