Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.42.0
github.com/aws/aws-sdk-go-v2/service/sns v1.34.0
github.com/aws/aws-sdk-go-v2/service/sts v1.26.6
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
Expand Down Expand Up @@ -148,7 +150,6 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
Expand Down Expand Up @@ -207,8 +209,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down
56 changes: 56 additions & 0 deletions internal/server/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/LeanerCloud/CUDly/internal/runtime"
"github.com/LeanerCloud/CUDly/internal/scheduler"
"github.com/LeanerCloud/CUDly/internal/secrets"
"github.com/LeanerCloud/CUDly/internal/server/scheduledauth"
"github.com/LeanerCloud/CUDly/pkg/logging"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
Expand Down Expand Up @@ -77,6 +78,17 @@ type Application struct {
// federated credential path). Nil when the deployment has not
// opted into the federated flow.
signer oidc.Signer

// scheduledAuth authenticates inbound /api/scheduled/* requests.
// Non-nil after NewApplicationFromDeps — disabled mode allows
// everything through with a loud WARN log; oidc mode (GCP)
// validates the Cloud Scheduler-signed ID token; bearer mode
// (Azure) constant-time-compares the shared secret resolved at
// startup from Key Vault. May be nil in tests that build
// Application directly without the constructor; the
// scheduledAuthMiddleware helper passes through unmodified in
// that case so handler-only tests stay focused.
scheduledAuth *scheduledauth.Validator
}

// ApplicationConfig holds all env-based configuration for the application
Expand Down Expand Up @@ -267,6 +279,35 @@ func resolveScheduledTaskSecret(ctx context.Context, cfg ApplicationConfig, reso
return resolved
}

// envSourceOS implements scheduledauth.EnvSource against os.Getenv. The
// scheduledauth package owns the env var names and parse rules so they
// stay aligned with Terraform.
type envSourceOS struct{}

func (envSourceOS) Get(key string) string { return os.Getenv(key) }

// buildScheduledAuth wires up the /api/scheduled/* validator. It pulls
// mode + OIDC params from env (they're per-deployment Terraform inputs)
// but injects the bearer secret from cfg — that path goes through the
// SecretResolver (Key Vault) for production deployments and never lives
// in a container env var. Returns ErrConfigInvalid on misconfig so the
// container fails fast rather than silently accepting everything.
func buildScheduledAuth(cfg ApplicationConfig) (*scheduledauth.Validator, error) {
saCfg, err := scheduledauth.LoadConfig(envSourceOS{})
if err != nil {
return nil, err
}
// In bearer mode, override the env-supplied secret with the one
// already resolved from KV / SM. LoadConfig reads SCHEDULED_TASK_SECRET
// directly which is fine for local dev where the env carries the
// plaintext, but in prod cfg.ScheduledTaskSecret is the authoritative
// value (resolved by resolveScheduledTaskSecret upstream).
if saCfg.Mode == scheduledauth.ModeBearer {
saCfg.Bearer = cfg.ScheduledTaskSecret
}
return scheduledauth.New(saCfg)
Comment on lines +295 to +308

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't fail open when SCHEDULED_TASK_AUTH_MODE is missing.

buildScheduledAuth goes through scheduledauth.LoadConfig, which currently maps an unset mode to disabled. In the production constructor, that turns a missed Terraform/env wiring into a live validator that allows every /api/scheduled/* request instead of failing startup, which is the exact regression this PR is trying to prevent.

🔒 Proposed guard
 func buildScheduledAuth(cfg ApplicationConfig) (*scheduledauth.Validator, error) {
+	if strings.TrimSpace(os.Getenv(scheduledauth.EnvAuthMode)) == "" {
+		return nil, fmt.Errorf(
+			"%w: %s must be explicitly set (use disabled only for local dev)",
+			scheduledauth.ErrConfigInvalid,
+			scheduledauth.EnvAuthMode,
+		)
+	}
 	saCfg, err := scheduledauth.LoadConfig(envSourceOS{})
 	if err != nil {
 		return nil, err
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func buildScheduledAuth(cfg ApplicationConfig) (*scheduledauth.Validator, error) {
saCfg, err := scheduledauth.LoadConfig(envSourceOS{})
if err != nil {
return nil, err
}
// In bearer mode, override the env-supplied secret with the one
// already resolved from KV / SM. LoadConfig reads SCHEDULED_TASK_SECRET
// directly which is fine for local dev where the env carries the
// plaintext, but in prod cfg.ScheduledTaskSecret is the authoritative
// value (resolved by resolveScheduledTaskSecret upstream).
if saCfg.Mode == scheduledauth.ModeBearer {
saCfg.Bearer = cfg.ScheduledTaskSecret
}
return scheduledauth.New(saCfg)
func buildScheduledAuth(cfg ApplicationConfig) (*scheduledauth.Validator, error) {
if strings.TrimSpace(os.Getenv(scheduledauth.EnvAuthMode)) == "" {
return nil, fmt.Errorf(
"%w: %s must be explicitly set (use disabled only for local dev)",
scheduledauth.ErrConfigInvalid,
scheduledauth.EnvAuthMode,
)
}
saCfg, err := scheduledauth.LoadConfig(envSourceOS{})
if err != nil {
return nil, err
}
// In bearer mode, override the env-supplied secret with the one
// already resolved from KV / SM. LoadConfig reads SCHEDULED_TASK_SECRET
// directly which is fine for local dev where the env carries the
// plaintext, but in prod cfg.ScheduledTaskSecret is the authoritative
// value (resolved by resolveScheduledTaskSecret upstream).
if saCfg.Mode == scheduledauth.ModeBearer {
saCfg.Bearer = cfg.ScheduledTaskSecret
}
return scheduledauth.New(saCfg)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/app.go` around lines 295 - 308, buildScheduledAuth currently
treats a missing SCHEDULED_TASK_AUTH_MODE as "disabled" and ends up constructing
a permissive validator; change buildScheduledAuth to detect when LoadConfig
returns a missing/empty mode (or ModeDisabled) and return a non-nil error
instead of continuing, so the process fails startup on missing auth wiring.
Specifically, after scheduledauth.LoadConfig(envSourceOS{}) validate that
saCfg.Mode is set and not the default disabled sentinel (check against the empty
value or scheduledauth.ModeDisabled) before applying the ModeBearer secret
override and calling scheduledauth.New(saCfg); if the mode is missing/disabled
return an error explaining SCHEDULED_TASK_AUTH_MODE must be configured.

}

// NewApplicationFromDeps creates an Application from pre-built configuration and dependencies.
// This is the testable constructor - all external I/O is done before calling this.
func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps ExternalDeps) (*Application, error) {
Expand All @@ -276,6 +317,20 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext

cfg.ScheduledTaskSecret = resolveScheduledTaskSecret(ctx, cfg, deps.SecretResolver)

// Build the /api/scheduled/* auth validator. Fail-fast on bad
// config (empty subjects in oidc mode, unknown mode, etc.) — better
// to crash on startup than to silently accept unauthenticated
// scheduled-task calls in production.
scheduledAuth, err := buildScheduledAuth(cfg)
if err != nil {
return nil, fmt.Errorf("scheduled-task auth init: %w", err)
}
// Best-effort JWKS warmup — surfaces misconfiguration in startup
// logs without blocking startup if Google's CDN is unreachable.
warmCtx, warmCancel := context.WithTimeout(ctx, 5*time.Second)
scheduledAuth.Warmup(warmCtx)
warmCancel()

// Construct the OIDC issuer signer once per deployment. Nil means
// the deployment has not opted into the federated flow yet — all
// OIDC-dependent paths (handler_oidc.go, purchase manager Azure
Expand Down Expand Up @@ -381,6 +436,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext
secretResolver: deps.SecretResolver,
appConfig: cfg,
signer: signer,
scheduledAuth: scheduledAuth,
}, nil
}

Expand Down
6 changes: 6 additions & 0 deletions internal/server/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,12 @@ func TestLoadApplicationConfig(t *testing.T) {
func TestNewApplicationFromDeps(t *testing.T) {
ctx := testutil.TestContext(t)

// SCHEDULED_TASK_AUTH_MODE is required at startup (fail-closed default
// per CR pass-4 review on PR #161). Tests boot the app without
// scheduled-task wiring, so explicitly opt into "disabled" — the same
// thing local-dev tfvars do.
t.Setenv("SCHEDULED_TASK_AUTH_MODE", "disabled")

validDBConfig := &database.Config{
Host: "localhost",
Port: 5432,
Expand Down
36 changes: 22 additions & 14 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package server

import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
Expand All @@ -20,9 +19,13 @@ import (
func CreateHTTPServer(app *Application, port int) *http.Server {
mux := http.NewServeMux()

// Register health and scheduled task routes (always available)
// Register health and scheduled task routes (always available).
// /api/scheduled/* sits behind the scheduledauth middleware — see
// internal/server/scheduledauth. Mode is selected by
// SCHEDULED_TASK_AUTH_MODE: oidc on GCP, bearer on Azure, disabled
// for local dev.
mux.HandleFunc("/health", app.handleHealthCheck)
mux.HandleFunc("/api/scheduled/", app.handleScheduledHTTP)
mux.Handle("/api/scheduled/", app.scheduledAuthMiddleware(http.HandlerFunc(app.handleScheduledHTTP)))

// When STATIC_DIR is set, serve static files for non-API paths
// and route only /api/ to the API handler.
Expand Down Expand Up @@ -105,24 +108,29 @@ func (app *Application) handleHTTPRequest(w http.ResponseWriter, r *http.Request
lambdaResponseToHTTP(w, lambdaResp)
}

// scheduledAuthMiddleware returns the configured scheduledauth middleware,
// or a passthrough no-op if app.scheduledAuth is nil. Tests that build
// an Application directly (without NewApplicationFromDeps) leave it
// nil — they exercise the handler in isolation, with the middleware
// covered separately in scheduledauth's own tests.
func (app *Application) scheduledAuthMiddleware(next http.Handler) http.Handler {
if app.scheduledAuth == nil {
return next
}
return app.scheduledAuth.Middleware(next)
Comment on lines +116 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when scheduled auth has not been initialized.

Returning next here makes every /api/scheduled/* route public whenever an Application is built outside the production constructor. That is an auth bypass on the exact path this PR is hardening; tests should inject an explicit disabled validator instead of relying on a nil pass-through.

🔒 Suggested fail-closed guard
func (app *Application) scheduledAuthMiddleware(next http.Handler) http.Handler {
	if app.scheduledAuth == nil {
-		return next
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			http.Error(w, "Scheduled task auth is not configured", http.StatusServiceUnavailable)
+		})
	}
	return app.scheduledAuth.Middleware(next)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/http.go` around lines 116 - 120, The scheduledAuthMiddleware
currently returns next when app.scheduledAuth is nil which creates an auth
bypass; change scheduledAuthMiddleware to fail-closed: if app.scheduledAuth ==
nil return an http.Handler that rejects requests (e.g., respond with 401/403 and
short message) instead of returning next, and keep the normal path returning
app.scheduledAuth.Middleware(next); update tests to inject an explicit disabled
validator stub where they intend no auth rather than relying on nil.

}

// handleScheduledHTTP handles scheduled tasks via HTTP endpoint
// This is used by GCP Cloud Scheduler and Azure Logic Apps
// This is used by GCP Cloud Scheduler and Azure Logic Apps. Auth is
// enforced upstream by scheduledAuthMiddleware (see CreateHTTPServer);
// by the time we reach here the request is already authenticated for
// the configured mode.
func (app *Application) handleScheduledHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Verify shared secret for scheduled task authentication
if secret := app.appConfig.ScheduledTaskSecret; secret != "" {
provided := r.Header.Get("Authorization")
expected := "Bearer " + secret
if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}

ctx := r.Context()

// Ensure database connection is established (lazy initialization)
Expand Down
37 changes: 27 additions & 10 deletions internal/server/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/LeanerCloud/CUDly/internal/api"
"github.com/LeanerCloud/CUDly/internal/scheduler"
"github.com/LeanerCloud/CUDly/internal/server/scheduledauth"
"github.com/LeanerCloud/CUDly/internal/testutil"
"github.com/aws/aws-lambda-go/events"
)
Expand Down Expand Up @@ -216,20 +217,33 @@ func TestLambdaResponseToHTTP(t *testing.T) {
}

func TestHandleScheduledHTTP(t *testing.T) {
// newBearerValidator constructs a bearer-mode validator and fails
// the test on construction error — preferred over `panic` because
// failures stay scoped to the offending case.
newBearerValidator := func(t *testing.T, secret string) *scheduledauth.Validator {
t.Helper()
v, err := scheduledauth.New(scheduledauth.Config{
Mode: scheduledauth.ModeBearer,
Bearer: secret,
})
testutil.AssertNoError(t, err)
return v
}

tests := []struct {
name string
method string
path string
authHeader string
setupApp func(*Application)
setupApp func(*testing.T, *Application)
expectedStatus int
expectError bool
}{
{
name: "valid scheduled task",
method: "POST",
path: "/api/scheduled/collect_recommendations",
setupApp: func(app *Application) {
setupApp: func(_ *testing.T, app *Application) {
app.Scheduler = &testutil.MockScheduler{
CollectRecommendationsFunc: func(ctx context.Context) (*scheduler.CollectResult, error) {
return &scheduler.CollectResult{
Expand All @@ -246,24 +260,24 @@ func TestHandleScheduledHTTP(t *testing.T) {
name: "invalid method (GET instead of POST)",
method: "GET",
path: "/api/scheduled/collect_recommendations",
setupApp: func(app *Application) {},
setupApp: func(_ *testing.T, _ *Application) {},
expectedStatus: 405,
expectError: false,
},
{
name: "invalid path (missing task type)",
method: "POST",
path: "/api/scheduled/",
setupApp: func(app *Application) {},
setupApp: func(_ *testing.T, _ *Application) {},
expectedStatus: 400,
expectError: false,
},
{
name: "auth required but missing",
method: "POST",
path: "/api/scheduled/collect_recommendations",
setupApp: func(app *Application) {
app.appConfig.ScheduledTaskSecret = "my-secret"
setupApp: func(t *testing.T, app *Application) {
app.scheduledAuth = newBearerValidator(t, "my-secret")
},
expectedStatus: 401,
},
Expand All @@ -272,8 +286,8 @@ func TestHandleScheduledHTTP(t *testing.T) {
method: "POST",
path: "/api/scheduled/collect_recommendations",
authHeader: "Bearer my-secret",
setupApp: func(app *Application) {
app.appConfig.ScheduledTaskSecret = "my-secret"
setupApp: func(t *testing.T, app *Application) {
app.scheduledAuth = newBearerValidator(t, "my-secret")
app.Scheduler = &testutil.MockScheduler{
CollectRecommendationsFunc: func(ctx context.Context) (*scheduler.CollectResult, error) {
return &scheduler.CollectResult{}, nil
Expand All @@ -288,7 +302,7 @@ func TestHandleScheduledHTTP(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
app := &Application{}
if tt.setupApp != nil {
tt.setupApp(app)
tt.setupApp(t, app)
}

req := httptest.NewRequest(tt.method, tt.path, nil)
Expand All @@ -297,7 +311,10 @@ func TestHandleScheduledHTTP(t *testing.T) {
}
w := httptest.NewRecorder()

app.handleScheduledHTTP(w, req)
// Drive the request through the same middleware chain
// that CreateHTTPServer wires up, so auth is enforced
// upstream of handleScheduledHTTP.
app.scheduledAuthMiddleware(http.HandlerFunc(app.handleScheduledHTTP)).ServeHTTP(w, req)

testutil.AssertEqual(t, tt.expectedStatus, w.Code)

Expand Down
85 changes: 85 additions & 0 deletions internal/server/scheduledauth/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package scheduledauth

import (
"fmt"
"strings"
)

// EnvSource is the minimal interface NewFromEnv depends on. The default
// implementation (envOS) reads from os.Getenv; tests inject a map.
type EnvSource interface {
Get(string) string
}

// EnvMap is an EnvSource backed by a map. Useful in tests.
type EnvMap map[string]string

// Get returns the value for key, or "" if absent.
func (m EnvMap) Get(key string) string { return m[key] }

// Environment variable names. Centralised so tests and Terraform stay
// aligned with the Go code.
const (
EnvAuthMode = "SCHEDULED_TASK_AUTH_MODE" // "oidc" | "bearer" | "disabled"
EnvOIDCIssuer = "SCHEDULED_TASK_OIDC_ISSUER" // default GoogleIssuer
EnvOIDCJWKSURL = "SCHEDULED_TASK_OIDC_JWKS_URL" // default GoogleJWKSURL
EnvOIDCAudience = "SCHEDULED_TASK_OIDC_AUDIENCE" // comma-separated
EnvOIDCSubjects = "SCHEDULED_TASK_OIDC_SUBJECTS" // comma-separated; REQUIRED in oidc mode
EnvBearerSecret = "SCHEDULED_TASK_SECRET" // bearer mode only
)

// LoadConfig parses Config from env. SCHEDULED_TASK_AUTH_MODE MUST be
// set explicitly to one of "oidc", "bearer", or "disabled" — an unset
// value returns ErrConfigInvalid so a missed Terraform/Kubernetes env
// wiring fails server startup instead of silently booting
// /api/scheduled/* unauthenticated. "disabled" is reserved for an
// explicit local-dev choice and must be opted into deliberately.
func LoadConfig(env EnvSource) (Config, error) {
mode := strings.ToLower(strings.TrimSpace(env.Get(EnvAuthMode)))
if mode == "" {
return Config{}, fmt.Errorf("%w: %s is unset (set to oidc, bearer, or disabled)",
ErrConfigInvalid, EnvAuthMode)
}

cfg := Config{Mode: Mode(mode)}

switch cfg.Mode {
case ModeOIDC:
cfg.Issuer = strings.TrimSpace(env.Get(EnvOIDCIssuer))
cfg.JWKSURL = strings.TrimSpace(env.Get(EnvOIDCJWKSURL))
cfg.Audiences = splitCSV(env.Get(EnvOIDCAudience))
cfg.Subjects = splitCSV(env.Get(EnvOIDCSubjects))

case ModeBearer:
cfg.Bearer = env.Get(EnvBearerSecret)
// Permit other env vars to be set without complaint — the
// terraform module ships SCHEDULED_TASK_OIDC_* across all
// platforms and they should be ignored on Azure.

case ModeDisabled:
// Nothing to parse.

default:
return Config{}, fmt.Errorf("%w: %s=%q (expected oidc, bearer, or disabled)",
ErrConfigInvalid, EnvAuthMode, mode)
}

return cfg, nil
}

// splitCSV splits a comma-separated value into a trimmed list, dropping
// empty entries (so trailing commas / whitespace are tolerated).
func splitCSV(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
Loading