//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 //