From ad51d8ef46daa0a52dab1e219a3c22d5af828511 Mon Sep 17 00:00:00 2001 From: Chad Crum <1462069+chadcrum@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:25:25 -0400 Subject: [PATCH] test(auth): add auth subsystem black-box test suite Add Ginkgo black-box tests against real Keycloak/PostgreSQL/control-plane containers covering the auth middleware: proxy-secret validation, JIT actor provisioning, OIDC JWT round-trips, actor status enforcement, RFC 7807 error formats, and concurrent first-login races. Signed-off-by: Chad Crum <1462069+chadcrum@users.noreply.github.com> Co-Authored-By: Claude --- .github/workflows/subsystem.yaml | 12 ++ Makefile | 1 + make/auth.mk | 15 ++ test/subsystem/auth/admin_seed_test.go | 28 +++ test/subsystem/auth/docker-compose.yaml | 78 ++++++++ test/subsystem/auth/error_format_test.go | 53 +++++ test/subsystem/auth/health_test.go | 35 ++++ test/subsystem/auth/helpers_test.go | 234 +++++++++++++++++++++++ test/subsystem/auth/jit_test.go | 81 ++++++++ test/subsystem/auth/oidc_test.go | 77 ++++++++ test/subsystem/auth/proxy_secret_test.go | 75 ++++++++ test/subsystem/auth/race_test.go | 76 ++++++++ test/subsystem/auth/status_test.go | 88 +++++++++ test/subsystem/auth/suite_test.go | 70 +++++++ 14 files changed, 923 insertions(+) create mode 100644 make/auth.mk create mode 100644 test/subsystem/auth/admin_seed_test.go create mode 100644 test/subsystem/auth/docker-compose.yaml create mode 100644 test/subsystem/auth/error_format_test.go create mode 100644 test/subsystem/auth/health_test.go create mode 100644 test/subsystem/auth/helpers_test.go create mode 100644 test/subsystem/auth/jit_test.go create mode 100644 test/subsystem/auth/oidc_test.go create mode 100644 test/subsystem/auth/proxy_secret_test.go create mode 100644 test/subsystem/auth/race_test.go create mode 100644 test/subsystem/auth/status_test.go create mode 100644 test/subsystem/auth/suite_test.go diff --git a/.github/workflows/subsystem.yaml b/.github/workflows/subsystem.yaml index 4992900..d5cf120 100644 --- a/.github/workflows/subsystem.yaml +++ b/.github/workflows/subsystem.yaml @@ -7,6 +7,18 @@ on: branches: [main, 'release/v*'] jobs: + auth-subsystem: + uses: dcm-project/shared-workflows/.github/workflows/black-box.yaml@main + with: + up-target: auth-subsystem-test-up + test-target: auth-subsystem-test + down-target: auth-subsystem-test-down + images: >- + registry.access.redhat.com/ubi9/go-toolset:1.25.5 + registry.access.redhat.com/ubi9/ubi-minimal:latest + quay.io/sclorg/postgresql-16-c9s:latest + quay.io/keycloak/keycloak:26.0 + policy-subsystem: uses: dcm-project/shared-workflows/.github/workflows/black-box.yaml@main with: diff --git a/Makefile b/Makefile index 02a2132..1f3de9f 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ export COMPOSE_PROJECT_NAME CONTAINER_IMAGE_NAME ?= quay.io/dcm-project/$(BINARY_NAME) CONTAINER_IMAGE_TAG ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +include make/auth.mk include make/catalog.mk include make/placement.mk include make/policy.mk diff --git a/make/auth.mk b/make/auth.mk new file mode 100644 index 0000000..2d782d0 --- /dev/null +++ b/make/auth.mk @@ -0,0 +1,15 @@ +# Auth domain (subsystem tests). +AUTH_DOMAIN := auth + +AUTH_COMPOSE = COMPOSE_PROJECT_NAME=auth-subsystem $(COMPOSE) -f test/subsystem/$(AUTH_DOMAIN)/docker-compose.yaml + +auth-subsystem-test-up: + $(AUTH_COMPOSE) up -d --build + +auth-subsystem-test-down: + $(AUTH_COMPOSE) down -v + +auth-subsystem-test: + $(GINKGO) $(GINKGO_FLAGS) -tags=subsystem ./test/subsystem/$(AUTH_DOMAIN) + +.PHONY: auth-subsystem-test-up auth-subsystem-test-down auth-subsystem-test diff --git a/test/subsystem/auth/admin_seed_test.go b/test/subsystem/auth/admin_seed_test.go new file mode 100644 index 0000000..bd4a976 --- /dev/null +++ b/test/subsystem/auth/admin_seed_test.go @@ -0,0 +1,28 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Admin actor seeding", func() { + It("seeds an active admin actor on startup", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, adminSubject)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, username, status := getActorByExternalID(adminSubject) + Expect(username).To(Equal("admin")) + Expect(status).To(Equal("active")) + }) + + It("binds the admin identity to the configured subject", func() { + count := countActorsByExternalID(adminSubject) + Expect(count).To(Equal(1)) + }) +}) diff --git a/test/subsystem/auth/docker-compose.yaml b/test/subsystem/auth/docker-compose.yaml new file mode 100644 index 0000000..846ce48 --- /dev/null +++ b/test/subsystem/auth/docker-compose.yaml @@ -0,0 +1,78 @@ +# Auth subsystem test stack. Runs with AUTH_DISABLED=false to exercise +# the full authentication middleware, JIT provisioning, and OIDC flow. + +services: + postgres: + image: quay.io/sclorg/postgresql-16-c9s:latest + environment: + POSTGRESQL_DATABASE: auth_test + POSTGRESQL_USER: ${POSTGRES_USER:-test_user} + POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD:-test_password} + ports: + - "25432:5432" + volumes: + - postgres_data:/var/lib/pgsql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-test_user} -d auth_test"] + interval: 5s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + entrypoint: ["/bin/sh", "-c"] + command: + - "mkdir -p /opt/keycloak/data/import && content=$$(cat /opt/keycloak/data/realm-template.json) && printf '%s' \"$${content//REPLACE_DCM_DEV_PASSWORD/$$DCM_DEV_USER_PASSWORD}\" > /opt/keycloak/data/import/realm-export.json && exec /opt/keycloak/bin/kc.sh start-dev --import-realm" + environment: + KC_HEALTH_ENABLED: "true" + KC_HOSTNAME: "http://keycloak:8080" + KC_HOSTNAME_STRICT: "false" + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + DCM_DEV_USER_PASSWORD: ${DCM_DEV_USER_PASSWORD:-admin} + ports: + - "28180:8080" + volumes: + - ../../../deploy/keycloak/realm-export.json:/opt/keycloak/data/realm-template.json:ro,z + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + + control-plane: + build: + context: ../../.. + dockerfile: Containerfile + environment: + BIND_ADDRESS: "0.0.0.0:8080" + DB_TYPE: pgsql + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: auth_test + DB_USER: ${POSTGRES_USER:-test_user} + DB_PASSWORD: ${POSTGRES_PASSWORD:-test_password} + NATS_DISABLED: "true" + AUTH_DISABLED: "false" + AUTH_PROXY_SECRET: "test-proxy-secret" + AUTH_ISSUER_URL: "http://keycloak:8080/realms/dcm" + AUTH_JWT_AUDIENCE: "dcm-api" + AUTH_CACHE_TTL: "2s" + DCM_ADMIN_SUBJECT: "56deb662-4820-5d83-b828-f4beb11a5fa7" + LOG_LEVEL: debug + ports: + - "28080:8080" + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1alpha1/health"] + interval: 5s + timeout: 5s + retries: 15 + +volumes: + postgres_data: {} diff --git a/test/subsystem/auth/error_format_test.go b/test/subsystem/auth/error_format_test.go new file mode 100644 index 0000000..46297c8 --- /dev/null +++ b/test/subsystem/auth/error_format_test.go @@ -0,0 +1,53 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RFC 7807 error response format", func() { + It("returns well-formed problem+json on 401", func() { + resp := doRequest(http.MethodGet, "/catalog-items") + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(resp.Header.Get("Content-Type")).To(Equal("application/problem+json")) + Expect(resp.Header.Get("WWW-Authenticate")).To(Equal("Bearer")) + + problem := readProblemResponse(resp) + Expect(problem.Type).To(Equal("UNAUTHENTICATED")) + Expect(problem.Status).To(Equal(401)) + Expect(problem.Title).To(Equal("Unauthorized")) + Expect(problem.Detail).NotTo(BeEmpty()) + }) + + It("returns well-formed problem+json on 403", func() { + adminToken := getKeycloakAdminToken() + username := uniqueUsername() + kcUserID := createKeycloakUser(adminToken, username, "testpass") + defer deleteKeycloakUser(adminToken, kcUserID) + + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + updateActorStatus(kcUserID, "suspended") + time.Sleep(3 * time.Second) + + resp2 := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + Expect(resp2.StatusCode).To(Equal(http.StatusForbidden)) + Expect(resp2.Header.Get("Content-Type")).To(Equal("application/problem+json")) + + problem := readProblemResponse(resp2) + Expect(problem.Type).To(Equal("PERMISSION_DENIED")) + Expect(problem.Status).To(Equal(403)) + Expect(problem.Title).To(Equal("Forbidden")) + Expect(problem.Detail).To(Equal("account suspended")) + }) +}) diff --git a/test/subsystem/auth/health_test.go b/test/subsystem/auth/health_test.go new file mode 100644 index 0000000..c8e1236 --- /dev/null +++ b/test/subsystem/auth/health_test.go @@ -0,0 +1,35 @@ +//go:build subsystem + +package subsystem_test + +import ( + "io" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Health endpoint bypass", func() { + It("returns 200 with no auth headers", func() { + resp := doRequest(http.MethodGet, "/health") + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"status":"ok"`)) + }) + + It("returns 200 even with an invalid Bearer token", func() { + resp := doRequest(http.MethodGet, "/health", withBearerToken("garbage-token")) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("returns 200 even with a wrong proxy secret", func() { + resp := doRequest(http.MethodGet, "/health", withProxySecret("wrong-secret", "any-subject")) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) +}) diff --git a/test/subsystem/auth/helpers_test.go b/test/subsystem/auth/helpers_test.go new file mode 100644 index 0000000..c5188e7 --- /dev/null +++ b/test/subsystem/auth/helpers_test.go @@ -0,0 +1,234 @@ +//go:build subsystem + +package subsystem_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// --- Keycloak helpers --- + +func getKeycloakAdminToken() string { + GinkgoHelper() + tokenURL := keycloakURL + "/realms/master/protocol/openid-connect/token" + resp, err := httpClient.PostForm(tokenURL, url.Values{ + "grant_type": {"password"}, + "client_id": {"admin-cli"}, + "username": {"admin"}, + "password": {"admin"}, + }) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK), "failed to get Keycloak admin token") + + var tokenResp tokenResponse + Expect(json.NewDecoder(resp.Body).Decode(&tokenResp)).To(Succeed()) + Expect(tokenResp.AccessToken).NotTo(BeEmpty()) + return tokenResp.AccessToken +} + +func createKeycloakUser(adminToken, username, password string) string { + GinkgoHelper() + usersURL := keycloakURL + "/admin/realms/dcm/users" + + userPayload := fmt.Sprintf(`{ + "username": %q, + "enabled": true, + "emailVerified": true, + "email": %q, + "firstName": "Test", + "lastName": "User", + "credentials": [{"type": "password", "value": %q, "temporary": false}] + }`, username, username+"@test.local", password) + + req, err := http.NewRequest(http.MethodPost, usersURL, strings.NewReader(userPayload)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := httpClient.Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusCreated), "failed to create Keycloak user %q", username) + + location := resp.Header.Get("Location") + Expect(location).NotTo(BeEmpty()) + parts := strings.Split(location, "/") + return parts[len(parts)-1] +} + +func deleteKeycloakUser(adminToken, userID string) { + GinkgoHelper() + userURL := keycloakURL + "/admin/realms/dcm/users/" + userID + + req, err := http.NewRequest(http.MethodDelete, userURL, nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := httpClient.Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) +} + +func getUserToken(username, password string) string { + GinkgoHelper() + tokenURL := keycloakURL + "/realms/dcm/protocol/openid-connect/token" + resp, err := httpClient.PostForm(tokenURL, url.Values{ + "grant_type": {"password"}, + "client_id": {"dcm-proxy"}, + "client_secret": {"dcm-proxy-secret"}, + "username": {username}, + "password": {password}, + }) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK), "failed to get user token for %q", username) + + var tokenResp tokenResponse + Expect(json.NewDecoder(resp.Body).Decode(&tokenResp)).To(Succeed()) + Expect(tokenResp.AccessToken).NotTo(BeEmpty()) + return tokenResp.AccessToken +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` +} + +// --- HTTP request helpers --- + +type requestOption func(*http.Request) + +func withBearerToken(token string) requestOption { + return func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +func withProxySecret(secret, subject string) requestOption { + return func(req *http.Request) { + req.Header.Set("X-Auth-Proxy-Secret", secret) + if subject != "" { + req.Header.Set("X-Forwarded-User", subject) + } + } +} + +func withPreferredUsername(preferredUsername string) requestOption { + return func(req *http.Request) { + req.Header.Set("X-Forwarded-Preferred-Username", preferredUsername) + } +} + +func doRequest(method, path string, opts ...requestOption) *http.Response { + GinkgoHelper() + reqURL := apiURL + path + req, err := http.NewRequest(method, reqURL, nil) + Expect(err).NotTo(HaveOccurred()) + for _, opt := range opts { + opt(req) + } + resp, err := httpClient.Do(req) + Expect(err).NotTo(HaveOccurred()) + return resp +} + +type problemResponse struct { + Type string `json:"type"` + Status int `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` +} + +func readProblemResponse(resp *http.Response) problemResponse { + GinkgoHelper() + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + var problem problemResponse + Expect(json.Unmarshal(body, &problem)).To(Succeed(), "body: %s", string(body)) + return problem +} + +// --- DB helpers --- + +func updateActorStatus(externalID, status string) { + GinkgoHelper() + result, err := db.Exec( + "UPDATE actors SET status = $1 WHERE id = (SELECT actor_id FROM actor_identities WHERE external_id = $2 AND auth_provider = 'keycloak')", + status, externalID, + ) + Expect(err).NotTo(HaveOccurred()) + rows, err := result.RowsAffected() + Expect(err).NotTo(HaveOccurred()) + Expect(rows).To(Equal(int64(1)), "expected to update exactly 1 actor for external_id %q", externalID) +} + +func getActorByExternalID(externalID string) (actorID, username, status string) { + GinkgoHelper() + err := db.QueryRow( + "SELECT a.id, a.username, a.status FROM actors a JOIN actor_identities ai ON a.id = ai.actor_id WHERE ai.external_id = $1 AND ai.auth_provider = 'keycloak'", + externalID, + ).Scan(&actorID, &username, &status) + Expect(err).NotTo(HaveOccurred()) + return +} + +func countActorsByExternalID(externalID string) int { + GinkgoHelper() + var count int + err := db.QueryRow( + "SELECT COUNT(*) FROM actor_identities WHERE external_id = $1 AND auth_provider = 'keycloak'", + externalID, + ).Scan(&count) + Expect(err).NotTo(HaveOccurred()) + return count +} + +func extractSubClaim(token string) string { + GinkgoHelper() + parts := strings.SplitN(token, ".", 3) + Expect(parts).To(HaveLen(3)) + decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) + Expect(err).NotTo(HaveOccurred()) + var claims struct { + Sub string `json:"sub"` + } + Expect(json.Unmarshal(decoded, &claims)).To(Succeed()) + Expect(claims.Sub).NotTo(BeEmpty()) + return claims.Sub +} + +func getServiceAccountToken() string { + GinkgoHelper() + tokenURL := keycloakURL + "/realms/dcm/protocol/openid-connect/token" + resp, err := httpClient.PostForm(tokenURL, url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"dcm-proxy"}, + "client_secret": {"dcm-proxy-secret"}, + }) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK), "failed to get service account token") + + var tokenResp tokenResponse + Expect(json.NewDecoder(resp.Body).Decode(&tokenResp)).To(Succeed()) + Expect(tokenResp.AccessToken).NotTo(BeEmpty()) + return tokenResp.AccessToken +} + +// --- Utility --- + +func uniqueUsername() string { + return "test-user-" + uuid.NewString()[:8] +} diff --git a/test/subsystem/auth/jit_test.go b/test/subsystem/auth/jit_test.go new file mode 100644 index 0000000..167d82b --- /dev/null +++ b/test/subsystem/auth/jit_test.go @@ -0,0 +1,81 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("JIT provisioning via proxy-secret path", func() { + var ( + adminToken string + kcUserID string + username string + password = "testpass" + ) + + BeforeEach(func() { + adminToken = getKeycloakAdminToken() + username = uniqueUsername() + kcUserID = createKeycloakUser(adminToken, username, password) + }) + + AfterEach(func() { + if kcUserID != "" { + deleteKeycloakUser(adminToken, kcUserID) + } + }) + + It("provisions a new actor on first request with a new subject", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, status := getActorByExternalID(kcUserID) + Expect(dbUsername).To(Equal(username)) + Expect(status).To(Equal("active")) + }) + + It("uses preferred username for the actor username", func() { + preferred := "my-preferred-" + username + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(preferred)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, _ := getActorByExternalID(kcUserID) + Expect(dbUsername).To(Equal(preferred)) + }) + + It("falls back to subject as username when preferred username is absent", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, _ := getActorByExternalID(kcUserID) + Expect(dbUsername).To(Equal(kcUserID)) + }) + + It("succeeds on subsequent requests for the same subject", func() { + resp1 := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp1.Body.Close() + Expect(resp1.StatusCode).To(Equal(http.StatusOK)) + + resp2 := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp2.Body.Close() + Expect(resp2.StatusCode).To(Equal(http.StatusOK)) + + Expect(countActorsByExternalID(kcUserID)).To(Equal(1)) + }) +}) diff --git a/test/subsystem/auth/oidc_test.go b/test/subsystem/auth/oidc_test.go new file mode 100644 index 0000000..9010de8 --- /dev/null +++ b/test/subsystem/auth/oidc_test.go @@ -0,0 +1,77 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Full OIDC round-trip via JWT", func() { + var ( + adminToken string + kcUserID string + username string + password = "testpass" + ) + + BeforeEach(func() { + adminToken = getKeycloakAdminToken() + username = uniqueUsername() + kcUserID = createKeycloakUser(adminToken, username, password) + }) + + AfterEach(func() { + if kcUserID != "" { + deleteKeycloakUser(adminToken, kcUserID) + } + }) + + It("authenticates with a Keycloak JWT and JIT provisions the actor", func() { + token := getUserToken(username, password) + + resp := doRequest(http.MethodGet, "/catalog-items", withBearerToken(token)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, status := getActorByExternalID(kcUserID) + Expect(dbUsername).To(Equal(username)) + Expect(status).To(Equal("active")) + }) + + It("validates the dcm-api audience in the JWT", func() { + token := getUserToken(username, password) + + resp := doRequest(http.MethodGet, "/catalog-items", withBearerToken(token)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("returns 401 for a tampered JWT", func() { + token := getUserToken(username, password) + tampered := token[:len(token)-4] + "XXXX" + + resp := doRequest(http.MethodGet, "/catalog-items", withBearerToken(tampered)) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("invalid bearer token")) + }) +}) + +var _ = Describe("Client credentials service account JWT", func() { + It("authenticates and JIT provisions a service account actor", func() { + token := getServiceAccountToken() + sub := extractSubClaim(token) + + resp := doRequest(http.MethodGet, "/catalog-items", withBearerToken(token)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, status := getActorByExternalID(sub) + Expect(dbUsername).To(HavePrefix("service-account-")) + Expect(status).To(Equal("active")) + }) +}) diff --git a/test/subsystem/auth/proxy_secret_test.go b/test/subsystem/auth/proxy_secret_test.go new file mode 100644 index 0000000..c77f277 --- /dev/null +++ b/test/subsystem/auth/proxy_secret_test.go @@ -0,0 +1,75 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Proxy secret validation", func() { + It("returns 401 with no auth credentials", func() { + resp := doRequest(http.MethodGet, "/catalog-items") + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(resp.Header.Get("WWW-Authenticate")).To(Equal("Bearer")) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("missing authentication")) + }) + + It("returns 401 with wrong proxy secret", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret("wrong-secret", adminSubject)) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("invalid proxy secret")) + }) + + It("succeeds with correct proxy secret and valid subject", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, adminSubject)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("returns 401 with correct proxy secret but empty subject", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, "")) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("missing subject identifier")) + }) + + It("uses JWT identity when both valid Bearer and valid proxy secret are present", func() { + adminToken := getKeycloakAdminToken() + username := uniqueUsername() + kcUserID := createKeycloakUser(adminToken, username, "testpass") + defer deleteKeycloakUser(adminToken, kcUserID) + + token := getUserToken(username, "testpass") + + resp := doRequest(http.MethodGet, "/catalog-items", + withBearerToken(token), + withProxySecret(proxySecret, adminSubject)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, dbUsername, status := getActorByExternalID(kcUserID) + Expect(dbUsername).To(Equal(username)) + Expect(status).To(Equal("active")) + }) + + It("returns 401 for invalid Bearer even when valid proxy secret is present", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withBearerToken("garbage-token"), + withProxySecret(proxySecret, adminSubject)) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("invalid bearer token")) + }) +}) diff --git a/test/subsystem/auth/race_test.go b/test/subsystem/auth/race_test.go new file mode 100644 index 0000000..bf64172 --- /dev/null +++ b/test/subsystem/auth/race_test.go @@ -0,0 +1,76 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Concurrent first-login race condition", func() { + var ( + adminToken string + ) + + BeforeEach(func() { + adminToken = getKeycloakAdminToken() + }) + + It("handles concurrent JIT provisioning for the same subject", func() { + username := uniqueUsername() + kcUserID := createKeycloakUser(adminToken, username, "testpass") + defer deleteKeycloakUser(adminToken, kcUserID) + + const concurrency = 10 + var wg sync.WaitGroup + statuses := make([]int, concurrency) + + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func(idx int) { + defer GinkgoRecover() + defer wg.Done() + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp.Body.Close() + statuses[idx] = resp.StatusCode + }(i) + } + wg.Wait() + + for i, status := range statuses { + Expect(status).To(Equal(http.StatusOK), "request %d returned %d", i, status) + } + Expect(countActorsByExternalID(kcUserID)).To(Equal(1)) + }) + + It("returns 409 on username collision between different subjects", func() { + collisionName := "collision-" + uniqueUsername() + + user1 := uniqueUsername() + kcUser1ID := createKeycloakUser(adminToken, user1, "testpass") + defer deleteKeycloakUser(adminToken, kcUser1ID) + + resp1 := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUser1ID), + withPreferredUsername(collisionName)) + defer resp1.Body.Close() + Expect(resp1.StatusCode).To(Equal(http.StatusOK)) + + user2 := uniqueUsername() + kcUser2ID := createKeycloakUser(adminToken, user2, "testpass") + defer deleteKeycloakUser(adminToken, kcUser2ID) + + resp2 := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUser2ID), + withPreferredUsername(collisionName)) + Expect(resp2.StatusCode).To(Equal(http.StatusConflict)) + + problem := readProblemResponse(resp2) + Expect(problem.Detail).To(Equal("username already in use by another account")) + }) +}) diff --git a/test/subsystem/auth/status_test.go b/test/subsystem/auth/status_test.go new file mode 100644 index 0000000..b6569e1 --- /dev/null +++ b/test/subsystem/auth/status_test.go @@ -0,0 +1,88 @@ +//go:build subsystem + +package subsystem_test + +import ( + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Actor status enforcement", func() { + var ( + adminToken string + kcUserID string + username string + password = "testpass" + ) + + BeforeEach(func() { + adminToken = getKeycloakAdminToken() + username = uniqueUsername() + kcUserID = createKeycloakUser(adminToken, username, password) + + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID), + withPreferredUsername(username)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + AfterEach(func() { + if kcUserID != "" { + deleteKeycloakUser(adminToken, kcUserID) + } + }) + + It("allows access for an active actor", func() { + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("returns 403 for a suspended actor", func() { + updateActorStatus(kcUserID, "suspended") + time.Sleep(3 * time.Second) + + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("account suspended")) + }) + + It("allows access after reactivating a suspended actor", func() { + updateActorStatus(kcUserID, "suspended") + time.Sleep(3 * time.Second) + + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("account suspended")) + + updateActorStatus(kcUserID, "active") + time.Sleep(3 * time.Second) + + resp = doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("returns 403 for a deactivated actor", func() { + updateActorStatus(kcUserID, "deactivated") + time.Sleep(3 * time.Second) + + resp := doRequest(http.MethodGet, "/catalog-items", + withProxySecret(proxySecret, kcUserID)) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + + problem := readProblemResponse(resp) + Expect(problem.Detail).To(Equal("account deactivated")) + }) +}) diff --git a/test/subsystem/auth/suite_test.go b/test/subsystem/auth/suite_test.go new file mode 100644 index 0000000..b5dc84e --- /dev/null +++ b/test/subsystem/auth/suite_test.go @@ -0,0 +1,70 @@ +//go:build subsystem + +package subsystem_test + +import ( + "database/sql" + "fmt" + "net/http" + "os" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +var ( + apiURL string + keycloakURL string + proxySecret string + adminSubject string + dbConnStr string + httpClient = &http.Client{Timeout: 10 * time.Second} + db *sql.DB +) + +func TestSubsystem(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Auth Subsystem Suite") +} + +var _ = BeforeSuite(func() { + apiURL = envOrDefault("API_URL", "http://localhost:28080/api/v1alpha1") + keycloakURL = envOrDefault("KEYCLOAK_URL", "http://localhost:28180") + proxySecret = envOrDefault("AUTH_PROXY_SECRET", "test-proxy-secret") + adminSubject = envOrDefault("DCM_ADMIN_SUBJECT", "56deb662-4820-5d83-b828-f4beb11a5fa7") + dbConnStr = envOrDefault("DB_CONN_STR", "postgres://test_user:test_password@localhost:25432/auth_test?sslmode=disable") + + var err error + db, err = sql.Open("pgx", dbConnStr) + Expect(err).NotTo(HaveOccurred()) + Expect(db.Ping()).To(Succeed()) + + Eventually(func() error { + resp, err := httpClient.Get(apiURL + "/health") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check returned %d", resp.StatusCode) + } + return nil + }).WithTimeout(120 * time.Second).WithPolling(2 * time.Second).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + if db != nil { + db.Close() + } +}) + +func envOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +}