diff --git a/Makefile b/Makefile index cf61d5d..fce000f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run test lint verify verify-docs verify-deploy loadtest acme-cert-manager-test acme-rfc-conformance-test clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats +.PHONY: help build run test lint verify verify-docs verify-deploy loadtest acme-cert-manager-test acme-rfc-conformance-test keycloak-integration-test okta-smoke-test clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats # Default target - show help help: @@ -171,6 +171,32 @@ loadtest: @echo "==> results landed in deploy/test/loadtest/results/" @if [ -f deploy/test/loadtest/results/summary.txt ]; then cat deploy/test/loadtest/results/summary.txt; fi +# Auth Bundle 2 Phase 10 — Keycloak end-to-end OIDC integration test. +# Boots a Keycloak container via testcontainers-go (quay.io/keycloak:25.0), +# imports a canned realm with two groups + two users, and drives the +# full OIDC flow against the certctl service: discovery + JWKS, +# auth-code login, group-claim parsing, group-role mapping, session +# mint, and JWKS rotation. +# +# Build-tag-gated under `integration` so `make verify` (which runs +# go test -short) NEVER pulls in the 60-90s Keycloak boot. Requires a +# local Docker daemon. Skips cleanly with t.Skip() when -short is set. +keycloak-integration-test: + @echo "==> running Keycloak OIDC integration test (requires Docker)" + @go test -tags=integration -count=1 -timeout=10m \ + ./internal/auth/oidc/... + +# Auth Bundle 2 Phase 10 — optional Okta smoke test. Gated behind TWO +# build tags (integration + okta_smoke) so it only runs when invoked +# manually against the operator's own Okta dev tenant. Requires the +# OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars; the test +# t.Skip's with a clear message when any are missing. Documented in +# internal/auth/oidc/integration_okta_smoke_test.go. +okta-smoke-test: + @echo "==> running Okta smoke test (requires OKTA_ISSUER / _CLIENT_ID / _CLIENT_SECRET env vars)" + @go test -tags='integration okta_smoke' -count=1 -timeout=2m \ + ./internal/auth/oidc/... + # Phase 5 — kind-driven cert-manager integration test. Requires # `kind`, `kubectl`, `helm`, and a local Docker daemon. Sets # KIND_AVAILABLE=1 so the test runs (it skips cleanly when unset, which diff --git a/internal/auth/oidc/integration_keycloak_test.go b/internal/auth/oidc/integration_keycloak_test.go new file mode 100644 index 0000000..7f3988e --- /dev/null +++ b/internal/auth/oidc/integration_keycloak_test.go @@ -0,0 +1,585 @@ +//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 } + +func newItestPreLogin() *itestPreLogin { + return &itestPreLogin{rows: make(map[string]itestPreLoginRow)} +} +func (s *itestPreLogin) CreatePreLogin(_ context.Context, providerID, state, nonce, verifier string) (string, string, error) { + cookieVal := fmt.Sprintf("pl-keycloak-itest-%d", len(s.rows)+1) + s.rows[cookieVal] = itestPreLoginRow{providerID, state, nonce, verifier} + return cookieVal, "ses-" + cookieVal, nil +} +func (s *itestPreLogin) LookupAndConsume(_ context.Context, cookie 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, 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 + //