diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5224e1b3..cff2b0df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -184,6 +184,8 @@ jobs: DEX_KUBERNETES_CONFIG_PATH: ~/.kube/config + DEX_AUTHPROXY_URL: http://localhost:18081 + lint: name: Lint runs-on: ubuntu-latest diff --git a/connector/authproxy/authproxy_integration_test.go b/connector/authproxy/authproxy_integration_test.go new file mode 100644 index 00000000..1675ac82 --- /dev/null +++ b/connector/authproxy/authproxy_integration_test.go @@ -0,0 +1,223 @@ +package authproxy + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "sync" + "testing" + "time" + + "github.com/dexidp/dex/connector" +) + +// The fixed port on which the Go test backend listens. +// nginx is configured to proxy_pass to host.docker.internal:18562. +const testBackendPort = "18562" + +const testAuthProxyURLEnv = "DEX_AUTHPROXY_URL" + +// identityResult holds the result of HandleCallback invoked on the proxied request. +type identityResult struct { + Identity connector.Identity `json:"identity"` + Error string `json:"error,omitempty"` +} + +// startTestBackend starts an HTTP server that receives proxied requests from nginx, +// invokes HandleCallback on each request, and returns the identity as JSON. +// The caller must call the returned cleanup function to shut down the server. +func startTestBackend(t *testing.T, conn *callback) (cleanup func()) { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + ident, err := conn.HandleCallback(connector.Scopes{Groups: true}, nil, r) + result := identityResult{Identity: ident} + if err != nil { + result.Error = err.Error() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + }) + + listener, err := net.Listen("tcp", ":"+testBackendPort) + if err != nil { + t.Fatalf("failed to listen on port %s: %v", testBackendPort, err) + } + + server := &http.Server{Handler: mux} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + t.Errorf("backend server error: %v", err) + } + }() + + return func() { + server.Close() + wg.Wait() + } +} + +// subtest describes a single integration test case for the authproxy connector. +type integrationSubtest struct { + name string + config Config + // path is the nginx location path to hit (e.g., "/default", "/minimal"). + path string + + wantErr bool + want connector.Identity +} + +func TestIntegrationAuthProxy(t *testing.T) { + proxyURL := os.Getenv(testAuthProxyURLEnv) + if proxyURL == "" { + t.Skipf("test environment variable %q not set, skipping authproxy integration tests", testAuthProxyURLEnv) + } + + tests := []integrationSubtest{ + { + name: "all headers set", + config: Config{}, + path: "/default", + want: connector.Identity{ + UserID: "uid-12345", + Username: "testuser", + PreferredUsername: "Test User", + Email: "testuser@example.com", + EmailVerified: true, + Groups: []string{"group1", "group2", "group3"}, + }, + }, + { + name: "minimal headers - fallback to X-Remote-User", + config: Config{}, + path: "/minimal", + want: connector.Identity{ + UserID: "janedoe", + Username: "janedoe", + PreferredUsername: "janedoe", + Email: "janedoe", + EmailVerified: true, + }, + }, + { + name: "no auth headers - expect error", + config: Config{}, + path: "/no-headers", + wantErr: true, + }, + { + name: "custom group separator", + config: Config{ + GroupHeaderSeparator: ";", + }, + path: "/custom-separator", + want: connector.Identity{ + UserID: "uid-99999", + Username: "johndoe", + PreferredUsername: "John Doe", + Email: "johndoe@example.com", + EmailVerified: true, + Groups: []string{"admins", "developers", "ops"}, + }, + }, + { + name: "all headers set with static groups", + config: Config{ + Groups: []string{"static-group1", "static-group2"}, + }, + path: "/default", + want: connector.Identity{ + UserID: "uid-12345", + Username: "testuser", + PreferredUsername: "Test User", + Email: "testuser@example.com", + EmailVerified: true, + Groups: []string{"group1", "group2", "group3", "static-group1", "static-group2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := slog.New(slog.DiscardHandler) + c, err := tt.config.Open("test-authproxy", l) + if err != nil { + t.Fatalf("failed to open connector: %v", err) + } + + cb := c.(*callback) + cleanup := startTestBackend(t, cb) + defer cleanup() + + // Give the backend a moment to start. + time.Sleep(50 * time.Millisecond) + + // Make a request through the nginx proxy. + reqURL := fmt.Sprintf("%s%s", proxyURL, tt.path) + + resp, err := http.Get(reqURL) + if err != nil { + t.Fatalf("failed to make request through proxy: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var result identityResult + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("failed to decode response: %v\nbody: %s", err, string(body)) + } + + if tt.wantErr { + if result.Error == "" { + t.Fatal("expected error from HandleCallback, got none") + } + return + } + + if result.Error != "" { + t.Fatalf("unexpected error from HandleCallback: %s", result.Error) + } + + got := result.Identity + if got.UserID != tt.want.UserID { + t.Errorf("UserID: got %q, want %q", got.UserID, tt.want.UserID) + } + if got.Username != tt.want.Username { + t.Errorf("Username: got %q, want %q", got.Username, tt.want.Username) + } + if got.PreferredUsername != tt.want.PreferredUsername { + t.Errorf("PreferredUsername: got %q, want %q", got.PreferredUsername, tt.want.PreferredUsername) + } + if got.Email != tt.want.Email { + t.Errorf("Email: got %q, want %q", got.Email, tt.want.Email) + } + if got.EmailVerified != tt.want.EmailVerified { + t.Errorf("EmailVerified: got %v, want %v", got.EmailVerified, tt.want.EmailVerified) + } + if len(got.Groups) != len(tt.want.Groups) { + t.Errorf("Groups length: got %d, want %d (got: %v, want: %v)", len(got.Groups), len(tt.want.Groups), got.Groups, tt.want.Groups) + } else { + for i := range tt.want.Groups { + if got.Groups[i] != tt.want.Groups[i] { + t.Errorf("Groups[%d]: got %q, want %q", i, got.Groups[i], tt.want.Groups[i]) + } + } + } + }) + } +} diff --git a/connector/authproxy/testdata/nginx.conf b/connector/authproxy/testdata/nginx.conf new file mode 100644 index 00000000..de32a658 --- /dev/null +++ b/connector/authproxy/testdata/nginx.conf @@ -0,0 +1,55 @@ +events { + worker_connections 128; +} + +http { + # The upstream is the Go test server started on a fixed port. + upstream backend { + server host.docker.internal:18562; + } + + server { + listen 80; + + # Default headers scenario: all X-Remote-* headers set. + location /default { + proxy_set_header X-Remote-User "testuser"; + proxy_set_header X-Remote-User-Id "uid-12345"; + proxy_set_header X-Remote-User-Email "testuser@example.com"; + proxy_set_header X-Remote-User-Name "Test User"; + proxy_set_header X-Remote-Group "group1, group2, group3"; + proxy_pass http://backend; + } + + # Only X-Remote-User header set; other fields should fall back to it. + location /minimal { + proxy_set_header X-Remote-User "janedoe"; + proxy_set_header X-Remote-User-Id ""; + proxy_set_header X-Remote-User-Email ""; + proxy_set_header X-Remote-User-Name ""; + proxy_set_header X-Remote-Group ""; + proxy_pass http://backend; + } + + # No auth headers: connector should return an error. + location /no-headers { + proxy_set_header X-Remote-User ""; + proxy_set_header X-Remote-User-Id ""; + proxy_set_header X-Remote-User-Email ""; + proxy_set_header X-Remote-User-Name ""; + proxy_set_header X-Remote-Group ""; + proxy_pass http://backend; + } + + # Custom separator scenario: groups separated by ";". + location /custom-separator { + proxy_set_header X-Remote-User "johndoe"; + proxy_set_header X-Remote-User-Id "uid-99999"; + proxy_set_header X-Remote-User-Email "johndoe@example.com"; + proxy_set_header X-Remote-User-Name "John Doe"; + proxy_set_header X-Remote-Group "admins;developers;ops"; + proxy_pass http://backend; + } + } +} + diff --git a/docker-compose.override.yaml.dist b/docker-compose.override.yaml.dist index 30591add..3fc22862 100644 --- a/docker-compose.override.yaml.dist +++ b/docker-compose.override.yaml.dist @@ -21,3 +21,7 @@ services: ports: - "127.0.0.1:389:389" - "127.0.0.1:636:636" + + authproxy-nginx: + ports: + - "127.0.0.1:18081:80" diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 933ff801..56f03ee7 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -16,3 +16,12 @@ services: volumes: - ./connector/ldap/testdata/certs:/container/service/slapd/assets/certs - ./connector/ldap/testdata/schema.ldif:/container/service/slapd/assets/config/bootstrap/ldif/99-schema.ldif + + authproxy-nginx: + image: nginx:1.27-alpine + volumes: + - ./connector/authproxy/testdata/nginx.conf:/etc/nginx/nginx.conf:ro + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - 18081:80 diff --git a/docker-compose.yaml b/docker-compose.yaml index 6c5a052a..fd86ff89 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -74,3 +74,13 @@ services: - IPC_LOCK ports: - 8210:8200 + + authproxy-nginx: + image: nginx:1.27-alpine + volumes: + - ./connector/authproxy/testdata/nginx.conf:/etc/nginx/nginx.conf:ro + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - 18081:80 +