mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
546ceb949c
* fix(security): encrypt session cookies + fail closed on invalid config
Batch 1 of security audit remediation (ranks 1, 2, 6).
- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
and build the gorilla cookie store with both, so session cookies are now
encrypted, not merely signed. The single-key store previously left OIDC
access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
instead of silently substituting a public hardcoded encryption key for
empty/short keys (which allowed session forgery). The yaegi analyzer
passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
tests in security_audit_fixes_test.go.
Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).
* fix(security): validate discovered OIDC endpoints + pin introspection host
Batch 2 of security audit remediation (ranks 3, 4).
- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
taken from the provider discovery document (jwks_uri, token, authorization,
revocation, end_session, introspection, registration). Blocks link-local
(cloud metadata 169.254.169.254), multicast, unspecified and private
addresses (unless allowPrivateIPAddresses); blocks loopback unless the
configured providerURL is itself loopback (dev/test). Cross-domain JWKS
hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
blanks any that fail (fail closed downstream). The introspection endpoint
carries the client secret via HTTP Basic, so it is additionally pinned to
the providerURL host to stop a poisoned discovery document exfiltrating the
secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.
* fix(security): close open redirects + anchor excluded-URL matching
Batch 3 of security audit remediation (ranks 5, 14, 15).
- auth_flow.go: run the stored incoming path through normalizeLogoutPath
before using it as the post-login redirect, so //evil.com and /\evil.com
payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
(exact, sub-path "/", or file extension "."), so excluding "/public" no
longer also bypasses auth on "/publicsecret"; "/favicon" still matches
"/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
endpoint is host-relative, never an absolute URL derived from the
client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
add regression tests.
* fix(security): reject unverified Azure tokens; fix transport TLS reuse
Batch 4 of security audit remediation (ranks 7, 11).
- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
cryptographically verified no longer returns "authenticated" when there is
no ID token to corroborate it; it refreshes (if possible) or forces
re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
lock before mutating refCount (fixes a data race) and only reuses a
transport whose TLS settings (CA pool + InsecureSkipVerify) match the
caller's, never one with a different trust store; if none matches it returns
nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.
* fix(security): stop logging templated header values (token leak)
Batch 5 of security audit remediation (rank 16).
middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.
* fix(security): cache-key collision, cache-config divergence, fleet cleanup
Batch 6 of security audit remediation (ranks 9, 10, 12).
- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
full token instead of the first 32 chars (which are only the base64url JWT
header). Distinct tokens sharing alg+kid no longer collide and get
mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
shared across plugin instances; it now logs a loud warning when a later
instance requests a different explicit Redis backend that is silently
ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
instance count; the shared singleton-token-cleanup task is stopped only when
the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.
* fix(security): bound introspection cache + cookie lifetime to config
Batch 7 of security audit remediation (ranks 8, 13).
- token_introspection.go: when requireTokenIntrospection is enabled, cap the
positive introspection-result cache at 30s (instead of 5m) so a token
revoked at the provider stops passing within ~30s, matching the operator's
near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
so the cookie codec's cryptographic timestamp validity is no longer fixed at
gorilla's 30-day default; a stolen cookie is valid only for the configured
session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.
* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)
Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).
- universal_cache.go: updateLocalCache updates an existing key in place instead
of orphaning its LRU element and double-counting currentSize/currentMemory
(rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.
* fix(security): header sanitization, issuer pinning, fail-closed paths
Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).
- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
auth path before injecting them into downstream headers. Drop group/role and
identifier values containing control chars, bidi-override runes, or the
, ; = delimiters (a comma would inject phantom entries into X-User-Groups);
reject control/bidi/over-length in rendered templated header output (but
permit , ; = in free-form values such as a bearer token). The bearer path
already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
closed on a missing or mismatched introspection audience; aud parsed as
string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
successful introspection fails closed (re-auth) instead of authenticating
(ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
sanitization distinction and the fail-closed paths.
* chore(security): remove unwired dead code with latent footguns
Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.
- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
Valid=true with NO signature verification — a severe footgun if ever wired
(rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
(Update/Read/DeleteClientRegistration) that dereferenced an attacker-
influenced RegistrationClientURI with the registration token attached and no
HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.
* chore: add Makefile with yaegi load validation
No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.
- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
excluded from `go build ./...` (avoids VCS-stamping a main binary, which
fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
targets; `make check` runs vet + tests + yaegi-validate.
Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.
* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1
Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.
Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.
Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.
* docs: align README/CONFIGURATION with branch behavior changes
- excludedURLs: documented as segment/extension-boundary matching (was
"prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
callbackURL, non-HTTPS remote providerURL) now fails closed at startup.
* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check
CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.
The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.
* fix(test): stabilize flaky TestWorkerPool_TaskPanic
tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
788 lines
29 KiB
Go
788 lines
29 KiB
Go
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
// It supports multiple OIDC providers including Google, Azure AD, and generic OIDC providers
|
|
// with features like token refresh, session management, and provider-specific optimizations.
|
|
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
"time"
|
|
|
|
telemetry "github.com/lukaszraczylo/oss-telemetry"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
const (
|
|
ConstSessionTimeout = 86400
|
|
)
|
|
|
|
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
|
|
// process. Traefik calls New once per route that uses the plugin; oss-telemetry
|
|
// does not deduplicate client-side (the server does), so the gate stays here.
|
|
var telemetryStartupOnce sync.Once
|
|
|
|
// isTestMode detects if the code is running in a test environment.
|
|
func isTestMode() bool {
|
|
if os.Getenv("SUPPRESS_DIAGNOSTIC_LOGS") == "1" {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(os.Args[0], ".test") ||
|
|
strings.Contains(os.Args[0], "go_build_") ||
|
|
os.Getenv("GO_TEST") == "1" ||
|
|
runtime.Compiler == "yaegi" {
|
|
return true
|
|
}
|
|
|
|
for _, arg := range os.Args {
|
|
if strings.Contains(arg, "-test") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// mergeScopes combines default scopes with user-provided scopes, removing duplicates.
|
|
func mergeScopes(defaultScopes, userScopes []string) []string {
|
|
if len(userScopes) == 0 {
|
|
return append([]string(nil), defaultScopes...)
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var result []string
|
|
|
|
for _, scope := range defaultScopes {
|
|
if !seen[scope] {
|
|
seen[scope] = true
|
|
result = append(result, scope)
|
|
}
|
|
}
|
|
|
|
for _, scope := range userScopes {
|
|
if !seen[scope] {
|
|
seen[scope] = true
|
|
result = append(result, scope)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// defaultExcludedURLs are the paths that are excluded from authentication
|
|
var defaultExcludedURLs = map[string]struct{}{
|
|
"/favicon": {},
|
|
}
|
|
|
|
// New creates a new TraefikOidc middleware instance.
|
|
// It initializes all components including caches, HTTP clients, session management,
|
|
// templates, and starts background processes for metadata discovery.
|
|
// Parameters:
|
|
// - ctx: The context for the middleware lifecycle.
|
|
// - next: The next HTTP handler in the middleware chain.
|
|
// - config: The OIDC configuration containing provider details, client credentials, etc.
|
|
// - name: The name of the middleware instance.
|
|
//
|
|
// Returns:
|
|
// - The configured TraefikOidc handler ready to process requests.
|
|
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
|
|
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
|
telemetryStartupOnce.Do(func() {
|
|
// Only stamped release builds phone home; dev/local/test builds keep the
|
|
// devPluginVersion sentinel (see version.go) and stay silent.
|
|
if traefikoidcPluginVersion != devPluginVersion {
|
|
telemetry.Send("traefikoidc", traefikoidcPluginVersion)
|
|
}
|
|
})
|
|
return NewWithContext(ctx, config, next, name)
|
|
}
|
|
|
|
// NewWithContext creates a new TraefikOidc middleware instance with proper context handling.
|
|
// This is the preferred constructor that ensures proper goroutine lifecycle management.
|
|
func NewWithContext(ctx context.Context, config *Config, next http.Handler, name string) (*TraefikOidc, error) {
|
|
if config == nil {
|
|
config = CreateConfig()
|
|
}
|
|
|
|
logger := NewLogger(config.LogLevel)
|
|
|
|
// Fail closed on invalid configuration. Validate() enforces the security
|
|
// constraints (required fields, HTTPS-only URLs, key length, excludedURLs
|
|
// safety, rate-limit floor, audience format, ...) that were previously
|
|
// unenforced because this constructor never called it. Crucially it rejects
|
|
// an empty or too-short SessionEncryptionKey instead of silently
|
|
// substituting a public hardcoded key, which would let an attacker forge
|
|
// any session. Traefik's yaegi plugin analyzer supplies a valid key via
|
|
// .traefik.yml testData, so it passes; only misconfigured deployments fail.
|
|
if err := config.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid configuration: %w", err)
|
|
}
|
|
// Setup HTTP client
|
|
caPool, err := config.loadCACertPool()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load CA certificates: %w", err)
|
|
}
|
|
if config.InsecureSkipVerify {
|
|
logger.Errorf("SECURITY WARNING: InsecureSkipVerify is enabled for the OIDC provider. TLS certificate verification is DISABLED. Do not use in production.")
|
|
}
|
|
var httpClient *http.Client
|
|
if config.HTTPClient != nil {
|
|
httpClient = config.HTTPClient
|
|
} else {
|
|
defaultCfg := DefaultHTTPClientConfig()
|
|
defaultCfg.RootCAs = caPool
|
|
defaultCfg.InsecureSkipVerify = config.InsecureSkipVerify
|
|
httpClient = CreatePooledHTTPClient(defaultCfg)
|
|
}
|
|
tokenCfg := TokenHTTPClientConfig()
|
|
tokenCfg.RootCAs = caPool
|
|
tokenCfg.InsecureSkipVerify = config.InsecureSkipVerify
|
|
tokenHTTPClient := CreatePooledHTTPClient(tokenCfg)
|
|
goroutineWG := &sync.WaitGroup{}
|
|
cacheManager := GetGlobalCacheManagerWithConfig(goroutineWG, config)
|
|
|
|
// Use provided context instead of creating new one
|
|
var pluginCtx context.Context
|
|
var cancelFunc context.CancelFunc
|
|
if ctx != nil {
|
|
pluginCtx, cancelFunc = context.WithCancel(ctx)
|
|
} else {
|
|
pluginCtx, cancelFunc = context.WithCancel(context.Background())
|
|
}
|
|
|
|
t := &TraefikOidc{
|
|
next: next,
|
|
name: name,
|
|
goroutineWG: goroutineWG,
|
|
redirURLPath: config.CallbackURL,
|
|
logoutURLPath: func() string {
|
|
if config.LogoutURL == "" {
|
|
return config.CallbackURL + "/logout"
|
|
}
|
|
return config.LogoutURL
|
|
}(),
|
|
postLogoutRedirectURI: func() string {
|
|
if config.PostLogoutRedirectURI == "" {
|
|
return "/"
|
|
}
|
|
return config.PostLogoutRedirectURI
|
|
}(),
|
|
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
|
|
tokenTypeCache: cacheManager.GetSharedTokenTypeCache(), // Cache for token type detection
|
|
jwkCache: cacheManager.GetSharedJWKCache(),
|
|
metadataCache: cacheManager.GetSharedMetadataCache(),
|
|
introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results
|
|
clientID: config.ClientID,
|
|
clientSecret: config.ClientSecret,
|
|
clientAuthMethod: func() string {
|
|
if config.ClientAuthMethod != "" {
|
|
return config.ClientAuthMethod
|
|
}
|
|
return "client_secret_post"
|
|
}(),
|
|
audience: func() string {
|
|
if config.Audience != "" {
|
|
return config.Audience
|
|
}
|
|
return config.ClientID
|
|
}(),
|
|
roleClaimName: func() string {
|
|
if config.RoleClaimName != "" {
|
|
return config.RoleClaimName
|
|
}
|
|
return "roles" // Backward compatible default
|
|
}(),
|
|
groupClaimName: func() string {
|
|
if config.GroupClaimName != "" {
|
|
return config.GroupClaimName
|
|
}
|
|
return "groups" // Backward compatible default
|
|
}(),
|
|
userIdentifierClaim: func() string {
|
|
if config.UserIdentifierClaim != "" {
|
|
return config.UserIdentifierClaim
|
|
}
|
|
return "email" // Backward compatible default
|
|
}(),
|
|
forceHTTPS: config.ForceHTTPS,
|
|
enablePKCE: config.EnablePKCE,
|
|
extraAuthParams: config.ExtraAuthParams,
|
|
overrideScopes: config.OverrideScopes,
|
|
strictAudienceValidation: config.StrictAudienceValidation,
|
|
allowOpaqueTokens: config.AllowOpaqueTokens,
|
|
requireTokenIntrospection: config.RequireTokenIntrospection,
|
|
disableReplayDetection: config.DisableReplayDetection,
|
|
scopes: func() []string {
|
|
userProvidedScopes := deduplicateScopes(config.Scopes)
|
|
|
|
if config.OverrideScopes {
|
|
return userProvidedScopes
|
|
}
|
|
|
|
defaultSystemScopes := []string{"openid", "profile", "email"}
|
|
return deduplicateScopes(mergeScopes(defaultSystemScopes, userProvidedScopes))
|
|
}(),
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), config.RateLimit),
|
|
tokenCache: cacheManager.GetSharedTokenCache(),
|
|
httpClient: httpClient,
|
|
tokenHTTPClient: tokenHTTPClient,
|
|
excludedURLs: createStringMap(config.ExcludedURLs),
|
|
allowedUserDomains: createCaseInsensitiveStringMap(config.AllowedUserDomains),
|
|
allowedUsers: createCaseInsensitiveStringMap(config.AllowedUsers),
|
|
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
|
|
initComplete: make(chan struct{}),
|
|
logger: logger,
|
|
refreshGracePeriod: func() time.Duration {
|
|
if config.RefreshGracePeriodSeconds > 0 {
|
|
return time.Duration(config.RefreshGracePeriodSeconds) * time.Second
|
|
}
|
|
return 60 * time.Second
|
|
}(),
|
|
maxRefreshTokenAge: func() time.Duration {
|
|
// 0 (or unset) disables the heuristic; negative is rejected by Validate.
|
|
if config.MaxRefreshTokenAgeSeconds > 0 {
|
|
return time.Duration(config.MaxRefreshTokenAgeSeconds) * time.Second
|
|
}
|
|
return 0
|
|
}(),
|
|
tokenCleanupStopChan: make(chan struct{}),
|
|
metadataRefreshStopChan: make(chan struct{}),
|
|
ctx: pluginCtx,
|
|
cancelFunc: cancelFunc,
|
|
suppressDiagnosticLogs: isTestMode(),
|
|
securityHeadersApplier: config.GetSecurityHeadersApplier(),
|
|
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
|
|
dcrConfig: config.DynamicClientRegistration,
|
|
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
|
|
minimalHeaders: config.MinimalHeaders,
|
|
stripAuthCookies: config.StripAuthCookies,
|
|
enableBackchannelLogout: config.EnableBackchannelLogout,
|
|
enableFrontchannelLogout: config.EnableFrontchannelLogout,
|
|
backchannelLogoutPath: normalizeLogoutPath(config.BackchannelLogoutURL),
|
|
frontchannelLogoutPath: normalizeLogoutPath(config.FrontchannelLogoutURL),
|
|
sessionInvalidationCache: cacheManager.GetSharedSessionInvalidationCache(),
|
|
refreshResultCache: cacheManager.GetSharedRefreshResultCache(),
|
|
enableBearerAuth: config.EnableBearerAuth,
|
|
stripAuthorizationHeader: config.StripAuthorizationHeader,
|
|
bearerEmitWWWAuthenticate: config.BearerEmitWWWAuthenticate,
|
|
bearerOverridesCookie: config.BearerOverridesCookie,
|
|
bearerIdentifierClaim: func() string {
|
|
if config.BearerIdentifierClaim != "" {
|
|
return config.BearerIdentifierClaim
|
|
}
|
|
return "sub"
|
|
}(),
|
|
maxIdentifierLength: func() int {
|
|
if config.MaxIdentifierLength > 0 {
|
|
return config.MaxIdentifierLength
|
|
}
|
|
return 256
|
|
}(),
|
|
maxTokenAge: func() time.Duration {
|
|
if config.MaxTokenAgeSeconds > 0 {
|
|
return time.Duration(config.MaxTokenAgeSeconds) * time.Second
|
|
}
|
|
return 24 * time.Hour
|
|
}(),
|
|
bearerFailureThreshold: func() int {
|
|
if config.BearerFailureThreshold > 0 {
|
|
return config.BearerFailureThreshold
|
|
}
|
|
return 20
|
|
}(),
|
|
bearerFailureWindow: func() time.Duration {
|
|
if config.BearerFailureWindowSeconds > 0 {
|
|
return time.Duration(config.BearerFailureWindowSeconds) * time.Second
|
|
}
|
|
return 60 * time.Second
|
|
}(),
|
|
bearerFailurePenalty: func() time.Duration {
|
|
if config.BearerFailurePenaltySeconds > 0 {
|
|
return time.Duration(config.BearerFailurePenaltySeconds) * time.Second
|
|
}
|
|
return 60 * time.Second
|
|
}(),
|
|
}
|
|
|
|
// Log audience configuration
|
|
if config.Audience != "" && config.Audience != config.ClientID {
|
|
t.logger.Infof("Custom audience configured: %s", config.Audience)
|
|
} else {
|
|
t.logger.Debugf("No custom audience specified, using clientID as audience: %s", t.clientID)
|
|
}
|
|
|
|
// Bearer-auth startup validation. The bearer path is M2M-only and demands
|
|
// a non-default audience so tokens issued for a different resource cannot
|
|
// be replayed against this service. The BearerIdentifierClaim guard blocks
|
|
// the `email` claim explicitly — without email_verified enforcement (out of
|
|
// scope for M2M), trusting email is a spoofing vector for federated IdPs.
|
|
// See spec §7.9 / §13.
|
|
if config.EnableBearerAuth {
|
|
if config.Audience == "" {
|
|
cancelFunc()
|
|
return nil, fmt.Errorf("EnableBearerAuth=true requires Audience to be set explicitly (cannot default to clientID — that path accepts ID tokens)")
|
|
}
|
|
if t.bearerIdentifierClaim == "email" {
|
|
cancelFunc()
|
|
return nil, fmt.Errorf("enableBearerAuth=true with bearerIdentifierClaim=%q is rejected: email-based identity without email_verified enforcement is a spoofing vector for federated IdPs (use \"sub\" or a custom claim; cookie-path userIdentifierClaim is unaffected)", t.bearerIdentifierClaim)
|
|
}
|
|
if !config.StrictAudienceValidation {
|
|
t.logger.Infof("EnableBearerAuth=true with StrictAudienceValidation=false: recommend enabling strict audience validation for hardening")
|
|
}
|
|
t.bearerFailureTracker = newBearerFailureTracker(
|
|
t.bearerFailureThreshold, t.bearerFailureWindow, t.bearerFailurePenalty,
|
|
)
|
|
t.logger.Infof("Bearer-token auth enabled: audience=%q identifierClaim=%q stripAuthz=%t bearerOverridesCookie=%t maxTokenAge=%s",
|
|
config.Audience, t.bearerIdentifierClaim, t.stripAuthorizationHeader, t.bearerOverridesCookie, t.maxTokenAge)
|
|
}
|
|
|
|
// Convert sessionMaxAge from seconds to duration (0 will use default 24 hours)
|
|
sessionMaxAge := time.Duration(config.SessionMaxAge) * time.Second
|
|
sessionManager, err := NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, config.CookiePrefix, sessionMaxAge, t.logger)
|
|
if err != nil {
|
|
cancelFunc()
|
|
return nil, fmt.Errorf("failed to create session manager: %w", err)
|
|
}
|
|
t.sessionManager = sessionManager
|
|
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
|
|
|
|
// Initialize token resilience manager with default configuration
|
|
tokenResilienceConfig := DefaultTokenResilienceConfig()
|
|
t.tokenResilienceManager = NewTokenResilienceManager(tokenResilienceConfig, t.logger)
|
|
|
|
// Coalesces concurrent refresh-token grants per refresh_token to one upstream
|
|
// call, preventing the thundering herd that yields invalid_grant when the IdP
|
|
// rotates refresh tokens (Zitadel/Authentik default).
|
|
t.refreshCoordinator = NewRefreshCoordinator(DefaultRefreshCoordinatorConfig(), t.logger)
|
|
|
|
if config.ClientAuthMethod == "private_key_jwt" {
|
|
signer, err := buildClientAssertionSignerFromConfig(config)
|
|
if err != nil {
|
|
cancelFunc()
|
|
return nil, fmt.Errorf("failed to build client assertion signer: %w", err)
|
|
}
|
|
t.clientAssertion = signer
|
|
}
|
|
|
|
t.extractClaimsFunc = extractClaims
|
|
t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
|
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
}
|
|
|
|
for k, v := range defaultExcludedURLs {
|
|
t.excludedURLs[k] = v
|
|
}
|
|
|
|
t.tokenVerifier = t
|
|
t.jwtVerifier = t
|
|
t.tokenExchanger = t
|
|
|
|
t.headerTemplates = make(map[string]*template.Template)
|
|
|
|
funcMap := template.FuncMap{
|
|
"default": func(defaultVal interface{}, val interface{}) interface{} {
|
|
if val == nil || val == "" {
|
|
return defaultVal
|
|
}
|
|
return val
|
|
},
|
|
"get": func(m interface{}, key string) interface{} {
|
|
if mapVal, ok := m.(map[string]interface{}); ok {
|
|
if val, exists := mapVal[key]; exists {
|
|
return val
|
|
}
|
|
}
|
|
return ""
|
|
},
|
|
}
|
|
|
|
for _, header := range config.Headers {
|
|
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
|
|
|
|
parsedTmpl, err := tmpl.Parse(header.Value)
|
|
if err != nil {
|
|
logger.Errorf("Failed to parse header template for %s: %v", header.Name, err)
|
|
continue
|
|
}
|
|
|
|
t.headerTemplates[header.Name] = parsedTmpl
|
|
logger.Debugf("Parsed template for header %s: %s", header.Name, header.Value)
|
|
}
|
|
|
|
startReplayCacheCleanup(pluginCtx, logger)
|
|
|
|
// Start memory monitoring for leak detection and performance insights.
|
|
// The interval is clamped to MinMemoryMonitorInterval (30s) inside
|
|
// StartMonitoring; tests that need deterministic sampling should call
|
|
// MemoryMonitor.Refresh() directly instead of waiting on a fast ticker.
|
|
memoryMonitor := GetGlobalMemoryMonitor()
|
|
memoryMonitor.StartMonitoring(pluginCtx, DefaultMemoryMonitorInterval)
|
|
logger.Debug("Started global memory monitoring")
|
|
|
|
logger.Debugf("TraefikOidc.New: Final t.scopes initialized to: %v", t.scopes)
|
|
|
|
// Log callback URL configuration to help diagnose redirect loop issues.
|
|
// If callbackURL is a full URL instead of a path, the callback matching
|
|
// in ServeHTTP will silently fail because req.URL.Path is compared directly.
|
|
logger.Debugf("TraefikOidc.New: callbackURL (redirURLPath) configured as: %q", t.redirURLPath)
|
|
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
|
|
|
|
t.providerURL = config.ProviderURL
|
|
|
|
// Use singleton resource manager for metadata initialization
|
|
rm := GetResourceManager()
|
|
|
|
// Add reference for this instance
|
|
rm.AddReference(name)
|
|
registerLiveInstance()
|
|
|
|
// Initialize metadata in a goroutine with proper tracking
|
|
if t.goroutineWG != nil {
|
|
t.goroutineWG.Add(1)
|
|
}
|
|
go func() {
|
|
defer func() {
|
|
if t.goroutineWG != nil {
|
|
t.goroutineWG.Done()
|
|
}
|
|
// Recover from panics to prevent goroutine leaks
|
|
if r := recover(); r != nil {
|
|
t.safeLogErrorf("Initialize metadata goroutine panic recovered: %v", r)
|
|
}
|
|
}()
|
|
t.initializeMetadata(config.ProviderURL)
|
|
}()
|
|
|
|
// Setup cleanup hook for when context is canceled
|
|
if pluginCtx != nil {
|
|
go func() {
|
|
<-pluginCtx.Done()
|
|
_ = t.Close() // Safe to ignore: cleanup on context cancellation
|
|
}()
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
// initializeMetadata initializes OIDC provider metadata by fetching configuration.
|
|
// It retrieves the provider's .well-known/openid-configuration and updates
|
|
// internal endpoint URLs. Uses error recovery if available for resilient fetching.
|
|
// Parameters:
|
|
// - providerURL: The base URL of the OIDC provider.
|
|
func (t *TraefikOidc) initializeMetadata(providerURL string) {
|
|
t.safeLogDebug("Starting provider metadata discovery")
|
|
|
|
// Ensure initComplete is always closed, even on failure
|
|
defer func() {
|
|
select {
|
|
case <-t.initComplete:
|
|
// Already closed, do nothing
|
|
default:
|
|
close(t.initComplete)
|
|
}
|
|
}()
|
|
|
|
// Get metadata from cache or fetch it with error recovery if available
|
|
var metadata *ProviderMetadata
|
|
var err error
|
|
if t.errorRecoveryManager != nil {
|
|
metadata, err = t.metadataCache.GetMetadataWithRecovery(providerURL, t.httpClient, t.logger, t.errorRecoveryManager)
|
|
} else {
|
|
metadata, err = t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger)
|
|
}
|
|
if err != nil {
|
|
t.safeLogErrorf("Failed to get provider metadata: %v", err)
|
|
return
|
|
}
|
|
|
|
if metadata != nil {
|
|
t.safeLogDebug("Successfully initialized provider metadata")
|
|
t.updateMetadataEndpoints(metadata)
|
|
return
|
|
}
|
|
|
|
t.safeLogError("Received nil metadata during initialization")
|
|
}
|
|
|
|
// updateMetadataEndpoints updates internal endpoint URLs with discovered metadata.
|
|
// It sets the authorization URL, token URL, JWKS URL, issuer URL, revocation URL,
|
|
// end session URL, introspection URL, and registration URL based on the provider's metadata.
|
|
// If Dynamic Client Registration is enabled and no ClientID is configured, it will
|
|
// automatically register the client with the provider.
|
|
// Parameters:
|
|
// - metadata: A pointer to the ProviderMetadata struct containing the discovered endpoints.
|
|
func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
|
|
// SSRF defense (audit ranks 3 & 4): a discovery document is attacker-
|
|
// influenced when the provider or its TLS is compromised. Reject any
|
|
// discovered endpoint pointed at a blocked address before the plugin issues
|
|
// outbound requests to it, so it can never be used to reach the cloud
|
|
// metadata service or an internal host.
|
|
allowLoopback := false
|
|
if pu, err := url.Parse(t.providerURL); err == nil {
|
|
allowLoopback = isLoopbackHost(pu.Hostname())
|
|
}
|
|
sanitize := func(name, raw string) string {
|
|
if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil {
|
|
t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err)
|
|
return ""
|
|
}
|
|
return raw
|
|
}
|
|
metadata.JWKSURL = sanitize("jwks_uri", metadata.JWKSURL)
|
|
metadata.AuthURL = sanitize("authorization", metadata.AuthURL)
|
|
metadata.TokenURL = sanitize("token", metadata.TokenURL)
|
|
metadata.RevokeURL = sanitize("revocation", metadata.RevokeURL)
|
|
metadata.EndSessionURL = sanitize("end_session", metadata.EndSessionURL)
|
|
metadata.RegistrationURL = sanitize("registration", metadata.RegistrationURL)
|
|
metadata.IntrospectionURL = sanitize("introspection", metadata.IntrospectionURL)
|
|
// The introspection request authenticates with the client secret via HTTP
|
|
// Basic, so the endpoint must live on the same host as the operator-
|
|
// configured provider; otherwise a poisoned discovery document could
|
|
// exfiltrate the client secret to an attacker-controlled host.
|
|
if metadata.IntrospectionURL != "" && t.providerURL != "" && !sameHost(metadata.IntrospectionURL, t.providerURL) {
|
|
t.logger.Errorf("Ignoring introspection endpoint %q: host does not match configured providerURL", metadata.IntrospectionURL)
|
|
metadata.IntrospectionURL = ""
|
|
}
|
|
|
|
// Pin the discovered issuer to the operator-configured provider host. The
|
|
// issuer is the trust anchor for JWT issuer validation, so a poisoned
|
|
// discovery document advertising an attacker-chosen issuer must never be
|
|
// stored. Real providers (Google, Azure, Keycloak, Okta, Auth0) keep the
|
|
// issuer on the same host as the configured providerURL. On mismatch, leave
|
|
// issuerURL empty/unchanged so downstream issuer validation fails closed
|
|
// rather than trusting the attacker-chosen value.
|
|
discoveredIssuer := metadata.Issuer
|
|
if discoveredIssuer != "" && t.providerURL != "" && !sameHost(discoveredIssuer, t.providerURL) {
|
|
t.logger.Errorf("Ignoring discovered issuer %q: host does not match configured providerURL", discoveredIssuer)
|
|
discoveredIssuer = ""
|
|
}
|
|
|
|
t.metadataMu.Lock()
|
|
|
|
t.jwksURL = metadata.JWKSURL
|
|
t.scopesSupported = metadata.ScopesSupported // Store supported scopes from discovery
|
|
t.authURL = metadata.AuthURL
|
|
t.tokenURL = metadata.TokenURL
|
|
t.issuerURL = discoveredIssuer
|
|
t.revocationURL = metadata.RevokeURL
|
|
t.endSessionURL = metadata.EndSessionURL
|
|
t.introspectionURL = metadata.IntrospectionURL // OAuth 2.0 Token Introspection endpoint (RFC 7662)
|
|
t.registrationURL = metadata.RegistrationURL // OIDC Dynamic Client Registration endpoint (RFC 7591)
|
|
|
|
// Copy values for logging after unlock to avoid race conditions
|
|
introspectionURL := t.introspectionURL
|
|
registrationURL := t.registrationURL
|
|
|
|
// Publish the read-mostly URL bundle atomically. Hot-path readers Load
|
|
// this directly instead of acquiring metadataMu.RLock per request.
|
|
t.metadataSnapshot.Store(&MetadataSnapshot{
|
|
IssuerURL: discoveredIssuer,
|
|
JWKSURL: metadata.JWKSURL,
|
|
TokenURL: metadata.TokenURL,
|
|
AuthURL: metadata.AuthURL,
|
|
RevocationURL: metadata.RevokeURL,
|
|
EndSessionURL: metadata.EndSessionURL,
|
|
IntrospectionURL: metadata.IntrospectionURL,
|
|
RegistrationURL: metadata.RegistrationURL,
|
|
})
|
|
|
|
t.metadataMu.Unlock()
|
|
|
|
// Log introspection endpoint availability for opaque token support
|
|
if introspectionURL != "" {
|
|
t.logger.Debugf("Token introspection endpoint discovered: %s", introspectionURL)
|
|
if t.allowOpaqueTokens {
|
|
t.logger.Debugf("Opaque token support enabled with introspection endpoint")
|
|
}
|
|
} else if t.allowOpaqueTokens || t.requireTokenIntrospection {
|
|
t.logger.Infof("⚠️ Opaque tokens enabled but no introspection endpoint available from provider")
|
|
}
|
|
|
|
// Log registration endpoint availability
|
|
if registrationURL != "" {
|
|
t.logger.Debugf("Dynamic client registration endpoint discovered: %s", registrationURL)
|
|
}
|
|
|
|
// Perform Dynamic Client Registration if enabled and ClientID is not set
|
|
if t.dcrConfig != nil && t.dcrConfig.Enabled && t.clientID == "" {
|
|
t.performDynamicClientRegistration()
|
|
}
|
|
}
|
|
|
|
// performDynamicClientRegistration performs automatic client registration with the OIDC provider
|
|
func (t *TraefikOidc) performDynamicClientRegistration() {
|
|
t.logger.Info("Dynamic Client Registration enabled - registering client with provider")
|
|
|
|
// Initialize the DCR registrar if not already done
|
|
if t.dynamicClientRegistrar == nil {
|
|
t.dynamicClientRegistrar = NewDynamicClientRegistrar(
|
|
t.httpClient,
|
|
t.logger,
|
|
t.dcrConfig,
|
|
t.providerURL,
|
|
)
|
|
|
|
// Set up storage backend for credentials persistence
|
|
if t.dcrConfig.PersistCredentials {
|
|
cacheManager := GetGlobalCacheManagerWithConfig(t.goroutineWG, nil)
|
|
store, err := NewDCRCredentialsStore(t.dcrConfig, cacheManager, t.logger)
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to create DCR credentials store: %v", err)
|
|
// Continue without persistence - registration will still work
|
|
} else {
|
|
t.dynamicClientRegistrar.SetStore(store)
|
|
t.logger.Debugf("DCR credentials store initialized with backend: %s", t.dcrConfig.StorageBackend)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get registration endpoint (from metadata or config override)
|
|
registrationEndpoint := t.registrationURL
|
|
if t.dcrConfig.RegistrationEndpoint != "" {
|
|
registrationEndpoint = t.dcrConfig.RegistrationEndpoint
|
|
}
|
|
|
|
// Perform registration
|
|
ctx, cancel := context.WithTimeout(t.ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := t.dynamicClientRegistrar.RegisterClient(ctx, registrationEndpoint)
|
|
if err != nil {
|
|
t.logger.Errorf("Dynamic Client Registration failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Update client credentials from registration response
|
|
t.metadataMu.Lock()
|
|
t.clientID = resp.ClientID
|
|
t.clientSecret = resp.ClientSecret
|
|
if t.audience == "" {
|
|
t.audience = resp.ClientID // Default audience to client ID
|
|
}
|
|
t.metadataMu.Unlock()
|
|
|
|
t.logger.Infof("Dynamic Client Registration successful - client_id: %s", resp.ClientID)
|
|
|
|
// Log additional registration details
|
|
if resp.ClientSecretExpiresAt > 0 {
|
|
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
|
|
t.logger.Infof("Client secret expires at: %s", expiresAt.Format(time.RFC3339))
|
|
}
|
|
if resp.RegistrationClientURI != "" {
|
|
t.logger.Debugf("Registration management URI: %s", resp.RegistrationClientURI)
|
|
}
|
|
}
|
|
|
|
// startMetadataRefresh starts a background goroutine that periodically refreshes provider metadata.
|
|
// It runs every 2 hours and implements exponential backoff for consecutive failures.
|
|
// The refresh helps ensure endpoint URLs stay current and handles provider configuration changes.
|
|
// Parameters:
|
|
// - providerURL: The base URL of the OIDC provider, used for subsequent refresh attempts.
|
|
func (t *TraefikOidc) startMetadataRefresh(providerURL string) {
|
|
// Use singleton resource manager for metadata refresh
|
|
rm := GetResourceManager()
|
|
// Use last 6 chars of provider URL hash to create unique task name per realm
|
|
// This fixes multi-realm support where different Keycloak realms need separate refresh tasks
|
|
hash := sha256.Sum256([]byte(providerURL))
|
|
taskName := "singleton-metadata-refresh-" + hex.EncodeToString(hash[:])[0:6]
|
|
|
|
// Create refresh function
|
|
refreshFunc := func() {
|
|
if t.metadataCache == nil || t.httpClient == nil {
|
|
return
|
|
}
|
|
|
|
metadata, err := t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger)
|
|
if err != nil {
|
|
t.safeLogErrorf("Failed to refresh provider metadata: %v", err)
|
|
return
|
|
}
|
|
|
|
if metadata != nil {
|
|
t.updateMetadataEndpoints(metadata)
|
|
t.safeLogDebug("Successfully refreshed provider metadata")
|
|
}
|
|
}
|
|
|
|
// Register as singleton task - will return existing if already registered
|
|
err := rm.RegisterBackgroundTask(taskName, 2*time.Hour, refreshFunc)
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to register metadata refresh task: %v", err)
|
|
return
|
|
}
|
|
|
|
// Start the task if not already running
|
|
if !rm.IsTaskRunning(taskName) {
|
|
_ = rm.StartBackgroundTask(taskName) // Safe to ignore: task registration succeeded, start is best-effort
|
|
t.logger.Debug("Started singleton metadata refresh task")
|
|
} else {
|
|
t.logger.Debug("Metadata refresh task already running, skipping duplicate")
|
|
}
|
|
}
|
|
|
|
// attemptMetadataRecovery tries to fetch provider metadata when the system is in a failed state.
|
|
// This is called periodically (every 30s) when requests come in and metadata is unavailable.
|
|
// It allows automatic recovery when the OIDC provider becomes available again.
|
|
func (t *TraefikOidc) attemptMetadataRecovery() {
|
|
if t.metadataCache == nil || t.httpClient == nil {
|
|
return
|
|
}
|
|
|
|
// Try to fetch metadata (single attempt, no aggressive retry here since this runs every 30s)
|
|
metadata, err := t.metadataCache.GetMetadata(t.providerURL, t.httpClient, t.logger)
|
|
if err != nil {
|
|
t.safeLogDebugf("Metadata recovery attempt failed: %v", err)
|
|
return
|
|
}
|
|
|
|
if metadata != nil {
|
|
t.updateMetadataEndpoints(metadata)
|
|
t.safeLogInfo("Successfully recovered OIDC provider metadata - service restored")
|
|
}
|
|
}
|
|
|
|
// createCaseInsensitiveStringMap creates a map with lowercase keys for case-insensitive matching.
|
|
// This is used for case-insensitive matching of email addresses.
|
|
// Parameters:
|
|
// - items: The string items to convert to lowercase keys.
|
|
//
|
|
// Returns:
|
|
// - A map with lowercase string keys for case-insensitive lookups.
|
|
func createCaseInsensitiveStringMap(items []string) map[string]struct{} {
|
|
result := make(map[string]struct{})
|
|
for _, item := range items {
|
|
result[strings.ToLower(item)] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// buildFullURL constructs a complete URL from scheme, host, and path components.
|
|
// It handles absolute URLs in the path and ensures proper URL formatting.
|
|
// Parameters:
|
|
// - scheme: The URL scheme ("http" or "https").
|
|
// - host: The host name and optional port.
|
|
// - path: The path component (may be absolute URL itself).
|
|
//
|
|
// Returns:
|
|
// - The combined absolute URL string (e.g., "https://example.com:8080/resource").
|
|
func buildFullURL(scheme, host, path string) string {
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return path
|
|
}
|
|
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
|
}
|