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:
parent
d760e77e49
commit
5cba2f2cd4
4 changed files with 165 additions and 3 deletions
83
authentik.go
83
authentik.go
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
3
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue