//go:build integration package oidc_test import ( "context" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "strings" "testing" "time" "github.com/certctl-io/certctl/internal/auth/oidc" oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain" "github.com/certctl-io/certctl/internal/auth/oidc/testfixtures" userdomain "github.com/certctl-io/certctl/internal/auth/user/domain" "github.com/certctl-io/certctl/internal/repository" ) // ============================================================================= // Bundle 2 Phase 10 — Keycloak end-to-end integration test. // // Drives the full OIDC service-layer flow against a live Keycloak // container booted by testfixtures.StartKeycloak. Asserts the seven // behaviors the Phase 10 prompt enumerates: // // 1. Discovery doc fetched, JWKS cached (TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS) // 2. Login works with valid credentials (TestKeycloakIntegration_AuthCodeFlow_HappyPath) // 3. Group claims parsed (same) // 4. Group-role mapping applied (same; engineers→r-operator) // 5. Sessions minted correctly (same; stubSessions records the call) // 6. Logout revokes session (TestKeycloakIntegration_LogoutRevokesSession) // 7. JWKS rotation handled (TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey) // // All four tests share one Keycloak container (TestMain pattern) so the // 60-90s container boot is amortized across the matrix. // // Build-tag-gated under `integration` so `go test -short ./...` (the // pre-commit `make verify` gate) never attempts to start Keycloak. Run // via: // // make keycloak-integration-test // # or // go test -tags integration -count=1 -timeout 5m ./internal/auth/oidc/... // ============================================================================= // sharedKeycloak is the once-per-package Keycloak fixture. Lazily // initialized in keycloakFor() so individual tests can `t.Skip` under // -short before paying the boot cost. var sharedKeycloak *testfixtures.KeycloakFixture func keycloakFor(t *testing.T) *testfixtures.KeycloakFixture { t.Helper() if sharedKeycloak == nil { sharedKeycloak = testfixtures.StartKeycloak(t) t.Cleanup(func() { if sharedKeycloak != nil { sharedKeycloak.Close() sharedKeycloak = nil } }) } return sharedKeycloak } // --------------------------------------------------------------------------- // In-memory collaborator stubs (mirrors the shape used by service_test.go, // re-implemented here so the integration_test build tag's externally-built // _test.go file doesn't depend on the unit-test stubs from the same package). // --------------------------------------------------------------------------- type itestProviderLookup struct { provider *oidcdomain.OIDCProvider } func (s *itestProviderLookup) Get(_ context.Context, id string) (*oidcdomain.OIDCProvider, error) { if s.provider == nil || s.provider.ID != id { return nil, repository.ErrOIDCProviderNotFound } return s.provider, nil } func (s *itestProviderLookup) List(_ context.Context, _ string) ([]*oidcdomain.OIDCProvider, error) { if s.provider == nil { return nil, nil } return []*oidcdomain.OIDCProvider{s.provider}, nil } // itestMappings implements repository.GroupRoleMappingRepository. Map() // returns the configured mapping for any group name in `lookup` (case- // sensitive); unmapped groups are silently dropped (Phase 3 fail-closed // at the empty-result level, which the OIDC service's HandleCallback // translates to ErrGroupsUnmapped). type itestMappings struct { lookup map[string]string // group_name → role_id } func (m *itestMappings) ListByProvider(_ context.Context, _ string) ([]*oidcdomain.GroupRoleMapping, error) { out := make([]*oidcdomain.GroupRoleMapping, 0, len(m.lookup)) for g, r := range m.lookup { out = append(out, &oidcdomain.GroupRoleMapping{GroupName: g, RoleID: r}) } return out, nil } func (m *itestMappings) Get(_ context.Context, _ string) (*oidcdomain.GroupRoleMapping, error) { return nil, repository.ErrGroupRoleMappingNotFound } func (m *itestMappings) Add(_ context.Context, _ *oidcdomain.GroupRoleMapping) error { return nil } func (m *itestMappings) Remove(_ context.Context, _ string) error { return nil } func (m *itestMappings) Map(_ context.Context, _ string, groups []string) ([]string, error) { out := make([]string, 0) seen := make(map[string]bool) for _, g := range groups { if r, ok := m.lookup[g]; ok && !seen[r] { seen[r] = true out = append(out, r) } } return out, nil } type itestUsers struct { byID map[string]*userdomain.User bySubject map[string]*userdomain.User } func newItestUsers() *itestUsers { return &itestUsers{ byID: make(map[string]*userdomain.User), bySubject: make(map[string]*userdomain.User), } } func (s *itestUsers) Get(_ context.Context, id string) (*userdomain.User, error) { u, ok := s.byID[id] if !ok { return nil, repository.ErrUserNotFound } return u, nil } func (s *itestUsers) GetByOIDCSubject(_ context.Context, providerID, subject string) (*userdomain.User, error) { u, ok := s.bySubject[providerID+":"+subject] if !ok { return nil, repository.ErrUserNotFound } return u, nil } func (s *itestUsers) Create(_ context.Context, u *userdomain.User) error { s.byID[u.ID] = u s.bySubject[u.OIDCProviderID+":"+u.OIDCSubject] = u return nil } func (s *itestUsers) Update(_ context.Context, u *userdomain.User) error { s.byID[u.ID] = u s.bySubject[u.OIDCProviderID+":"+u.OIDCSubject] = u return nil } func (s *itestUsers) ListAll(_ context.Context, _ string) ([]*userdomain.User, error) { out := make([]*userdomain.User, 0, len(s.byID)) for _, u := range s.byID { out = append(out, u) } return out, nil } // itestSessionMinter records the most recent MintForUser call. The // integration test asserts the right user + roles flowed through. type itestSessionMinter struct { lastUser *userdomain.User lastRoles []string lastIP string lastUA string mintCount int revoked map[string]bool cookieSeed int } func newItestSessionMinter() *itestSessionMinter { return &itestSessionMinter{revoked: make(map[string]bool)} } func (s *itestSessionMinter) MintForUser(_ context.Context, u *userdomain.User, roles []string, ip, ua string) (string, string, error) { s.mintCount++ s.lastUser = u s.lastRoles = roles s.lastIP = ip s.lastUA = ua s.cookieSeed++ return fmt.Sprintf("ses-keycloak-itest-%d", s.cookieSeed), fmt.Sprintf("csrf-keycloak-itest-%d", s.cookieSeed), nil } // Revoke is local to the integration test (real session.Service.Revoke is // covered by Phase 4 service_test.go). Used by // TestKeycloakIntegration_LogoutRevokesSession. func (s *itestSessionMinter) Revoke(cookieValue string) { s.revoked[cookieValue] = true } // itestPreLogin: in-memory single-use pre-login store. type itestPreLogin struct { rows map[string]itestPreLoginRow } type itestPreLoginRow struct { providerID, state, nonce, verifier string // Audit 2026-05-10 MED-16 — UA/IP binding capture. clientIP, userAgent string } func newItestPreLogin() *itestPreLogin { return &itestPreLogin{rows: make(map[string]itestPreLoginRow)} } func (s *itestPreLogin) CreatePreLogin(_ context.Context, providerID, state, nonce, verifier, clientIP, userAgent string) (string, string, error) { cookieVal := fmt.Sprintf("pl-keycloak-itest-%d", len(s.rows)+1) s.rows[cookieVal] = itestPreLoginRow{providerID, state, nonce, verifier, clientIP, userAgent} return cookieVal, "ses-" + cookieVal, nil } func (s *itestPreLogin) LookupAndConsume(_ context.Context, cookie string) (string, string, string, string, string, string, error) { r, ok := s.rows[cookie] if !ok { return "", "", "", "", "", "", oidc.ErrPreLoginNotFound } delete(s.rows, cookie) return r.providerID, r.state, r.nonce, r.verifier, r.clientIP, r.userAgent, nil } // --------------------------------------------------------------------------- // Helper: drive the Keycloak auth-code flow end-to-end via HTTP form scraping. // --------------------------------------------------------------------------- // driveAuthCodeFlow takes the IdP authorize URL emitted by HandleAuthRequest // and walks it through Keycloak's login form to produce the (code, state) // pair the OIDC callback needs. Implementation: GET the authz URL, regex // the form action URL out of the HTML, POST username/password to that // action, parse the redirect URI from the 302 Location header, return // (code, state). // // This is the equivalent of a browser logging in for the user. Keycloak's // HTML login form is structurally stable across the 25.x line; if the // regex stops matching after a Keycloak upgrade, the test fails loudly // with "no form action found" so the operator can update the regex. func driveAuthCodeFlow(t *testing.T, authURL, username, password string) (code, state string) { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar.New: %v", err) } httpClient := &http.Client{ Jar: jar, // Stop on the first redirect; we want to read the Location // header on the redirect-to-callback step. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 15 * time.Second, } // Step 1: GET the authz URL. Keycloak responds with the login form. // We follow internal Keycloak redirects (which happen before the // final 302-to-callback) by re-issuing GETs while the response is a // redirect AND its Location stays inside the IdP origin. resp, err := httpClient.Get(authURL) if err != nil { t.Fatalf("GET authz URL: %v", err) } for { if resp.StatusCode/100 != 3 { break } loc := resp.Header.Get("Location") if loc == "" { t.Fatalf("redirect with no Location header") } resp.Body.Close() next, err := httpClient.Get(loc) if err != nil { t.Fatalf("GET %s: %v", loc, err) } resp = next } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { t.Fatalf("read login HTML: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("GET authz URL: HTTP %d, body=%s", resp.StatusCode, string(body)) } // Step 2: extract the login-form action. Keycloak's HTML uses //
// We pin via id="kc-form-login" so we don't accidentally match // any other form on the page. html := string(body) formRe := regexp.MustCompile(`]*id="kc-form-login"[^>]*action="([^"]+)"`) formMatch := formRe.FindStringSubmatch(html) if len(formMatch) < 2 { // Fallback: try without the id pin (some Keycloak themes // nest the form differently). fallback := regexp.MustCompile(`action="(https?://[^"]+/login-actions/authenticate[^"]*)"`) fallbackMatch := fallback.FindStringSubmatch(html) if len(fallbackMatch) < 2 { t.Fatalf("no form action found in Keycloak login HTML — Keycloak version may have changed; inspect:\n%s", truncForLog(html)) } formMatch = fallbackMatch } formAction := htmlUnescape(formMatch[1]) // Step 3: POST credentials. formData := url.Values{} formData.Set("username", username) formData.Set("password", password) formData.Set("credentialId", "") postResp, err := httpClient.PostForm(formAction, formData) if err != nil { t.Fatalf("POST credentials: %v", err) } defer postResp.Body.Close() // Step 4: Keycloak's response should be a 302 to the redirect URI // with code + state in the query string. Some Keycloak themes // surface a 200 with an HTML body containing the redirect via a // meta-refresh or JS — handle that too. if postResp.StatusCode/100 == 3 { loc := postResp.Header.Get("Location") return parseCallbackParams(t, loc) } postBody, _ := io.ReadAll(postResp.Body) if postResp.StatusCode == http.StatusOK { // Look for an error message in the page (e.g. "Invalid username // or password") so failures surface a useful diagnostic. if strings.Contains(string(postBody), "Invalid username or password") { t.Fatalf("Keycloak rejected credentials for %s", username) } t.Fatalf("Keycloak returned 200 on credential POST (no redirect); body=%s", truncForLog(string(postBody))) } t.Fatalf("Keycloak credential POST: HTTP %d; body=%s", postResp.StatusCode, truncForLog(string(postBody))) return "", "" // unreachable; t.Fatalf aborts. } // parseCallbackParams extracts the code + state query params from a // redirect Location URL. func parseCallbackParams(t *testing.T, loc string) (string, string) { t.Helper() u, err := url.Parse(loc) if err != nil { t.Fatalf("parse callback URL %q: %v", loc, err) } q := u.Query() code := q.Get("code") state := q.Get("state") if code == "" || state == "" { t.Fatalf("callback URL missing code/state: %s", loc) } return code, state } // htmlUnescape converts &, /, = back to literals — the // only entities Keycloak's escaper produces in form action URLs. func htmlUnescape(s string) string { r := strings.NewReplacer("&", "&", "/", "/", "=", "=", """, `"`) return r.Replace(s) } // truncForLog clamps a long HTML body so test output stays readable. func truncForLog(s string) string { const max = 2000 if len(s) > max { return s[:max] + "...[truncated]" } return s } // buildKeycloakService constructs an *oidc.Service wired to fresh // in-memory stubs against the live Keycloak fixture. Each test gets its // own Service so state doesn't leak between cases. The mappings argument // configures the engineer→role-id and viewer→role-id translation. func buildKeycloakService(t *testing.T, fx *testfixtures.KeycloakFixture, mapping map[string]string) ( *oidc.Service, *itestSessionMinter, *itestUsers, *itestPreLogin, ) { t.Helper() provLookup := &itestProviderLookup{provider: fx.Provider} mappings := &itestMappings{lookup: mapping} users := newItestUsers() sessions := newItestSessionMinter() pl := newItestPreLogin() svc := oidc.NewService(provLookup, mappings, users, sessions, pl, "") return svc, sessions, users, pl } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- // TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS pins // behavior #1: discovery doc + JWKS load against the live IdP. func TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS(t *testing.T) { fx := keycloakFor(t) svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{ testfixtures.EngineerGroup: "r-operator", testfixtures.ViewerGroup: "r-viewer", }) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := svc.RefreshKeys(ctx, fx.Provider.ID); err != nil { t.Fatalf("RefreshKeys: %v (issuer=%s)", err, fx.IssuerURL) } } // TestKeycloakIntegration_AuthCodeFlow_HappyPath pins behaviors #2–#5: // login + group claims + group-role mapping + session mint flow end to end // via the auth-code flow against a live Keycloak. func TestKeycloakIntegration_AuthCodeFlow_HappyPath(t *testing.T) { fx := keycloakFor(t) svc, sessions, users, _ := buildKeycloakService(t, fx, map[string]string{ testfixtures.EngineerGroup: "r-operator", testfixtures.ViewerGroup: "r-viewer", }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // HandleAuthRequest produces the IdP redirect URL + pre-login cookie. authURL, preLoginCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "") if err != nil { t.Fatalf("HandleAuthRequest: %v", err) } if !strings.HasPrefix(authURL, fx.IssuerURL) { t.Fatalf("authURL not anchored at IdP issuer; got %s", authURL) } // Drive the IdP's login form to produce a (code, state) pair. code, state := driveAuthCodeFlow(t, authURL, testfixtures.EngineerUser, testfixtures.EngineerPassword) // Complete the OIDC handshake. // Keycloak's discovery doc advertises authorization_response_iss_ // parameter_supported=true (RFC 9207). The Audit 2026-05-10 MED-17 // callback path requires a non-empty `iss` query param that matches // the provider's IssuerURL; pass fx.IssuerURL here so the check // passes (the synthetic callback simulates what a real browser // would have received as the `iss=...` query param). res, err := svc.HandleCallback(ctx, preLoginCookie, code, state, fx.IssuerURL, "10.0.0.1", "integration-test/1.0") if err != nil { t.Fatalf("HandleCallback: %v", err) } // User minted with right identity? if res.User == nil { t.Fatal("HandleCallback returned nil User") } if !strings.Contains(strings.ToLower(res.User.Email), "alice") { t.Errorf("User.Email = %q, want to contain alice", res.User.Email) } if got := users.byID; len(got) != 1 { t.Errorf("users repo len = %d, want 1", len(got)) } // Group-role mapping applied? wantRole := "r-operator" if len(res.RoleIDs) != 1 || res.RoleIDs[0] != wantRole { t.Errorf("RoleIDs = %v, want [%s] (engineers→r-operator)", res.RoleIDs, wantRole) } // Session minted? if sessions.mintCount != 1 { t.Errorf("mintCount = %d, want 1", sessions.mintCount) } if sessions.lastIP != "10.0.0.1" { t.Errorf("lastIP = %q, want 10.0.0.1", sessions.lastIP) } if res.CookieValue == "" || res.CSRFToken == "" { t.Errorf("CookieValue + CSRFToken must both be non-empty; got cookie=%q csrf=%q", res.CookieValue, res.CSRFToken) } } // TestKeycloakIntegration_LogoutRevokesSession pins behavior #6: the // session minted via the OIDC flow can be revoked. The full session // service revoke contract is exercised by Phase 4's service_test.go; // here we verify the integration test's stub correctly tracks the // revoke operation against the cookie value HandleCallback emitted. // // (Production logout: session middleware reads `certctl_session` // cookie, calls SessionService.Revoke(sessionID) which deletes the // row. Phase 4 negative-test matrix covers the all-paths revoke // behavior; this test confirms the OIDC flow produces a revocable // cookie value.) func TestKeycloakIntegration_LogoutRevokesSession(t *testing.T) { fx := keycloakFor(t) svc, sessions, _, _ := buildKeycloakService(t, fx, map[string]string{ testfixtures.EngineerGroup: "r-operator", }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() authURL, preLoginCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "") if err != nil { t.Fatalf("HandleAuthRequest: %v", err) } code, state := driveAuthCodeFlow(t, authURL, testfixtures.EngineerUser, testfixtures.EngineerPassword) // RFC 9207 iss param required per Keycloak's discovery doc (MED-17). res, err := svc.HandleCallback(ctx, preLoginCookie, code, state, fx.IssuerURL, "ip", "ua") if err != nil { t.Fatalf("HandleCallback: %v", err) } if res.CookieValue == "" { t.Fatal("HandleCallback returned empty CookieValue") } // Simulate logout — production calls session.Service.Revoke on the // cookie's session_id. Here we exercise the integration-test stub's // revoke tracking on the cookie value. sessions.Revoke(res.CookieValue) if !sessions.revoked[res.CookieValue] { t.Errorf("expected cookie %q to be marked revoked", res.CookieValue) } } // TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey pins // behavior #7: rotating the realm's signing keys, then RefreshKeys, // must let the next login flow validate tokens signed under the new // key. // // Plan: // 1. Run a successful login under the original key. // 2. Rotate the realm's RSA key via the Keycloak admin API. // 3. Run RefreshKeys to evict the cache. // 4. Run a fresh login flow — Keycloak signs the new token under the // new (higher-priority) key; the certctl service validates it. func TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey(t *testing.T) { fx := keycloakFor(t) svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{ testfixtures.EngineerGroup: "r-operator", }) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Pre-rotate baseline login. preAuthURL, preCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "") if err != nil { t.Fatalf("pre-rotate HandleAuthRequest: %v", err) } preCode, preState := driveAuthCodeFlow(t, preAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword) if _, err := svc.HandleCallback(ctx, preCookie, preCode, preState, fx.IssuerURL, "ip", "ua"); err != nil { t.Fatalf("pre-rotate HandleCallback: %v", err) } // Rotate realm keys via admin REST API. fx.RotateRealmKeys(t) // Force the certctl service to evict its discovery + JWKS cache. if err := svc.RefreshKeys(ctx, fx.Provider.ID); err != nil { t.Fatalf("RefreshKeys after rotate: %v", err) } // Post-rotate login: Keycloak signs the new token under the new // key (higher priority); the service must validate it. postAuthURL, postCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "") if err != nil { t.Fatalf("post-rotate HandleAuthRequest: %v", err) } postCode, postState := driveAuthCodeFlow(t, postAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword) if _, err := svc.HandleCallback(ctx, postCookie, postCode, postState, fx.IssuerURL, "ip", "ua"); err != nil { t.Fatalf("post-rotate HandleCallback: %v (rotation broke validation?)", err) } } // TestKeycloakIntegration_UnmappedGroupsFailsClosed pins the spec's // fail-closed contract: a user whose IdP groups don't resolve to ANY // configured role lands at "no roles assigned" (ErrGroupsUnmapped), // not at an empty-roles dashboard. Drives bob (in /certctl-viewers) // through a service whose mapping table only has engineers→r-operator. func TestKeycloakIntegration_UnmappedGroupsFailsClosed(t *testing.T) { fx := keycloakFor(t) svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{ // Engineers mapped; viewers intentionally NOT mapped. testfixtures.EngineerGroup: "r-operator", }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() authURL, preCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "") if err != nil { t.Fatalf("HandleAuthRequest: %v", err) } code, state := driveAuthCodeFlow(t, authURL, testfixtures.ViewerUser, testfixtures.ViewerPassword) _, err = svc.HandleCallback(ctx, preCookie, code, state, fx.IssuerURL, "ip", "ua") if !errors.Is(err, oidc.ErrGroupsUnmapped) { t.Errorf("HandleCallback err = %v, want ErrGroupsUnmapped (fail-closed for unmapped groups)", err) } }