diff --git a/internal/cluster/orchestrator_service_auth.go b/internal/cluster/orchestrator_service_auth.go index 537055e..81f7845 100644 --- a/internal/cluster/orchestrator_service_auth.go +++ b/internal/cluster/orchestrator_service_auth.go @@ -6,10 +6,12 @@ import ( "encoding/base64" "encoding/json" "fmt" + "html" "io" "net/http" "net/http/cookiejar" neturl "net/url" + "regexp" "strings" "time" @@ -32,15 +34,14 @@ type kubernetesSecret struct { Data map[string]string `json:"data"` } +var keycloakLoginFormActionPattern = regexp.MustCompile(`(?is)]*id=["']kc-form-login["'][^>]*action=["']([^"']+)["']`) + // checklistAuthHTTPClient runs one orchestration or CLI step. // Signature: (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error). // Why: startup checklist checks that require real user behavior need an // authenticated robotuser browser-like session before probing service pages. func (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("create cookie jar: %w", err) - } + jar, _ := cookiejar.New(nil) transport := &http.Transport{} if insecureSkipTLS { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} @@ -74,6 +75,9 @@ func (o *Orchestrator) authenticateRobotChecklistSession(ctx context.Context, cl if err != nil { return err } + if err := o.keycloakAdminBrowserLogin(ctx, client, auth, adminUser, adminPassword); err != nil { + return fmt.Errorf("initialize keycloak admin browser session: %w", err) + } adminToken, err := o.keycloakAdminToken(ctx, client, auth, adminUser, adminPassword) if err != nil { return err @@ -101,6 +105,74 @@ func (o *Orchestrator) authenticateRobotChecklistSession(ctx context.Context, cl } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = strings.TrimSpace(resp.Request.URL.String()) + } + if strings.Contains(finalURL, "/protocol/openid-connect/auth") || strings.Contains(finalURL, "/login-actions/authenticate") { + return fmt.Errorf("robot session bootstrap ended on keycloak login flow: %s", finalURL) + } + return nil +} + +// keycloakAdminBrowserLogin runs one orchestration or CLI step. +// Signature: (o *Orchestrator) keycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error. +// Why: Keycloak impersonation only yields a usable robot session cookie when the +// client already has a real admin browser session; token-only API calls are not +// sufficient for downstream OIDC-gated service checks. +func (o *Orchestrator) keycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error { + baseURL := keycloakBaseURL(auth) + authURL := baseURL + "/realms/master/protocol/openid-connect/auth?" + keycloakAdminConsoleAuthQuery(baseURL).Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) + if err != nil { + return fmt.Errorf("build keycloak admin auth request: %w", err) + } + req.Header.Set("User-Agent", "ananke/startup-checklist") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request keycloak admin auth page: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if resp.StatusCode/100 != 2 { + return fmt.Errorf("keycloak admin auth page request failed status=%d body=%q", resp.StatusCode, compactHTTPBody(body)) + } + actionURL, err := keycloakLoginFormAction(string(body), baseURL) + if err != nil { + return err + } + + form := neturl.Values{} + form.Set("username", adminUser) + form.Set("password", adminPassword) + form.Set("credentialId", "") + + loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, actionURL, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("build keycloak admin login request: %w", err) + } + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginReq.Header.Set("User-Agent", "ananke/startup-checklist") + + loginResp, err := client.Do(loginReq) + if err != nil { + return fmt.Errorf("request keycloak admin login submit: %w", err) + } + defer loginResp.Body.Close() + loginBody, _ := io.ReadAll(io.LimitReader(loginResp.Body, 512*1024)) + finalURL := "" + if loginResp.Request != nil && loginResp.Request.URL != nil { + finalURL = strings.TrimSpace(loginResp.Request.URL.String()) + } + if loginResp.StatusCode >= 500 { + return fmt.Errorf("keycloak admin login failed status=%d body=%q", loginResp.StatusCode, compactHTTPBody(loginBody)) + } + if strings.Contains(finalURL, "/login-actions/authenticate") || strings.Contains(finalURL, "/protocol/openid-connect/auth") { + return fmt.Errorf("keycloak admin login did not complete (final_url=%q)", finalURL) + } + if strings.Contains(strings.ToLower(string(loginBody)), "kc-form-login") { + return fmt.Errorf("keycloak admin login form still present after submit") + } return nil } @@ -273,6 +345,42 @@ func keycloakBaseURL(auth config.ServiceChecklistAuthSettings) string { return strings.TrimRight(strings.TrimSpace(auth.KeycloakBaseURL), "/") } +// keycloakAdminConsoleAuthQuery runs one orchestration or CLI step. +// Signature: keycloakAdminConsoleAuthQuery() neturl.Values. +// Why: centralizes required Keycloak admin-console auth parameters, including +// PKCE fields required by current Keycloak defaults. +func keycloakAdminConsoleAuthQuery(baseURL string) neturl.Values { + query := neturl.Values{} + query.Set("client_id", "security-admin-console") + query.Set("redirect_uri", strings.TrimRight(strings.TrimSpace(baseURL), "/")+"/admin/master/console/") + query.Set("response_type", "code") + query.Set("scope", "openid") + query.Set("state", "ananke-startup-checklist") + query.Set("nonce", "ananke-startup-checklist") + query.Set("code_challenge_method", "S256") + query.Set("code_challenge", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + return query +} + +// keycloakLoginFormAction runs one orchestration or CLI step. +// Signature: keycloakLoginFormAction(page string, baseURL string) (string, error). +// Why: Keycloak's login form action carries session-bound query params and must +// be parsed from the rendered page before posting credentials. +func keycloakLoginFormAction(page string, baseURL string) (string, error) { + matches := keycloakLoginFormActionPattern.FindStringSubmatch(page) + if len(matches) < 2 || strings.TrimSpace(matches[1]) == "" { + return "", fmt.Errorf("keycloak login page missing kc-form-login action") + } + action := html.UnescapeString(strings.TrimSpace(matches[1])) + if strings.HasPrefix(action, "/") { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + action, nil + } + if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") { + return action, nil + } + return "", fmt.Errorf("keycloak login action uses unsupported format %q", action) +} + // compactHTTPBody runs one orchestration or CLI step. // Signature: compactHTTPBody(raw []byte) string. // Why: checklist auth errors should include a readable body summary without diff --git a/internal/cluster/testing_hooks_auth.go b/internal/cluster/testing_hooks_auth.go index 68a1fb0..e47ba43 100644 --- a/internal/cluster/testing_hooks_auth.go +++ b/internal/cluster/testing_hooks_auth.go @@ -57,6 +57,13 @@ func (o *Orchestrator) TestHookKeycloakImpersonationRedirect(ctx context.Context return o.keycloakImpersonationRedirect(ctx, client, auth, adminToken, robotUserID) } +// TestHookKeycloakAdminBrowserLogin runs one orchestration or CLI step. +// Signature: (o *Orchestrator) TestHookKeycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error. +// Why: exposes browser-session bootstrap internals to top-level tests. +func (o *Orchestrator) TestHookKeycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error { + return o.keycloakAdminBrowserLogin(ctx, client, auth, adminUser, adminPassword) +} + // TestHookHTTPChecklistProbeWithLocation runs one orchestration or CLI step. // Signature: (o *Orchestrator) TestHookHTTPChecklistProbeWithLocation(ctx context.Context, check config.ServiceChecklistCheck) (int, string, string, string, error). // Why: exposes redirect-aware checklist probe internals to top-level tests. @@ -77,3 +84,10 @@ func TestHookKeycloakBaseURL(auth config.ServiceChecklistAuthSettings) string { func TestHookCompactHTTPBody(raw []byte) string { return compactHTTPBody(raw) } + +// TestHookKeycloakLoginFormAction runs one orchestration or CLI step. +// Signature: TestHookKeycloakLoginFormAction(page string, baseURL string) (string, error). +// Why: exposes login-form action parser internals to top-level tests. +func TestHookKeycloakLoginFormAction(page string, baseURL string) (string, error) { + return keycloakLoginFormAction(page, baseURL) +} diff --git a/testing/orchestrator/hooks_service_auth_matrix_test.go b/testing/orchestrator/hooks_service_auth_matrix_test.go index 0753ff5..148bc1f 100644 --- a/testing/orchestrator/hooks_service_auth_matrix_test.go +++ b/testing/orchestrator/hooks_service_auth_matrix_test.go @@ -36,6 +36,26 @@ func authSettings(baseURL string) config.ServiceChecklistAuthSettings { } } +func serveMockKeycloakAdminBrowserFlow(w http.ResponseWriter, r *http.Request) bool { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte( + `
`, + )) + return true + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + http.Redirect(w, r, "/admin/master/console/", http.StatusFound) + return true + case strings.Contains(r.URL.Path, "/admin/master/console/"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("console ok")) + return true + default: + return false + } +} + // TestHookServiceAuthChecklistSuccess runs one orchestration or CLI step. // Signature: TestHookServiceAuthChecklistSuccess(t *testing.T). // Why: validates full robotuser-authenticated checklist flow with final URL and @@ -86,6 +106,21 @@ func TestHookServiceAuthChecklistSuccess(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(fmt.Sprintf(`{"redirect":"%s/session/bootstrap"}`, appServer.URL))) }) + kcMux.HandleFunc("/realms/master/protocol/openid-connect/auth", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/realms/master/login-actions/authenticate", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/admin/master/console/", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) kcServer := httptest.NewTLSServer(kcMux) defer kcServer.Close() @@ -343,6 +378,122 @@ func TestHookServiceAuthHTTPErrorBranches(t *testing.T) { } } +// TestHookKeycloakAdminBrowserLoginAndParserBranches runs one orchestration or CLI step. +// Signature: TestHookKeycloakAdminBrowserLoginAndParserBranches(t *testing.T). +// Why: covers admin-browser bootstrap and form-action parser branches that +// drive robotuser-authenticated startup checks. +func TestHookKeycloakAdminBrowserLoginAndParserBranches(t *testing.T) { + cfg := lifecycleConfig(t) + orch, _ := newHookOrchestrator(t, cfg, nil, nil) + client := &http.Client{Timeout: 2 * time.Second} + + t.Run("invalid base url build failure", func(t *testing.T) { + auth := authSettings("://bad-url") + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, auth, "admin", "pw"); err == nil { + t.Fatalf("expected browser login request-build failure") + } + }) + + t.Run("auth page non-2xx", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("bad gateway")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected non-2xx auth page failure") + } + }) + + t.Run("login submit request failure", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`
`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login submit request failure") + } + }) + + t.Run("build login request failure", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`
`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login request-build failure") + } + }) + + t.Run("login submit returns 5xx", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`
`)) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login 5xx branch") + } + }) + + t.Run("login success path", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + _, _ = w.Write([]byte(`
`)) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + http.Redirect(w, r, "/admin/master/console/", http.StatusFound) + case strings.Contains(r.URL.Path, "/admin/master/console/"): + _, _ = w.Write([]byte("console ok")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err != nil { + t.Fatalf("expected browser login success, got %v", err) + } + }) + + t.Run("form action parser absolute and invalid", func(t *testing.T) { + action, err := cluster.TestHookKeycloakLoginFormAction( + `
`, + "https://sso.bstein.dev", + ) + if err != nil || action != "https://example.test/login" { + t.Fatalf("expected absolute action parse success, action=%q err=%v", action, err) + } + if _, err := cluster.TestHookKeycloakLoginFormAction( + `
`, + "https://sso.bstein.dev", + ); err == nil { + t.Fatalf("expected unsupported action format failure") + } + }) +} + // TestHookServiceChecklistProbeBranches runs one orchestration or CLI step. // Signature: TestHookServiceChecklistProbeBranches(t *testing.T). // Why: exercises redirect + final-url probe branches, including robot-auth @@ -403,6 +554,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("robot-user lookup failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -424,6 +578,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("impersonation failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -447,6 +604,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("redirect url build failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -469,6 +629,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("redirect request failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -488,6 +651,79 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Fatalf("expected redirect request failure branch") } }) + + t.Run("admin login page missing form action", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("no form")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected admin login form parse failure branch") + } + }) + + t.Run("admin login remains on login form", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte( + `
`, + )) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`
`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected admin login failure branch when login form persists") + } + }) + + t.Run("robot bootstrap ends on keycloak login flow", func(t *testing.T) { + baseURL := "" + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + switch { + case strings.Contains(r.URL.Path, "/token"): + _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) + case strings.Contains(r.URL.Path, "/users"): + _, _ = w.Write([]byte(`[{"id":"robot-id"}]`)) + case strings.Contains(r.URL.Path, "/impersonation"): + _, _ = w.Write([]byte(fmt.Sprintf(`{"redirect":"%s/realms/atlas/protocol/openid-connect/auth?client_id=logs"}`, baseURL))) + case strings.Contains(r.URL.Path, "/realms/atlas/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`
`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + baseURL = kc.URL + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected robot bootstrap final-url auth-flow failure") + } + }) } // TestHookServiceAuthFallbackRedirect runs one orchestration or CLI step. @@ -512,6 +748,21 @@ func TestHookServiceAuthFallbackRedirect(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("account ok")) }) + kcMux.HandleFunc("/realms/master/protocol/openid-connect/auth", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/realms/master/login-actions/authenticate", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/admin/master/console/", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) kcServer := httptest.NewTLSServer(kcMux) defer kcServer.Close()