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 API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= 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_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_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 API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false

8
go.mod
View File

@@ -37,6 +37,11 @@ require (
golang.org/x/text v0.41.0 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 ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // 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/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.2 // indirect github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.1.0 // 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-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
@@ -118,7 +124,6 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // 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/otlploggrpc v0.21.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp 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 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 go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.55.0 // 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/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // 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/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 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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= 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/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 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= 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 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= 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= 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/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 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -39,6 +39,10 @@ type Api struct {
correlationIdHeader string correlationIdHeader string
basicAuthUsername string basicAuthUsername string
basicAuthPassword string basicAuthPassword string
oidcEnabled bool
oidcIssuer string
oidcAudience string
oidcJwksUrl string
downloadFromCfg downloadFromConfig downloadFromCfg downloadFromConfig
disableHealthCheckRouteTelemetry bool disableHealthCheckRouteTelemetry bool
disableRootRouteTelemetry 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-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.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-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-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.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") 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 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. // Get routes from modules.
mods, err := ctx.Modules(new(Router)) mods, err := ctx.Modules(new(Router))
if err != nil { 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 { if err != nil {
return err return err
} }
@@ -517,11 +553,18 @@ func (a *Api) Start() error {
hardTimeout := a.timeout + (time.Duration(5) * time.Second) hardTimeout := a.timeout + (time.Duration(5) * time.Second)
// Basic auth? // Authentication?
var securityMiddleware echo.MiddlewareFunc var securityMiddleware echo.MiddlewareFunc
if a.basicAuthUsername != "" { switch {
case a.basicAuthUsername != "":
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword) 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 { securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
return next(c) 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" "strings"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation" "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 // contextMiddleware, middleware for "multipart/form-data" requests, sets the
// [Context] and related context.CancelFunc in the [echo.Context] under // [Context] and related context.CancelFunc in the [echo.Context] under
// "context" and "cancel". If the process is synchronous, it also handles the // "context" and "cancel". If the process is synchronous, it also handles the

View File

@@ -2,6 +2,8 @@ package api
import ( import (
"context" "context"
"crypto/rand"
"crypto/rsa"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -11,6 +13,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -152,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te
t.Fatalf("error = %q, want a message mentioning logger", err) 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 When I make a "GET" request to Gotenberg at the "/" endpoint
Then the response status code should be 401 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) Scenario: GET /foo/ (Root Path)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):
| API_ROOT_PATH | /foo/ | | API_ROOT_PATH | /foo/ |