feat(api): add OIDC bearer token authentication

This commit is contained in:
Julien Neuhart
2026-08-14 17:04:20 +02:00
parent 3de6932279
commit c0f487e333
8 changed files with 291 additions and 4 deletions

View File

@@ -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

8
go.mod
View File

@@ -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

6
go.sum
View File

@@ -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=

View File

@@ -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)

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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/ |