diff --git a/authentik.go b/authentik.go index c81ff97..b80b434 100644 --- a/authentik.go +++ b/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() } } diff --git a/authentik_test.go b/authentik_test.go index dba810d..2c10b04 100644 --- a/authentik_test.go +++ b/authentik_test.go @@ -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. diff --git a/go.mod b/go.mod index 1463a99..3e2738d 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 9307f0b..291c9da 100644 --- a/go.sum +++ b/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=