From c0f487e333a78b5b5f3617c0d2978f900b0cc3f2 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 14 Aug 2026 17:04:20 +0200 Subject: [PATCH] feat(api): add OIDC bearer token authentication --- Makefile | 4 ++ go.mod | 8 ++- go.sum | 6 ++ pkg/modules/api/api.go | 49 +++++++++++++- pkg/modules/api/api_test.go | 67 +++++++++++++++++++ pkg/modules/api/middlewares.go | 59 +++++++++++++++++ pkg/modules/api/middlewares_test.go | 91 ++++++++++++++++++++++++++ test/integration/features/root.feature | 11 ++++ 8 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 pkg/modules/api/api_test.go diff --git a/Makefile b/Makefile index 7e0c6c4d..8236d4e1 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ API_CORRELATION_ID_HEADER=Gotenberg-Trace API_ENABLE_BASIC_AUTH=false GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_PASSWORD= +API_ENABLE_OIDC_AUTH=false +API_OIDC_ISSUER= +API_OIDC_AUDIENCE= +API_OIDC_JWKS_URL= API_DOWNLOAD_FROM_ALLOW_LIST= API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd) API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false diff --git a/go.mod b/go.mod index 3f7dca5e..9804fd7c 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,11 @@ require ( golang.org/x/text v0.41.0 ) +require ( + github.com/coreos/go-oidc/v3 v3.20.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 +) + require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -66,6 +71,7 @@ require ( github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/ebitengine/purego v0.10.2 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -118,7 +124,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect @@ -133,6 +138,7 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/crypto v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect diff --git a/go.sum b/go.sum index d36deb7a..3cf9d016 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -72,6 +74,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -305,6 +309,8 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index 0cd1e904..e9cd4048 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -39,6 +39,10 @@ type Api struct { correlationIdHeader string basicAuthUsername string basicAuthPassword string + oidcEnabled bool + oidcIssuer string + oidcAudience string + oidcJwksUrl string downloadFromCfg downloadFromConfig disableHealthCheckRouteTelemetry bool disableRootRouteTelemetry bool @@ -198,6 +202,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") + fs.Bool("api-enable-oidc-auth", false, "Enable OIDC bearer token authentication - mutually exclusive with basic authentication") + fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match") + fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it") + fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty") fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values") fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services") @@ -280,6 +288,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.basicAuthPassword = basicAuthPassword } + // Enable OIDC auth? The flags are populated from their API_OIDC_* env vars + // by the CLI, so no manual environment lookup is needed here. + a.oidcEnabled = flags.MustBool("api-enable-oidc-auth") + if a.oidcEnabled { + a.oidcIssuer = flags.MustString("api-oidc-issuer") + a.oidcAudience = flags.MustString("api-oidc-audience") + a.oidcJwksUrl = flags.MustString("api-oidc-jwks-url") + } + // Get routes from modules. mods, err := ctx.Modules(new(Router)) if err != nil { @@ -411,6 +428,25 @@ func (a *Api) Validate() error { ) } + if a.basicAuthUsername != "" && a.oidcEnabled { + err = errors.Join(err, + errors.New("basic authentication and OIDC authentication cannot both be enabled"), + ) + } + + if a.oidcEnabled { + if a.oidcIssuer == "" { + err = errors.Join(err, + errors.New("OIDC issuer must not be empty when OIDC auth is enabled; set --api-oidc-issuer"), + ) + } + if a.oidcAudience == "" { + err = errors.Join(err, + errors.New("OIDC audience must not be empty when OIDC auth is enabled; set --api-oidc-audience"), + ) + } + } + if err != nil { return err } @@ -517,11 +553,18 @@ func (a *Api) Start() error { hardTimeout := a.timeout + (time.Duration(5) * time.Second) - // Basic auth? + // Authentication? var securityMiddleware echo.MiddlewareFunc - if a.basicAuthUsername != "" { + switch { + case a.basicAuthUsername != "": securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword) - } else { + case a.oidcEnabled: + verifier, err := a.buildOidcVerifier() + if err != nil { + return fmt.Errorf("build OIDC verifier: %w", err) + } + securityMiddleware = oidcAuthMiddleware(verifier) + default: securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { return next(c) diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go new file mode 100644 index 00000000..d15e9269 --- /dev/null +++ b/pkg/modules/api/api_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "strings" + "testing" +) + +func TestApi_Validate_Auth(t *testing.T) { + base := func() *Api { + return &Api{port: 3000, rootPath: "/", correlationIdHeader: "Gotenberg-Trace"} + } + + for _, tc := range []struct { + scenario string + mutate func(*Api) + wantErr string // substring expected in the error, "" means no error + }{ + {"no auth", func(*Api) {}, ""}, + {"basic auth only", func(a *Api) { a.basicAuthUsername = "foo" }, ""}, + { + "oidc auth valid", + func(a *Api) { + a.oidcEnabled = true + a.oidcIssuer = "https://tenant.example.com/" + a.oidcAudience = "gotenberg" + }, + "", + }, + { + "basic and oidc are mutually exclusive", + func(a *Api) { + a.basicAuthUsername = "foo" + a.oidcEnabled = true + a.oidcIssuer = "https://tenant.example.com/" + a.oidcAudience = "gotenberg" + }, + "cannot both be enabled", + }, + { + "oidc missing issuer", + func(a *Api) { a.oidcEnabled = true; a.oidcAudience = "gotenberg" }, + "issuer must not be empty", + }, + { + "oidc missing audience", + func(a *Api) { a.oidcEnabled = true; a.oidcIssuer = "https://tenant.example.com/" }, + "audience must not be empty", + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := base() + tc.mutate(a) + + err := a.Validate() + + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want a substring %q", err, tc.wantErr) + } + }) + } +} diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index ea3d64ec..553b8368 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -10,9 +10,11 @@ import ( "strings" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" @@ -368,6 +370,63 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc { }) } +// buildOidcVerifier constructs an OIDC ID token verifier. When oidcJwksUrl is +// set, the keys are fetched from that URL lazily, so there is no network call at +// startup; otherwise the provider is discovered from its issuer, which does one. +// Both paths use an OTEL-instrumented HTTP client, so the JWKS and discovery +// fetches produce client spans. +func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) { + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: otelhttp.NewTransport(http.DefaultTransport), + } + ctx := oidc.ClientContext(context.Background(), httpClient) + + cfg := &oidc.Config{ + ClientID: a.oidcAudience, + SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256}, + } + + if a.oidcJwksUrl != "" { + keySet := oidc.NewRemoteKeySet(ctx, a.oidcJwksUrl) + return oidc.NewVerifier(a.oidcIssuer, keySet, cfg), nil + } + + provider, err := oidc.NewProvider(ctx, a.oidcIssuer) + if err != nil { + return nil, fmt.Errorf("discover OIDC provider '%s': %w", a.oidcIssuer, err) + } + + return provider.Verifier(cfg), nil +} + +// oidcAuthMiddleware validates the Bearer token in the Authorization header with +// the OIDC verifier, which checks the signature against the provider's rotating +// JWKS and the issuer, audience and expiry claims. It answers 401 for a missing +// or invalid token, logging the underlying reason at debug level without leaking +// it to the client. +func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ") + if !ok || rawToken == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header") + } + + _, err := verifier.Verify(c.Request().Context(), rawToken) + if err != nil { + if logger, ok := c.Get("logger").(*slog.Logger); ok && logger != nil { + logger.DebugContext(c.Request().Context(), "OIDC token verification failed", slog.Any("error", err)) + } + + return echo.NewHTTPError(http.StatusUnauthorized, "the Bearer token is invalid") + } + + return next(c) + } + } +} + // contextMiddleware, middleware for "multipart/form-data" requests, sets the // [Context] and related context.CancelFunc in the [echo.Context] under // "context" and "cancel". If the process is synchronous, it also handles the diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go index f3ccad45..4bc23d72 100644 --- a/pkg/modules/api/middlewares_test.go +++ b/pkg/modules/api/middlewares_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "crypto/rand" + "crypto/rsa" "errors" "fmt" "log/slog" @@ -11,6 +13,8 @@ import ( "testing" "time" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/coreos/go-oidc/v3/oidc/oidctest" "github.com/labstack/echo/v4" ) @@ -152,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te t.Fatalf("error = %q, want a message mentioning logger", err) } } + +func TestOidcAuthMiddleware(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + const ( + keyID = "test-key" + audience = "gotenberg" + ) + + oidcServer := &oidctest.Server{ + PublicKeys: []oidctest.PublicKey{ + {PublicKey: privateKey.Public(), KeyID: keyID, Algorithm: oidc.RS256}, + }, + } + srv := httptest.NewServer(oidcServer) + defer srv.Close() + oidcServer.SetIssuer(srv.URL) + + // Building through the module's own helper exercises the discovery path too. + a := &Api{oidcIssuer: srv.URL, oidcAudience: audience} + verifier, err := a.buildOidcVerifier() + if err != nil { + t.Fatalf("build verifier: %v", err) + } + + claims := func(issuer, aud string, expiresIn time.Duration) string { + now := time.Now() + return fmt.Sprintf(`{"iss":%q,"aud":%q,"sub":"user","exp":%d,"iat":%d}`, + issuer, aud, now.Add(expiresIn).Unix(), now.Unix()) + } + sign := func(claims string) string { + return oidctest.SignIDToken(privateKey, keyID, oidc.RS256, claims) + } + + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate other key: %v", err) + } + + for _, tc := range []struct { + scenario string + authHeader string + wantStatus int + }{ + {"valid token", "Bearer " + sign(claims(srv.URL, audience, time.Hour)), http.StatusOK}, + {"missing header", "", http.StatusUnauthorized}, + {"wrong scheme", "Basic Zm9vOmJhcg==", http.StatusUnauthorized}, + {"empty bearer", "Bearer ", http.StatusUnauthorized}, + {"malformed token", "Bearer not-a-jwt", http.StatusUnauthorized}, + {"wrong issuer", "Bearer " + sign(claims("https://evil.example/", audience, time.Hour)), http.StatusUnauthorized}, + {"wrong audience", "Bearer " + sign(claims(srv.URL, "someone-else", time.Hour)), http.StatusUnauthorized}, + {"expired token", "Bearer " + sign(claims(srv.URL, audience, -time.Hour)), http.StatusUnauthorized}, + {"unknown signing key", "Bearer " + oidctest.SignIDToken(otherKey, "unknown", oidc.RS256, claims(srv.URL, audience, time.Hour)), http.StatusUnauthorized}, + } { + t.Run(tc.scenario, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + c := echo.New().NewContext(req, httptest.NewRecorder()) + + handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + err := handler(c) + + if tc.wantStatus == http.StatusOK { + if err != nil { + t.Fatalf("expected the request to pass, got error: %v", err) + } + return + } + + var httpErr *echo.HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected an *echo.HTTPError, got %T (%v)", err, err) + } + if httpErr.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", httpErr.Code, tc.wantStatus) + } + }) + } +} diff --git a/test/integration/features/root.feature b/test/integration/features/root.feature index 1d39beef..40477215 100644 --- a/test/integration/features/root.feature +++ b/test/integration/features/root.feature @@ -29,6 +29,17 @@ Feature: / When I make a "GET" request to Gotenberg at the "/" endpoint Then the response status code should be 401 + # A request without a Bearer token is rejected. The JWKS URL is never fetched + # here, so no OIDC provider needs to be reachable. + Scenario: GET / (OIDC Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_OIDC_AUTH | true | + | API_OIDC_ISSUER | https://gotenberg.test/ | + | API_OIDC_AUDIENCE | gotenberg | + | API_OIDC_JWKS_URL | https://gotenberg.test/jwks.json | + When I make a "GET" request to Gotenberg at the "/" endpoint + Then the response status code should be 401 + Scenario: GET /foo/ (Root Path) Given I have a Gotenberg container with the following environment variable(s): | API_ROOT_PATH | /foo/ |