mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-17 12:42:16 +01:00
feat(api): add OIDC bearer token authentication
This commit is contained in:
4
Makefile
4
Makefile
@@ -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
8
go.mod
@@ -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
6
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/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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
67
pkg/modules/api/api_test.go
Normal file
67
pkg/modules/api/api_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/ |
|
||||||
|
|||||||
Reference in New Issue
Block a user