Serve legacy 3-segment OIDC SSO login and callback paths

An OIDC provider upgraded from v5 keeps its v5 redirect URL
(/api/oidc/callback, the DefaultOIDCCallbackPath), so the IdP redirects
the browser back to the 3-segment legacy path carrying only code and
state, with no session cookie and no API token yet. The global auth
middleware only marked the 4-segment per-provider path public, so in
API-token-only mode (AuthUser=="" && AuthPass=="" && HasAPITokens())
the callback was rejected with "API token required via Authorization
header or X-API-Token header", and the route dispatcher 404ed the same
path.

The handler layer (extractOIDCProviderID) already maps the legacy
/api/oidc/login and /api/oidc/callback paths to the migrated legacy
provider, so only the public-path allowlist and the route dispatcher
needed to recognise the 3-segment form. Adds a regression test covering
both legacy paths in API-token-only mode, the exact configuration that
emitted the error.

Refs #1533
This commit is contained in:
rcourtman 2026-07-08 08:15:03 +01:00
parent 74131e56e3
commit 590f8c34db
5 changed files with 74 additions and 4974 deletions

View file

@ -4031,11 +4031,17 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
}
// Per-provider SSO OIDC routes are public (login initiation + callback)
// Per-provider SSO OIDC routes are public (login initiation + callback).
// The legacy v5 single-provider paths /api/oidc/login and
// /api/oidc/callback stay public too: an upgraded provider keeps its v5
// redirect URL, so the IdP redirects the browser back to the 3-part path
// carrying only the code/state, with no session cookie or API token yet.
if strings.HasPrefix(normalizedPath, "/api/oidc/") {
oidcParts := strings.Split(strings.TrimPrefix(normalizedPath, "/"), "/")
if len(oidcParts) >= 4 && (oidcParts[3] == "login" || oidcParts[3] == "callback") {
isPublic = true
} else if len(oidcParts) == 3 && (oidcParts[2] == "login" || oidcParts[2] == "callback") {
isPublic = true
}
}

View file

@ -38,14 +38,23 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
// Use a prefix handler since Go 1.x ServeMux doesn't support path params.
// Requests matching /api/oidc/{something}/ are dispatched here.
r.mux.HandleFunc("/api/oidc/", func(w http.ResponseWriter, req *http.Request) {
// Determine which sub-endpoint was requested
// Determine which sub-endpoint was requested.
parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
// Expected: ["api", "oidc", "{providerID}", "{endpoint}"]
if len(parts) < 4 {
// Per-provider: ["api", "oidc", "{providerID}", "{endpoint}"]
// Legacy v5: ["api", "oidc", "{endpoint}"] -> mapped to the migrated
// legacy provider by handleSSOOIDC*. An upgraded provider
// keeps its v5 redirect URL (/api/oidc/callback), so the IdP
// still redirects the browser to the 3-part path.
endpoint := ""
switch {
case len(parts) >= 4:
endpoint = parts[3]
case len(parts) == 3:
endpoint = parts[2]
default:
http.NotFound(w, req)
return
}
endpoint := parts[3]
switch endpoint {
case "login":
r.handleSSOOIDCLogin(w, req)

View file

@ -4478,6 +4478,60 @@ func TestSSOOIDCCallbackBypassesAuth(t *testing.T) {
}
}
// TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly reproduces the v5->v6 upgrade
// bug from issue #1533. An upgraded OIDC provider keeps its v5 redirect URL
// (/api/oidc/callback, the 3-segment DefaultOIDCCallbackPath), so the IdP
// redirects the browser back to the legacy path with only code/state, no
// session cookie and no API token. In API-token-only mode
// (AuthUser=="" && AuthPass=="" && HasAPITokens()) the global auth middleware
// used to reject that inbound redirect with
// "API token required via Authorization header or X-API-Token header"
// because the public-path allowlist only recognised the 4-segment
// per-provider path. The legacy login and callback paths must bypass auth and
// reach handleSSOOIDC*, which map them to the migrated legacy provider.
func TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly(t *testing.T) {
// Token-only mode: this is the exact config that emits the reported error.
record := newTokenRecord(t, "legacy-oidc-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
if cfg.AuthUser != "" || cfg.AuthPass != "" || !cfg.HasAPITokens() {
t.Fatalf("test setup must reproduce token-only mode: authUser=%q authPass set=%v hasTokens=%v", cfg.AuthUser, cfg.AuthPass != "", cfg.HasAPITokens())
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
// No legacy provider is configured, so the callback handler redirects with a
// provider_not_found error (302) and the login handler returns 404. Either
// way the request reached the handler, proving auth was bypassed. Before the
// fix both returned 401 with the API-token error.
cases := []struct {
name string
path string
clientIP string
wantCode int
}{
{"legacy callback", "/api/oidc/callback?code=abc&state=xyz", "203.0.113.61", http.StatusFound},
{"legacy login", "/api/oidc/login", "203.0.113.62", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ResetRateLimitForIP(tc.clientIP)
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req.RemoteAddr = tc.clientIP + ":1234"
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code == http.StatusUnauthorized {
t.Fatalf("legacy OIDC path %q was rejected by API-token auth (401): %s", tc.path, strings.TrimSpace(rec.Body.String()))
}
if strings.Contains(rec.Body.String(), "API token required") {
t.Fatalf("legacy OIDC path %q returned the API-token error body: %s", tc.path, strings.TrimSpace(rec.Body.String()))
}
if rec.Code != tc.wantCode {
t.Fatalf("legacy OIDC path %q: expected status %d (auth bypassed, reached handler), got %d", tc.path, tc.wantCode, rec.Code)
}
})
}
}
func TestAIOAuthCallbackBypassesAuth(t *testing.T) {
cfg := newTestConfigWithTokens(t)
cfg.AuthUser = "admin"