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

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