feat(authentik): add OIDC JWT validation middleware

Add JWT validation as a second authentication block in the Authentik
middleware. Direct API clients can now send Authorization: Bearer <jwt>
tokens validated via OIDC discovery (coreos/go-oidc). Forward-auth
headers take priority; JWT is only attempted when no user was extracted
from headers. Validation is permissive — failures continue without a
user context. OIDC providers are cached per issuer to avoid repeated
discovery.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 16:42:23 +00:00
parent d760e77e49
commit 5cba2f2cd4
4 changed files with 165 additions and 3 deletions

View file

@ -3,8 +3,11 @@
package api
import (
"context"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
)
@ -65,11 +68,73 @@ func GetUser(c *gin.Context) *AuthentikUser {
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).
// Authentik forward-auth) or from a JWT in the Authorization header.
//
// The middleware is PERMISSIVE: it populates the context when headers are
// 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 {
@ -92,7 +157,7 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
}
}
// Only read headers when the proxy is trusted.
// Block 1: Extract user from X-authentik-* forward-auth headers.
if cfg.TrustedProxy {
username := c.GetHeader("X-authentik-username")
if username != "" {
@ -115,6 +180,18 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
}
}
// 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()
}
}

View file

@ -245,6 +245,82 @@ func TestGetUser_Good_NilContext(t *testing.T) {
}
}
// ── 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)
}
}
// ── Test helpers ───────────────────────────────────────────────────────
// authTestGroup provides a /v1/check endpoint that calls a custom handler.

3
go.mod
View file

@ -3,6 +3,7 @@ module forge.lthn.ai/core/go-api
go 1.25.5
require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
@ -20,6 +21,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
@ -47,6 +49,7 @@ require (
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect

6
go.sum
View file

@ -10,6 +10,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -24,6 +26,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@ -127,6 +131,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=