10 tasks covering DevOps deployment (enable Authentik on de2, add to prod playbook, configure OIDC app) and go-api middleware (header extraction, JWT validation, RequireAuth/RequireGroup helpers). ~220 LOC, 16 new tests. Co-Authored-By: Virgil <virgil@lethean.io>
32 KiB
Authentik + Traefik Integration Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Deploy Authentik as the identity provider, wire it into Traefik's forward auth, and add OIDC/header middleware to go-api so protected services get authenticated user context.
Architecture: Authentik runs alongside existing services on de2 (production). Traefik's file provider loads a forwardAuth middleware definition pointing at Authentik's outpost. Services opt-in via Docker label middlewares: authentik@file. go-api gains a WithAuthentik() option that extracts user identity from Authentik headers (forward auth mode) or validates JWTs directly (API client mode).
Tech Stack: Authentik 2025.2, Traefik v3.6, Go 1.25, coreos/go-oidc/v3, golang.org/x/oauth2
Design doc: docs/plans/2026-02-20-go-api-design.md (Authentik section)
Key references:
- Traefik role:
/Users/snider/Code/DevOps/roles/traefik/ - Authentik role:
/Users/snider/Code/DevOps/roles/authentik/ - Forward auth template:
/Users/snider/Code/DevOps/roles/traefik/templates/dynamic-authentik.yml.j2 - go-api repo:
/Users/snider/Code/go-api/
Current State
The Ansible infrastructure is already built but not activated:
| Component | Status | Location |
|---|---|---|
| Traefik v3.6 role | Deployed on de2 | roles/traefik/ |
| Authentik 2025.2 role | Written, never deployed | roles/authentik/ |
| Forward auth middleware template | Written, conditional on traefik_authentik_enabled |
dynamic-authentik.yml.j2 |
| Outpost routing in Authentik compose | Pre-configured | roles/authentik/templates/docker-compose.yml.j2 |
5 services with authentik@file |
Labels present, middleware not yet available | prod_rebuild.yml |
| go-api Authentik middleware | Not started | — |
Headers Authentik will pass to go-api (via Traefik):
X-authentik-username, X-authentik-groups, X-authentik-entitlements,
X-authentik-email, X-authentik-name, X-authentik-uid, X-authentik-jwt,
X-authentik-meta-jwks, X-authentik-meta-outpost, X-authentik-meta-provider,
X-authentik-meta-app, X-authentik-meta-version
Task 1: Enable Authentik in Production Inventory
This task sets the Ansible variables to enable Authentik deployment on the production host.
Files:
- Modify:
/Users/snider/Code/DevOps/inventory/host_vars/de2.yml(or equivalent group_vars)
Step 1: Find the correct inventory file for de2
Run:
find /Users/snider/Code/DevOps/inventory -name "*.yml" -o -name "*.yaml" | head -20
ls /Users/snider/Code/DevOps/inventory/
Identify where de2's host vars live.
Step 2: Add Authentik variables
Add these variables for the de2 host:
# Authentik
traefik_authentik_enabled: true
traefik_authentik_url: "https://auth.host.uk.com"
authentik_host: "auth.host.uk.com"
authentik_bootstrap_password: "<generate with: openssl rand -hex 32>"
authentik_bootstrap_token: "<generate with: openssl rand -hex 32>"
authentik_bootstrap_email: "admin@host.uk.com"
Note: authentik_secret_key auto-generates and persists on first run. authentik_pg_password auto-generates via lookup. The Authentik role handles both.
Step 3: Verify prerequisites exist on de2
Authentik requires PostgreSQL + Dragonfly (Redis). Check they're in the prod playbook:
grep -n "postgres\|dragonfly" /Users/snider/Code/DevOps/playbooks/prod_rebuild.yml | head -10
Step 4: Commit
cd /Users/snider/Code/DevOps
git add inventory/
git commit -m "feat(authentik): enable Authentik and Traefik forward auth on de2
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 2: Add Authentik to Production Playbook
The Authentik Ansible role exists but is not included in the prod rebuild playbook. This task adds it.
Files:
- Modify:
/Users/snider/Code/DevOps/playbooks/prod_rebuild.yml
Step 1: Read the playbook to find the right insertion point
Authentik must deploy AFTER PostgreSQL + Dragonfly (it needs them) and AFTER Traefik (it needs the proxy network), but BEFORE services that use authentik@file.
grep -n "Phase\|traefik\|postgres\|dragonfly\|portainer\|glance" /Users/snider/Code/DevOps/playbooks/prod_rebuild.yml | head -20
Step 2: Add Authentik role include
Insert after the Traefik phase, before services:
# ── Phase N: Identity (Authentik) ──
- name: Deploy Authentik
ansible.builtin.include_role:
name: authentik
tags: [authentik]
Step 3: Verify the playbook parses
cd /Users/snider/Code/DevOps
ansible-playbook playbooks/prod_rebuild.yml --syntax-check
Expected: No errors.
Step 4: Commit
cd /Users/snider/Code/DevOps
git add playbooks/prod_rebuild.yml
git commit -m "feat(authentik): add Authentik phase to prod rebuild playbook
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 3: Deploy Authentik (Run Playbook)
This is a manual step — run the Ansible playbook to deploy Authentik on de2.
Step 1: Dry-run the Authentik tag only
cd /Users/snider/Code/DevOps
ansible-playbook playbooks/prod_rebuild.yml --tags authentik --check --diff
Review the output. Expect: directories created, docker-compose deployed, containers started.
Note: --check will skip shell/command tasks (like the PostgreSQL user creation). This is expected — the actual run will handle those.
Step 2: Deploy Authentik
ansible-playbook playbooks/prod_rebuild.yml --tags authentik
Step 3: Re-deploy Traefik to pick up the forward auth middleware
The Traefik role conditionally deploys dynamic-authentik.yml based on traefik_authentik_enabled. Re-running the role with the new variable will create the middleware file:
ansible-playbook playbooks/prod_rebuild.yml --tags traefik
Step 4: Verify Authentik is accessible
curl -sI https://auth.host.uk.com | head -5
Expected: HTTP 200 or 302 redirect to login page.
Step 5: Complete initial setup
Open https://auth.host.uk.com/if/flow/initial-setup/ in a browser. Set the admin password (the bootstrap password from Task 1 is used for the API token, but the UI setup flow creates the actual admin account).
Task 4: Create Authentik OIDC Application for go-api
This configures Authentik to issue tokens for go-api. Done via the Authentik admin UI or API.
Step 1: Create an OAuth2/OIDC Provider
In Authentik Admin → Providers → Create:
| Field | Value |
|---|---|
| Name | Core API |
| Protocol | OAuth2/OIDC |
| Client type | Confidential |
| Client ID | core-api |
| Redirect URIs | https://api.lthn.ai/auth/callback (for auth code flow) |
| Signing key | Select auto-generated signing key |
| Scopes | openid, email, profile |
| Subject mode | Based on user's hashed ID |
Record the Client Secret — needed for go-api config.
Step 2: Create an Application
In Authentik Admin → Applications → Create:
| Field | Value |
|---|---|
| Name | Core API |
| Slug | core-api |
| Provider | Core API (from step 1) |
| Launch URL | https://api.lthn.ai/ |
Step 3: Create a Forward Auth (Proxy) Provider for Traefik
In Authentik Admin → Providers → Create:
| Field | Value |
|---|---|
| Name | Traefik Forward Auth — Core API |
| Protocol | Proxy |
| Mode | Forward auth (single application) |
| External host | https://api.lthn.ai |
Step 4: Create an Outpost (if not exists)
In Authentik Admin → Outposts:
- If no outpost exists: Create → Type: Proxy, Integration: Local Docker
- Add both providers to the outpost
Step 5: Test forward auth is working
# This should redirect to Authentik login
curl -sI https://api.lthn.ai/
Once authenticated, Traefik passes the X-authentik-* headers through.
Task 5: go-api Authentik User Type (TDD)
Files:
- Create:
/Users/snider/Code/go-api/authentik.go - Create:
/Users/snider/Code/go-api/authentik_test.go
Step 1: Write the failing tests
Create authentik_test.go:
package api_test
import (
"testing"
api "forge.lthn.ai/core/go-api"
)
func TestAuthentikUser_Good(t *testing.T) {
user := &api.AuthentikUser{
Username: "alice",
Email: "alice@example.com",
Name: "Alice Smith",
UID: "abc-123",
Groups: []string{"admins", "developers"},
}
if user.Username != "alice" {
t.Fatalf("expected username alice, got %s", user.Username)
}
if len(user.Groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(user.Groups))
}
}
func TestAuthentikUserHasGroup_Good(t *testing.T) {
user := &api.AuthentikUser{
Groups: []string{"admins", "developers"},
}
if !user.HasGroup("admins") {
t.Fatal("expected user to have admins group")
}
if user.HasGroup("viewers") {
t.Fatal("expected user to not have viewers group")
}
}
func TestAuthentikUserHasGroup_Bad_Empty(t *testing.T) {
user := &api.AuthentikUser{}
if user.HasGroup("admins") {
t.Fatal("expected empty user to have no groups")
}
}
func TestAuthentikConfig_Good(t *testing.T) {
cfg := api.AuthentikConfig{
Issuer: "https://auth.host.uk.com/application/o/core-api/",
ClientID: "core-api",
TrustedProxy: true,
}
if cfg.Issuer == "" {
t.Fatal("expected non-empty issuer")
}
if !cfg.TrustedProxy {
t.Fatal("expected TrustedProxy to be true")
}
}
Step 2: Run tests to verify they fail
cd /Users/snider/Code/go-api
go test ./... -v -run TestAuthentik
Expected: Compilation errors — api.AuthentikUser, api.AuthentikConfig not defined.
Step 3: Implement authentik.go
Create authentik.go:
package api
// AuthentikConfig configures Authentik OIDC integration.
type AuthentikConfig struct {
// Issuer is the OIDC issuer URL (e.g. "https://auth.host.uk.com/application/o/core-api/").
// Used for JWT validation via OIDC discovery.
Issuer string
// ClientID is the OAuth2 client identifier registered in Authentik.
ClientID string
// TrustedProxy enables reading X-authentik-* headers set by Traefik forward auth.
// Only enable this when go-api sits behind a trusted reverse proxy.
TrustedProxy bool
// PublicPaths lists path prefixes that skip authentication entirely.
// /health and /swagger are always public regardless of this setting.
PublicPaths []string
}
// AuthentikUser represents an authenticated user extracted from Authentik headers or JWT claims.
type AuthentikUser struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
UID string `json:"uid"`
Groups []string `json:"groups"`
Entitlements []string `json:"entitlements,omitempty"`
JWT string `json:"-"`
}
// HasGroup returns true if 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
}
Step 4: Run tests to verify they pass
cd /Users/snider/Code/go-api
go test ./... -v -run TestAuthentik
Expected: All 4 tests PASS.
Step 5: Commit
cd /Users/snider/Code/go-api
git add authentik.go authentik_test.go
git commit -m "feat: add AuthentikUser and AuthentikConfig types
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 6: go-api Header Extraction Middleware (TDD)
This implements the forward auth path — extracting user identity from X-authentik-* headers set by Traefik.
Files:
- Modify:
/Users/snider/Code/go-api/authentik.go - Modify:
/Users/snider/Code/go-api/authentik_test.go
Step 1: Write the failing tests
Append to authentik_test.go:
import (
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/gin-gonic/gin"
)
// authentikTestGroup returns JSON with the user from context.
type authentikTestGroup struct{}
func (g *authentikTestGroup) Name() string { return "authtest" }
func (g *authentikTestGroup) BasePath() string { return "/v1/authtest" }
func (g *authentikTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/whoami", func(c *gin.Context) {
user := api.GetUser(c)
if user == nil {
c.JSON(200, api.OK[any](nil))
return
}
c.JSON(200, api.OK(user))
})
}
func TestForwardAuthHeaders_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
TrustedProxy: true,
}))
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
req.Header.Set("X-authentik-username", "alice")
req.Header.Set("X-authentik-email", "alice@example.com")
req.Header.Set("X-authentik-name", "Alice Smith")
req.Header.Set("X-authentik-uid", "abc-123")
req.Header.Set("X-authentik-groups", "admins|developers")
req.Header.Set("X-authentik-entitlements", "core:read|core:write")
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[*api.AuthentikUser]
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data == nil {
t.Fatal("expected non-nil user data")
}
if resp.Data.Username != "alice" {
t.Fatalf("expected username alice, got %s", resp.Data.Username)
}
if resp.Data.Email != "alice@example.com" {
t.Fatalf("expected email alice@example.com, got %s", resp.Data.Email)
}
if len(resp.Data.Groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(resp.Data.Groups))
}
if resp.Data.Groups[0] != "admins" {
t.Fatalf("expected first group admins, got %s", resp.Data.Groups[0])
}
}
func TestForwardAuthHeaders_Good_NoHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
TrustedProxy: true,
}))
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
// Request without Authentik headers — should pass through (middleware is permissive)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[*api.AuthentikUser]
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data != nil {
t.Fatal("expected nil user when no headers present")
}
}
func TestForwardAuthHeaders_Bad_NotTrusted(t *testing.T) {
gin.SetMode(gin.TestMode)
// TrustedProxy: false — should NOT read X-authentik-* headers
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
TrustedProxy: false,
}))
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
req.Header.Set("X-authentik-username", "alice")
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[*api.AuthentikUser]
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data != nil {
t.Fatal("expected nil user when TrustedProxy is false")
}
}
func TestHealthBypassesAuthentik_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
TrustedProxy: true,
}))
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 for /health, got %d", w.Code)
}
}
func TestGetUser_Good_NilContext(t *testing.T) {
gin.SetMode(gin.TestMode)
// Test GetUser with no user in context (no Authentik middleware)
engine, _ := api.New()
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
}
Step 2: Run tests to verify they fail
cd /Users/snider/Code/go-api
go test ./... -v -run TestForwardAuth\|TestHealthBypassesAuthentik\|TestGetUser
Expected: Compilation errors — api.WithAuthentik, api.GetUser not defined.
Step 3: Add GetUser helper and middleware to authentik.go
Append to authentik.go:
import (
"strings"
"github.com/gin-gonic/gin"
)
const authentikUserKey = "authentik_user"
// GetUser returns the authenticated Authentik user from the Gin context, or nil
// if no user is authenticated.
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
}
// authentikMiddleware extracts user identity from X-authentik-* headers
// (when TrustedProxy is true) and stores it in the Gin context.
// This middleware is PERMISSIVE — it does not reject unauthenticated requests.
// Handlers must check GetUser() and decide whether to require auth.
func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
publicPaths := append([]string{"/health", "/swagger"}, cfg.PublicPaths...)
return func(c *gin.Context) {
// Skip public paths entirely.
for _, path := range publicPaths {
if strings.HasPrefix(c.Request.URL.Path, path) {
c.Next()
return
}
}
// Forward auth mode: read trusted headers from Traefik.
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)
}
}
c.Next()
}
}
Step 4: Add WithAuthentik option to options.go
Append to options.go:
// WithAuthentik adds Authentik identity middleware.
// When TrustedProxy is true, reads X-authentik-* headers from Traefik forward auth.
// When Issuer is set, also validates JWT Bearer tokens via OIDC discovery.
func WithAuthentik(cfg AuthentikConfig) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authentikMiddleware(cfg))
}
}
Step 5: Run tests to verify they pass
cd /Users/snider/Code/go-api
go test ./... -v -count=1
Expected: All tests PASS (existing 36 + new 5).
Step 6: Commit
cd /Users/snider/Code/go-api
git add authentik.go authentik_test.go options.go
git commit -m "feat: add Authentik header extraction middleware and GetUser helper
Forward auth mode reads X-authentik-* headers from Traefik.
Middleware is permissive — handlers decide whether auth is required.
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 7: go-api JWT Validation Middleware (TDD)
This implements the direct OIDC path — validating JWT Bearer tokens for API clients.
Files:
- Modify:
/Users/snider/Code/go-api/authentik.go - Modify:
/Users/snider/Code/go-api/authentik_test.go - Modify:
/Users/snider/Code/go-api/go.mod(new dependency)
Step 1: Write the failing tests
Append to authentik_test.go:
func TestJWTValidation_Bad_InvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use a fake issuer — OIDC discovery will fail, but we test the flow
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com/application/o/test/",
ClientID: "test-client",
}))
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
req.Header.Set("Authorization", "Bearer invalid-jwt-token")
handler.ServeHTTP(w, req)
// Without a reachable OIDC endpoint, JWT validation can't succeed.
// The middleware should pass through (permissive) with no user.
if w.Code != 200 {
t.Fatalf("expected 200 (permissive), got %d", w.Code)
}
var resp api.Response[*api.AuthentikUser]
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data != nil {
t.Fatal("expected nil user for invalid JWT")
}
}
func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
// Both WithBearerAuth and WithAuthentik should work together.
// Bearer auth gates access, Authentik extracts user identity.
engine, _ := api.New(
api.WithBearerAuth("secret-token"),
api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}),
)
engine.Register(&authentikTestGroup{})
handler := engine.Handler()
// With bearer token + Authentik headers → 200 with user
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/authtest/whoami", nil)
req.Header.Set("Authorization", "Bearer secret-token")
req.Header.Set("X-authentik-username", "bob")
req.Header.Set("X-authentik-email", "bob@example.com")
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[*api.AuthentikUser]
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data == nil {
t.Fatal("expected user data")
}
if resp.Data.Username != "bob" {
t.Fatalf("expected username bob, got %s", resp.Data.Username)
}
}
Step 2: Run tests to verify they fail
cd /Users/snider/Code/go-api
go test ./... -v -run TestJWTValidation\|TestBearerAndAuthentikCoexist
Step 3: Add OIDC validation to authentik middleware
Update authentikMiddleware in authentik.go to handle JWT Bearer tokens when Issuer is configured. Add the go-oidc dependency:
cd /Users/snider/Code/go-api
go get github.com/coreos/go-oidc/v3/oidc
go get golang.org/x/oauth2
Add JWT validation logic to the middleware — after the header extraction block, before c.Next():
// Direct OIDC mode: validate JWT from Authorization header.
if cfg.Issuer != "" && cfg.ClientID != "" {
// Only attempt JWT validation if no user was extracted from headers
// (headers take priority — they're pre-validated by Authentik).
if GetUser(c) == nil {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
user, err := validateJWT(c.Request.Context(), cfg, token)
if err == nil && user != nil {
c.Set(authentikUserKey, user)
}
// Permissive: if validation fails, continue without user.
}
}
}
Add the validation function:
import (
"context"
"sync"
oidc "github.com/coreos/go-oidc/v3/oidc"
)
var (
oidcProviderMu sync.Mutex
oidcProviders = make(map[string]*oidc.Provider)
)
// getOIDCProvider returns a cached OIDC provider for the given issuer.
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 JWT token against the OIDC provider and extracts the user.
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
}
Step 4: Run go mod tidy
cd /Users/snider/Code/go-api
go mod tidy
Step 5: Run tests to verify they pass
cd /Users/snider/Code/go-api
go test ./... -v -count=1
Expected: All tests PASS.
Step 6: Commit
cd /Users/snider/Code/go-api
git add authentik.go authentik_test.go go.mod go.sum
git commit -m "feat: add OIDC JWT validation for direct API client auth
Uses coreos/go-oidc for OIDC discovery and JWT verification.
Cached provider instances. Permissive — fails open if OIDC unreachable.
Forward auth headers take priority over JWT when both present.
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 8: go-api RequireAuth Middleware Helper (TDD)
The Authentik middleware is permissive. This task adds a helper for routes that REQUIRE authentication.
Files:
- Modify:
/Users/snider/Code/go-api/authentik.go - Modify:
/Users/snider/Code/go-api/authentik_test.go
Step 1: Write the failing tests
Append to authentik_test.go:
// protectedGroup uses RequireAuth on its routes.
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))
})
}
func TestRequireAuth_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
engine.Register(&protectedGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/protected/data", nil)
req.Header.Set("X-authentik-username", "alice")
req.Header.Set("X-authentik-email", "alice@example.com")
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 with auth, got %d", w.Code)
}
}
func TestRequireAuth_Bad_NoUser(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
engine.Register(&protectedGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/protected/data", nil)
handler.ServeHTTP(w, req)
if w.Code != 401 {
t.Fatalf("expected 401 without auth, got %d", w.Code)
}
}
func TestRequireAuth_Bad_NoAuthentikMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
// No WithAuthentik — RequireAuth should still return 401
engine, _ := api.New()
engine.Register(&protectedGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/protected/data", nil)
handler.ServeHTTP(w, req)
if w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
}
// groupRequireGroup uses 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"))
})
}
func TestRequireGroup_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
engine.Register(&groupRequireGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "alice")
req.Header.Set("X-authentik-groups", "admins|developers")
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 for admin user, got %d", w.Code)
}
}
func TestRequireGroup_Bad_WrongGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
engine.Register(&groupRequireGroup{})
handler := engine.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "bob")
req.Header.Set("X-authentik-groups", "developers")
handler.ServeHTTP(w, req)
if w.Code != 403 {
t.Fatalf("expected 403 for non-admin user, got %d", w.Code)
}
}
Step 2: Run tests to verify they fail
cd /Users/snider/Code/go-api
go test ./... -v -run TestRequireAuth\|TestRequireGroup
Expected: Compilation errors — api.RequireAuth, api.RequireGroup not defined.
Step 3: Implement RequireAuth and RequireGroup
Append to authentik.go:
import "net/http"
// RequireAuth is a Gin middleware that returns 401 if no authenticated user
// is present in the context. Use after WithAuthentik() middleware.
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 a Gin middleware that returns 403 if the authenticated user
// does not belong to the specified group. Implies RequireAuth.
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()
}
}
Step 4: Run tests to verify they pass
cd /Users/snider/Code/go-api
go test ./... -v -count=1
Expected: All tests PASS.
Step 5: Commit
cd /Users/snider/Code/go-api
git add authentik.go authentik_test.go
git commit -m "feat: add RequireAuth and RequireGroup middleware helpers
RequireAuth returns 401 when no user in context.
RequireGroup returns 403 when user lacks the specified group.
Both use British English 'unauthorised' in error responses.
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 9: Update go-api Documentation
Files:
- Modify:
/Users/snider/Code/go-api/CLAUDE.md - Modify:
/Users/snider/Code/go-api/README.md
Step 1: Update CLAUDE.md
Add to the Project Overview section:
## Authentik Integration
go-api supports Authentik as the identity provider:
- **Forward auth mode**: Reads `X-authentik-*` headers from Traefik (requires `TrustedProxy: true`)
- **OIDC mode**: Validates JWT Bearer tokens via OIDC discovery
- **Permissive middleware**: `WithAuthentik()` extracts user but doesn't block. Use `RequireAuth()` / `RequireGroup()` on routes that need auth.
- **Coexists with `WithBearerAuth()`** for service-to-service tokens
```go
engine, _ := api.New(
api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.host.uk.com/application/o/core-api/",
ClientID: "core-api",
TrustedProxy: true,
}),
)
**Step 2: Update README.md**
Add Authentik section with quick-start example showing `WithAuthentik()`, `GetUser()`, `RequireAuth()`, and `RequireGroup()`.
**Step 3: Commit**
```bash
cd /Users/snider/Code/go-api
git add CLAUDE.md README.md
git commit -m "docs: add Authentik integration guide to CLAUDE.md and README
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 10: Push go-api and DevOps Changes
Step 1: Push go-api
cd /Users/snider/Code/go-api
go test ./... -v -count=1 # Final verification
git push forge main
Step 2: Push DevOps
cd /Users/snider/Code/DevOps
git push forge main
Step 3: Update go-ecosystem memory
Update the go-api entry in the ecosystem inventory to note Authentik middleware.
Dependency Summary
Task 1 (enable vars) → Task 2 (playbook) → Task 3 (deploy) → Task 4 (OIDC app)
↓
Task 5 (user type) → Task 6 (header middleware) → Task 7 (JWT) → Task 8 (RequireAuth)
↓
Task 9 (docs) → Task 10 (push)
Tasks 1-4 (DevOps) and Tasks 5-8 (Go) are independent tracks that can run in parallel. Task 9-10 depend on both tracks.
Estimated Sizes
| Task | LOC | Tests |
|---|---|---|
| Task 5: User type | ~50 | 4 |
| Task 6: Header middleware | ~60 | 5 |
| Task 7: JWT validation | ~80 | 2 |
| Task 8: RequireAuth/Group | ~30 | 5 |
| go-api total | ~220 | 16 |