From e02fba6b5ff59b6f3017267e7da5e7a7c8c3ad45 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Tue, 21 Apr 2026 00:39:00 -0300 Subject: [PATCH 01/21] feat(modules/dex): port Kehrlann/testcontainers-dex to Go Upstream-merge-grade module with: Run() + endpoint getters, functional options (WithClient, WithUser, WithConnector, WithIssuer, WithSkipApprovalScreen, WithStorage, WithLogger, WithLogLevel, WithEnableClientCredentials), gRPC admin mutation (AddClient, RemoveClient, AddUser, RemoveUser), slog-backed log consumer, password DB + mockCallback connectors, and feature-flag support for the client_credentials grant. 36 tests cover YAML rendering, container lifecycle, gRPC mutation, auth_code + refresh + multi-redirect, ROPC, multi-client, mock connector, cross-container reachability via network alias, and consumer JWT verification via coreos/go-oidc. Monorepo origin: github.com/guilycst/totvs-work PR #19 (merge commit 0764128de). --- modules/dex/Makefile | 9 + modules/dex/README.md | 146 ++++++ modules/dex/config.go | 180 ++++++++ modules/dex/config_test.go | 263 +++++++++++ modules/dex/dex.go | 248 ++++++++++ modules/dex/dex_test.go | 672 ++++++++++++++++++++++++++++ modules/dex/dextest_helpers_test.go | 111 +++++ modules/dex/examples_test.go | 73 +++ modules/dex/go.mod | 71 +++ modules/dex/go.sum | 157 +++++++ modules/dex/grpc.go | 148 ++++++ modules/dex/log.go | 134 ++++++ modules/dex/log_test.go | 78 ++++ modules/dex/options.go | 135 ++++++ modules/dex/options_test.go | 55 +++ modules/dex/types.go | 64 +++ 16 files changed, 2544 insertions(+) create mode 100644 modules/dex/Makefile create mode 100644 modules/dex/README.md create mode 100644 modules/dex/config.go create mode 100644 modules/dex/config_test.go create mode 100644 modules/dex/dex.go create mode 100644 modules/dex/dex_test.go create mode 100644 modules/dex/dextest_helpers_test.go create mode 100644 modules/dex/examples_test.go create mode 100644 modules/dex/go.mod create mode 100644 modules/dex/go.sum create mode 100644 modules/dex/grpc.go create mode 100644 modules/dex/log.go create mode 100644 modules/dex/log_test.go create mode 100644 modules/dex/options.go create mode 100644 modules/dex/options_test.go create mode 100644 modules/dex/types.go diff --git a/modules/dex/Makefile b/modules/dex/Makefile new file mode 100644 index 0000000000..3e731b710d --- /dev/null +++ b/modules/dex/Makefile @@ -0,0 +1,9 @@ +include ../../commons-test.mk + +.PHONY: test-dex +test-dex: + $(MAKE) test-module + +.PHONY: test +test: + $(MAKE) test-dex diff --git a/modules/dex/README.md b/modules/dex/README.md new file mode 100644 index 0000000000..9a20a5402e --- /dev/null +++ b/modules/dex/README.md @@ -0,0 +1,146 @@ +# Dex + +Not available until the next release of testcontainers-go :material-tag: main + +## Introduction + +The Testcontainers module for [Dex](https://github.com/dexidp/dex), a CNCF OIDC provider. + +## Adding this module to your project dependencies + +``` +go get github.com/testcontainers/testcontainers-go/modules/dex +``` + +## Usage example + +```go +ctx := context.Background() + +c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", + dex.WithClient(dex.Client{ + ID: "my-app", + Secret: "secret", + RedirectURIs: []string{"http://localhost:8080/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Name: "My App", + }), + dex.WithUser(dex.User{ + Email: "user@example.com", + Username: "user", + Password: "password", + }), +) +if err != nil { + log.Fatalf("run dex: %v", err) +} +defer testcontainers.TerminateContainer(c) + +fmt.Println("issuer:", c.IssuerURL()) +``` + +## Supported grants + +`authorization_code`, `refresh_token`, `password`. Declare per-client via +`WithClient(Client{GrantTypes: ...})`. + +**`client_credentials` requires Dex ≥ v2.46.0 (not yet released at time of +writing) with the feature flag enabled.** Dex gates this grant behind the +env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`. Use +`WithEnableClientCredentials()` to set it automatically. Currently available +in `dexidp/dex:master` / `:latest` images; the first tagged release +containing it (likely `v2.46.0`) will also support it. + +```go +c, err := dex.Run(ctx, "dexidp/dex:master", + dex.WithEnableClientCredentials(), + dex.WithClient(dex.Client{ + ID: "svc", Secret: "s", Name: "Service", + GrantTypes: []string{"client_credentials"}, + }), +) +``` + +The module logs a warning when `WithEnableClientCredentials()` is set but +the image tag predates the feature (`v2.45.x` or earlier). Token exchanges +will fail with `unsupported_grant_type` in that case. + +Clients added at runtime via `AddClient` inherit Dex's defaults +(`authorization_code` + `refresh_token`) because Dex's gRPC `api.Client` proto +has no `grant_types` field. Clients needing other grants must be declared +pre-start via `WithClient`. + +## Connectors + +- `ConnectorPassword` — Dex's built-in static password DB (default; enabled + automatically when a user is seeded via `WithUser`). +- `ConnectorMock` — Dex's `mockCallback` test connector (returns a fixed user, + `kilgore@kilgore.trout`). + +## Issuer URL + +By default, the issuer is derived from the host and mapped HTTP port: +`http://:/dex`. Pass `WithIssuer(...)` when the issuer must +be reachable from other containers (for example via a shared Docker network +and a network alias). The caller owns reachability when overriding. + +## ID token claims + +Dex's password connector emits these standard claims in ID tokens: + +- `sub` — stable user ID (auto-generated UUID if `User.UserID` is empty). +- `email` — user's email address. +- `email_verified` — always `true` for static password entries. +- `name` — the value of `User.Username`. +- `iss` — the issuer URL. +- `aud` — the client ID. + +Dex does NOT emit the `preferred_username` claim; use the `name` claim instead +when a human-readable identifier is needed. + +## Module reference + +### Types + +- `Client{ID, Secret, RedirectURIs, GrantTypes, Public, Name}` +- `User{Email, Username, Password, UserID}` +- `ConnectorType` — `ConnectorPassword`, `ConnectorMock` + +### Options + +- `WithClient(Client)` +- `WithUser(User)` +- `WithConnector(type, id, name)` +- `WithIssuer(url)` +- `WithSkipApprovalScreen(bool)` +- `WithStorage(kind)` — `"sqlite3"` (default) or `"memory"` +- `WithLogger(*slog.Logger)` — captures Dex logs +- `WithLogLevel(level)` — sets Dex's `logger.level` YAML key (`debug|info|warn|error`) +- `WithEnableClientCredentials()` — enables the OAuth2 `client_credentials` grant via feature flag (requires Dex ≥ v2.46.0 or `:master`) + +### Endpoint getters + +`IssuerURL`, `ConfigEndpoint`, `JWKSEndpoint`, `TokenEndpoint`, +`AuthEndpoint`, `GRPCEndpoint`. + +### Runtime mutation (gRPC) + +`AddClient`, `RemoveClient`, `AddUser`, `RemoveUser`. Not safe for concurrent +use. + +- `AddClient` returns `ErrClientExists` when the ID is already registered. +- `AddUser` returns `ErrUserExists` when the email is already registered. +- `Remove*` return a plain error containing `"not found"` on unknown IDs. + +## Known limitations + +- Runtime-added clients inherit Dex's default grants; non-default grants must + be declared pre-start via `WithClient`. +- `client_credentials` requires `WithEnableClientCredentials()` and Dex ≥ + v2.46.0 or the `:master` image tag. On v2.45.x and earlier the token + endpoint returns `unsupported_grant_type` regardless of client config. +- gRPC admin API is plaintext. TLS not supported day 1. +- `mockCallback` emits a fixed user; parameterized flows need the password + connector with a pre-seeded user. +- SQLite storage is container-local and ephemeral. +- Bcrypt cost ≥ 10 is enforced by Dex for both YAML and gRPC password paths. diff --git a/modules/dex/config.go b/modules/dex/config.go new file mode 100644 index 0000000000..2f2f2fd854 --- /dev/null +++ b/modules/dex/config.go @@ -0,0 +1,180 @@ +package dex + +import ( + "crypto/rand" + "fmt" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +// testBcryptCost is the bcrypt work factor used when hashing test passwords. +// Dex v2.45+ enforces a minimum cost of 10; cost 10 is the minimum and still +// ~10x faster than the default cost 14 used in production. +const testBcryptCost = 10 + +// dexYAML mirrors Dex's config shape. Marshaled directly via yaml.v3 so no +// string field can inject structural characters. +type dexYAML struct { + Issuer string `yaml:"issuer"` + Storage storageBlock `yaml:"storage"` + Web endpointBlock `yaml:"web"` + GRPC grpcBlock `yaml:"grpc"` + Logger loggerBlock `yaml:"logger"` + OAuth2 oauth2Block `yaml:"oauth2"` + EnablePasswordDB bool `yaml:"enablePasswordDB"` + StaticClients []yamlClient `yaml:"staticClients,omitempty"` + StaticPasswords []yamlPassword `yaml:"staticPasswords,omitempty"` + Connectors []yamlConnector `yaml:"connectors,omitempty"` +} + +type loggerBlock struct { + Level string `yaml:"level"` +} + +type storageBlock struct { + Type string `yaml:"type"` + Config map[string]string `yaml:"config,omitempty"` +} + +type endpointBlock struct { + HTTP string `yaml:"http"` +} + +type grpcBlock struct { + Addr string `yaml:"addr"` +} + +type oauth2Block struct { + SkipApprovalScreen bool `yaml:"skipApprovalScreen"` + GrantTypes []string `yaml:"grantTypes"` + PasswordConnector string `yaml:"passwordConnector,omitempty"` +} + +type yamlClient struct { + ID string `yaml:"id"` + Secret string `yaml:"secret"` + Name string `yaml:"name"` + Public bool `yaml:"public"` + RedirectURIs []string `yaml:"redirectURIs,omitempty"` + GrantTypes []string `yaml:"grantTypes,omitempty"` +} + +type yamlPassword struct { + Email string `yaml:"email"` + Hash string `yaml:"hash"` + Username string `yaml:"username"` + UserID string `yaml:"userID"` +} + +type yamlConnector struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Name string `yaml:"name"` +} + +var defaultGrantTypes = []string{ + "authorization_code", + "refresh_token", + "client_credentials", + "password", +} + +// render serializes an options struct into a Dex YAML config payload. It +// validates that at least one auth source is configured and that the +// issuer has been populated. +func render(o options) ([]byte, error) { + if o.issuer == "" { + return nil, fmt.Errorf("dex: issuer is empty (internal bug — Run should populate before render)") + } + + if !o.enablePasswordDB && len(o.connectors) == 0 { + return nil, ErrNoAuthSource + } + + storage := storageBlock{Type: o.storage} + if o.storage == "sqlite3" { + storage.Config = map[string]string{"file": "/var/dex/dex.db"} + } + + clients := make([]yamlClient, 0, len(o.clients)) + for _, c := range o.clients { + clients = append(clients, yamlClient{ + ID: c.ID, + Secret: c.Secret, + Name: c.Name, + Public: c.Public, + RedirectURIs: c.RedirectURIs, + GrantTypes: c.GrantTypes, + }) + } + + passwords := make([]yamlPassword, 0, len(o.users)) + for _, u := range o.users { + hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), testBcryptCost) + if err != nil { + return nil, fmt.Errorf("dex: bcrypt user %q: %w", u.Email, err) + } + uid := u.UserID + if uid == "" { + uid = newUUIDv4() + } + passwords = append(passwords, yamlPassword{ + Email: u.Email, + Hash: string(hash), + Username: u.Username, + UserID: uid, + }) + } + + connectors := make([]yamlConnector, 0, len(o.connectors)) + for _, c := range o.connectors { + connectors = append(connectors, yamlConnector{ + Type: string(c.Type), + ID: c.ID, + Name: c.Name, + }) + } + + oauth2 := oauth2Block{ + SkipApprovalScreen: o.skipApprovalScreen, + GrantTypes: defaultGrantTypes, + } + // Dex requires oauth2.passwordConnector to name the connector ID used for + // the password grant (ROPC). When the built-in password DB is active its + // synthetic connector ID is "local". + if o.enablePasswordDB { + oauth2.PasswordConnector = "local" + } + + doc := dexYAML{ + Issuer: o.issuer, + Storage: storage, + Web: endpointBlock{HTTP: "0.0.0.0:5556"}, + GRPC: grpcBlock{Addr: "0.0.0.0:5557"}, + Logger: loggerBlock{Level: o.logLevel}, + OAuth2: oauth2, + EnablePasswordDB: o.enablePasswordDB, + StaticClients: clients, + StaticPasswords: passwords, + Connectors: connectors, + } + + out, err := yaml.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("dex: marshal yaml: %w", err) + } + return out, nil +} + +// newUUIDv4 generates an RFC 4122 v4 UUID without importing a third-party dep. +func newUUIDv4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Errorf("dex: read randomness: %w", err)) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/modules/dex/config_test.go b/modules/dex/config_test.go new file mode 100644 index 0000000000..03c281531b --- /dev/null +++ b/modules/dex/config_test.go @@ -0,0 +1,263 @@ +package dex + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +func TestRender_MinimalDefaults(t *testing.T) { + o := defaultOptions() + o.issuer = "http://localhost:5556/dex" + + out, err := render(o) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, yaml.Unmarshal(out, &got)) + + assert.Equal(t, "http://localhost:5556/dex", got["issuer"]) + assert.Equal(t, true, got["enablePasswordDB"]) + storage := got["storage"].(map[string]any) + assert.Equal(t, "sqlite3", storage["type"]) + web := got["web"].(map[string]any) + assert.Equal(t, "0.0.0.0:5556", web["http"]) + grpc := got["grpc"].(map[string]any) + assert.Equal(t, "0.0.0.0:5557", grpc["addr"]) + oauth2 := got["oauth2"].(map[string]any) + assert.Equal(t, true, oauth2["skipApprovalScreen"]) +} + +func TestRender_WithClients(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.clients = []Client{ + { + ID: "app1", Secret: "s1", + RedirectURIs: []string{"http://a/cb", "http://b/cb"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Name: "App 1", + }, + { + ID: "svc", Secret: "s2", + GrantTypes: []string{"client_credentials"}, + Name: "Service", + }, + } + + out, err := render(o) + require.NoError(t, err) + + var got struct { + StaticClients []struct { + ID string `yaml:"id"` + Secret string `yaml:"secret"` + RedirectURIs []string `yaml:"redirectURIs"` + GrantTypes []string `yaml:"grantTypes"` + Name string `yaml:"name"` + } `yaml:"staticClients"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Len(t, got.StaticClients, 2) + assert.Equal(t, "app1", got.StaticClients[0].ID) + assert.Equal(t, []string{"http://a/cb", "http://b/cb"}, got.StaticClients[0].RedirectURIs) + assert.Equal(t, []string{"client_credentials"}, got.StaticClients[1].GrantTypes) +} + +func TestRender_WithUsers_BcryptShape(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.users = []User{{Email: "u@e.com", Username: "u", Password: "p"}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + StaticPasswords []struct { + Email string `yaml:"email"` + Hash string `yaml:"hash"` + Username string `yaml:"username"` + UserID string `yaml:"userID"` + } `yaml:"staticPasswords"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Len(t, got.StaticPasswords, 1) + p := got.StaticPasswords[0] + assert.Equal(t, "u@e.com", p.Email) + assert.True(t, strings.HasPrefix(p.Hash, "$2a$") || strings.HasPrefix(p.Hash, "$2b$"), "bcrypt prefix") + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(p.Hash), []byte("p"))) + assert.NotEmpty(t, p.UserID, "userID should be auto-populated") +} + +func TestRender_WithConnectors(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.connectors = []connector{{Type: ConnectorMock, ID: "mock", Name: "Mock"}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + Connectors []struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Name string `yaml:"name"` + } `yaml:"connectors"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Len(t, got.Connectors, 1) + assert.Equal(t, "mockCallback", got.Connectors[0].Type) +} + +func TestRender_NoAuthSource_Errors(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.enablePasswordDB = false + // no connectors + + _, err := render(o) + assert.ErrorIs(t, err, ErrNoAuthSource) +} + +func TestRender_IssuerRequired(t *testing.T) { + o := defaultOptions() + // issuer empty + _, err := render(o) + assert.Error(t, err, "render must error when issuer is empty") +} + +func TestRender_BcryptCost(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.users = []User{{Email: "u@e.com", Username: "u", Password: "p"}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + StaticPasswords []struct { + Hash string `yaml:"hash"` + } `yaml:"staticPasswords"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + cost, err := bcrypt.Cost([]byte(got.StaticPasswords[0].Hash)) + require.NoError(t, err) + // Dex v2.45+ enforces a minimum bcrypt cost of 10; we use exactly 10 to + // satisfy that constraint while staying well below the production default (14). + assert.Equal(t, 10, cost, "bcrypt cost must be exactly 10: meets Dex minimum, fast enough for tests") +} + +func TestRender_UserWithExplicitUserID(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.users = []User{{Email: "u@e.com", Username: "u", Password: "p", UserID: "fixed-id-123"}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + StaticPasswords []struct { + UserID string `yaml:"userID"` + } `yaml:"staticPasswords"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Len(t, got.StaticPasswords, 1) + assert.Equal(t, "fixed-id-123", got.StaticPasswords[0].UserID) +} + +func TestRender_PasswordConnector_SetWhenPasswordDBEnabled(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + // enablePasswordDB is true by default. + + out, err := render(o) + require.NoError(t, err) + + var got struct { + OAuth2 struct { + PasswordConnector string `yaml:"passwordConnector"` + } `yaml:"oauth2"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + assert.Equal(t, "local", got.OAuth2.PasswordConnector, + "oauth2.passwordConnector must be 'local' when enablePasswordDB is true") +} + +func TestRender_PasswordConnector_OmitWhenPasswordDBDisabled(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + o.enablePasswordDB = false + o.connectors = []connector{{Type: ConnectorMock, ID: "mock", Name: "Mock"}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + OAuth2 struct { + PasswordConnector string `yaml:"passwordConnector"` + } `yaml:"oauth2"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + assert.Empty(t, got.OAuth2.PasswordConnector, + "oauth2.passwordConnector must be omitted when enablePasswordDB is false") +} + +func TestRender_YAMLInjection_NameField(t *testing.T) { + o := defaultOptions() + o.issuer = "http://h:5556/dex" + // A name containing a newline + bogus YAML that would break a + // template-based renderer. yaml.Marshal must escape it such that + // the round-tripped value equals the input verbatim. + malicious := "real-name\nmalicious_key: poison" + o.clients = []Client{{ID: "c", Secret: "s", Name: malicious}} + + out, err := render(o) + require.NoError(t, err) + + var got struct { + StaticClients []struct { + Name string `yaml:"name"` + } `yaml:"staticClients"` + Malicious string `yaml:"malicious_key,omitempty"` + } + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Len(t, got.StaticClients, 1) + assert.Equal(t, malicious, got.StaticClients[0].Name, "injected characters must round-trip as data, not structure") + assert.Empty(t, got.Malicious, "structural injection must not create a top-level key") +} + +func TestParseImageTag(t *testing.T) { + cases := map[string]string{ + "dexidp/dex:v2.45.1": "v2.45.1", + "dexidp/dex:master": "master", + "dexidp/dex:latest-alpine": "latest-alpine", + "ghcr.io/dexidp/dex:v2.46.0": "v2.46.0", + "localhost:5000/dex:v2.46.0": "v2.46.0", + "dexidp/dex": "", + "dexidp/dex@sha256:abcd": "", + "dexidp/dex:v2.46.0@sha256:deadbeefcafe": "v2.46.0", + } + for in, want := range cases { + assert.Equal(t, want, parseImageTag(in), "input=%q", in) + } +} + +func TestImageSupportsClientCredentials(t *testing.T) { + good := []string{"master", "latest", "master-alpine", "latest-distroless", + "v2.46.0", "v2.46.1", "v2.47.0", "v3.0.0", + "custom-build-abc", // unknown non-semver → trusted + } + for _, tag := range good { + assert.True(t, imageSupportsClientCredentials(tag), "tag=%q should be supported", tag) + } + + bad := []string{"v2.45.0", "v2.45.1", "v2.45.1-alpine", "v2.44.0", + "v2.0.0", "v1.99.99"} + for _, tag := range bad { + assert.False(t, imageSupportsClientCredentials(tag), "tag=%q should NOT be supported", tag) + } +} diff --git a/modules/dex/dex.go b/modules/dex/dex.go new file mode 100644 index 0000000000..1021bd9c86 --- /dev/null +++ b/modules/dex/dex.go @@ -0,0 +1,248 @@ +// Package dex provides a testcontainers module for the Dex OIDC provider. +// +// Supported grants: authorization_code, refresh_token, password. The +// client_credentials grant requires Dex ≥ v2.46.0 (or dexidp/dex:master) +// with WithEnableClientCredentials() — this sets the +// DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true env var that gates the +// feature. Earlier releases (v2.45.x and below) return unsupported_grant_type. +// +// Example: +// +// ctx := context.Background() +// c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", +// dex.WithClient(dex.Client{ID: "my-app", Secret: "s3cr3t", RedirectURIs: []string{"http://localhost/callback"}}), +// dex.WithUser(dex.User{Email: "u@example.com", Username: "u", Password: "p"}), +// ) +// defer testcontainers.TerminateContainer(c) +package dex + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + httpPort = "5556/tcp" + grpcPort = "5557/tcp" + + configPath = "/etc/dex/dex.yml" +) + +// DexContainer is a running Dex OIDC provider. +type DexContainer struct { + testcontainers.Container + issuer string +} + +// Run starts Dex. The image is required (tc-go convention). Module options +// (WithClient, WithUser, WithIssuer, ...) and generic tc-go customizers may +// be mixed in the opts slice. +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DexContainer, error) { + settings := defaultOptions() + for _, opt := range opts { + if apply, ok := opt.(Option); ok { + apply(&settings) + } + } + + container := &DexContainer{} + + postStart := func(ctx context.Context, c testcontainers.Container) error { + if settings.issuer == "" { + host, err := c.Host(ctx) + if err != nil { + return fmt.Errorf("dex: host: %w", err) + } + mapped, err := c.MappedPort(ctx, httpPort) + if err != nil { + return fmt.Errorf("dex: mapped port: %w", err) + } + settings.issuer = fmt.Sprintf("http://%s:%s/dex", host, mapped.Port()) + } + container.issuer = settings.issuer + + yml, err := render(settings) + if err != nil { + return fmt.Errorf("dex: render yaml: %w", err) + } + if err := c.CopyToContainer(ctx, yml, configPath, 0o644); err != nil { + return fmt.Errorf("dex: copy yaml: %w", err) + } + + // Wait for Dex to serve discovery + gRPC port before returning. + ready := wait.ForAll( + wait.ForHTTP("/dex/.well-known/openid-configuration"). + WithPort(httpPort). + WithStartupTimeout(60*time.Second), + wait.ForListeningPort(grpcPort). + WithStartupTimeout(60*time.Second), + ) + return ready.WaitUntilReady(ctx, c) + } + + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(httpPort, grpcPort), + testcontainers.WithEntrypoint("/bin/sh"), + testcontainers.WithCmd("-c", + "while [ ! -f "+configPath+" ]; do sleep 0.1; done; "+ + "exec dex serve "+configPath), + testcontainers.WithLifecycleHooks(testcontainers.ContainerLifecycleHooks{ + PostStarts: []testcontainers.ContainerHook{postStart}, + }), + // No wait strategy on the request — Dex would fail readiness before the + // PostStart hook copies the YAML. The hook performs its own wait after + // copying. + } + if settings.logger != nil { + moduleOpts = append(moduleOpts, + testcontainers.WithLogConsumers(newSlogConsumer(settings.logger))) + } + if settings.enableClientCredentials { + moduleOpts = append(moduleOpts, + testcontainers.WithEnv(map[string]string{ + "DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT": "true", + })) + if tag := parseImageTag(img); tag != "" && !imageSupportsClientCredentials(tag) { + warnLogger := settings.logger + if warnLogger == nil { + warnLogger = slog.Default() + } + warnLogger.Warn("dex: client_credentials grant requested but image tag predates feature flag support; token exchanges will fail", + slog.String("image", img), + slog.String("tag", tag), + slog.String("minimum_tag", "v2.46.0"), + slog.String("workaround", "use dexidp/dex:master or dexidp/dex:latest")) + } + } + moduleOpts = append(moduleOpts, opts...) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) + if ctr != nil { + container.Container = ctr + } + if err != nil { + return container, fmt.Errorf("dex: run: %w", err) + } + return container, nil +} + +// IssuerURL returns Dex's issuer URL. Empty if Run has not started. +func (c *DexContainer) IssuerURL() string { return c.issuer } + +// ConfigEndpoint returns the OIDC discovery document URL. +func (c *DexContainer) ConfigEndpoint() string { + return c.issuer + "/.well-known/openid-configuration" +} + +// JWKSEndpoint returns the JSON Web Key Set URL. +func (c *DexContainer) JWKSEndpoint() string { return c.issuer + "/keys" } + +// TokenEndpoint returns the OAuth2 token URL. +func (c *DexContainer) TokenEndpoint() string { return c.issuer + "/token" } + +// AuthEndpoint returns the OAuth2 authorization URL. +func (c *DexContainer) AuthEndpoint() string { return c.issuer + "/auth" } + +// GRPCEndpoint returns host:mappedPort for Dex's gRPC admin API. Empty +// before Run has started. +func (c *DexContainer) GRPCEndpoint() string { + if c.Container == nil { + return "" + } + host, err := c.Host(context.Background()) + if err != nil { + return "" + } + port, err := c.MappedPort(context.Background(), grpcPort) + if err != nil { + return "" + } + return fmt.Sprintf("%s:%s", host, port.Port()) +} + +// grpcEndpoint resolves host:mappedPort for Dex's gRPC admin API. Unlike +// GRPCEndpoint(), it propagates Docker API errors so callers can tell a +// pre-start container apart from a Docker-layer failure mid-test. +func (c *DexContainer) grpcEndpoint(ctx context.Context) (string, error) { + if c.Container == nil { + return "", fmt.Errorf("dex: container not started") + } + host, err := c.Host(ctx) + if err != nil { + return "", fmt.Errorf("dex: host: %w", err) + } + port, err := c.MappedPort(ctx, grpcPort) + if err != nil { + return "", fmt.Errorf("dex: mapped grpc port: %w", err) + } + return fmt.Sprintf("%s:%s", host, port.Port()), nil +} + +// parseImageTag extracts the tag portion from a Docker image reference like +// "dexidp/dex:v2.45.1" or "ghcr.io/dex:v2.45.1-alpine". Returns empty +// string when no tag is present or the image uses a digest reference. +func parseImageTag(img string) string { + // Drop any digest suffix; tag lives after the LAST colon that comes + // after the LAST slash (so we don't mistake the port in a registry + // like localhost:5000/dex for a tag). + if idx := strings.LastIndex(img, "@"); idx >= 0 { + img = img[:idx] + } + lastSlash := strings.LastIndex(img, "/") + colon := strings.LastIndex(img[lastSlash+1:], ":") + if colon < 0 { + return "" + } + return img[lastSlash+1+colon+1:] +} + +// imageSupportsClientCredentials reports whether a Dex image tag is known +// to include the client_credentials feature flag. +// +// Known-good tags: +// - master, latest (always rebuild from HEAD) +// - any tag ≥ v2.46.0 once released +// +// Known-bad tags: v2.45.x and earlier. +// Unknown tags (custom builds, non-semver) return true — the caller is +// presumed to know what they're doing. +func imageSupportsClientCredentials(tag string) bool { + // Strip common tag suffixes the Dex image uses: -alpine, -distroless. + base := tag + for _, suffix := range []string{"-alpine", "-distroless"} { + base = strings.TrimSuffix(base, suffix) + } + switch base { + case "master", "latest": + return true + } + // Parse semver vX.Y.Z. Accept anything ≥ v2.46.0. + if !strings.HasPrefix(base, "v") { + // Non-semver custom build — give the caller the benefit of the doubt. + return true + } + parts := strings.SplitN(strings.TrimPrefix(base, "v"), ".", 3) + if len(parts) < 2 { + return true // odd shape — trust the caller + } + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return true + } + switch { + case major > 2: + return true + case major < 2: + return false + default: // major == 2 + return minor >= 46 + } +} diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go new file mode 100644 index 0000000000..d8686903eb --- /dev/null +++ b/modules/dex/dex_test.go @@ -0,0 +1,672 @@ +package dex_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/dex" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +const ( + dexImage = "dexidp/dex:v2.45.1" + dexImageWithCC = "dexidp/dex:master" +) + +func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + assert.NotEmpty(t, c.IssuerURL()) + assert.Equal(t, c.IssuerURL()+"/.well-known/openid-configuration", c.ConfigEndpoint()) + assert.Equal(t, c.IssuerURL()+"/keys", c.JWKSEndpoint()) + assert.Equal(t, c.IssuerURL()+"/token", c.TokenEndpoint()) + assert.Equal(t, c.IssuerURL()+"/auth", c.AuthEndpoint()) + assert.NotEmpty(t, c.GRPCEndpoint()) + + resp, err := http.Get(c.ConfigEndpoint()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode, "discovery endpoint must return 200") + + var doc map[string]any + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &doc)) + + assert.Equal(t, c.IssuerURL(), doc["issuer"]) + assert.Equal(t, c.JWKSEndpoint(), doc["jwks_uri"]) + assert.Equal(t, c.TokenEndpoint(), doc["token_endpoint"]) + assert.Equal(t, c.AuthEndpoint(), doc["authorization_endpoint"]) +} + +func TestRun_WithIssuerOverride(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const issuer = "http://dex-override.test:5556/dex" + + c, err := dex.Run(ctx, dexImage, + dex.WithIssuer(issuer), + dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + assert.Equal(t, issuer, c.IssuerURL()) + assert.Equal(t, issuer+"/.well-known/openid-configuration", c.ConfigEndpoint()) + + // Cross-check: discovery doc MUST echo the overridden issuer, proving + // the YAML was rendered with the override and Dex booted against it. + host, err := c.Host(ctx) + require.NoError(t, err) + mapped, err := c.MappedPort(ctx, "5556/tcp") + require.NoError(t, err) + + reachable := fmt.Sprintf("http://%s:%s/dex/.well-known/openid-configuration", host, mapped.Port()) + resp, err := http.Get(reachable) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + var doc map[string]any + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &doc)) + assert.Equal(t, issuer, doc["issuer"], "discovery doc must echo the overridden issuer") +} + +func TestGRPC_AddRemoveClient(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cl := dex.Client{ + ID: "runtime-app", + Secret: "s", + RedirectURIs: []string{"http://localhost/cb"}, + Name: "Runtime App", + } + require.NoError(t, c.AddClient(ctx, cl)) + + // Idempotency: second Add returns ErrClientExists. + err = c.AddClient(ctx, cl) + assert.ErrorIs(t, err, dex.ErrClientExists) + + // Removal succeeds. + require.NoError(t, c.RemoveClient(ctx, cl.ID)) + + // Second remove errors (not-found). + err = c.RemoveClient(ctx, cl.ID) + assert.Error(t, err) +} + +func TestGRPC_AddRemoveUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + u := dex.User{Email: "runtime@example.com", Username: "runtime", Password: "p"} + require.NoError(t, c.AddUser(ctx, u)) + + // Duplicate add errors. + err = c.AddUser(ctx, u) + assert.ErrorIs(t, err, dex.ErrUserExists) + + // Removal succeeds. + require.NoError(t, c.RemoveUser(ctx, u.Email)) + + // Second removal errors. + err = c.RemoveUser(ctx, u.Email) + assert.Error(t, err) +} + +func TestWithLogger_CapturesDexOutput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + var buf safeBuffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + c, err := dex.Run(ctx, dexImage, + dex.WithLogger(logger), + dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + // Poll for the "listening on" line that Dex always emits at the end of + // its startup sequence. This proves the log pipe is fully wired — not + // just that boot started, but that log output flowed through after the + // container was declared ready. + // + // NOTE: Dex v2.x does not log per-operation gRPC events (CreateClient, + // CreatePassword, etc.) — only boot and key-rotation events reach the + // log stream. "listening on" is the last boot-time line and therefore + // the strongest stable signal we can assert on. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), "listening on") { + return + } + time.Sleep(50 * time.Millisecond) + } + + // Fallback: even if the specific string drifted across Dex + // versions, we MUST have captured at least one line. An empty buffer + // means the log pipe never wired up. + require.NotEmpty(t, buf.String(), "logger captured no output — pipe not wired") + t.Logf("captured logs (first 500 bytes):\n%.500s", buf.String()) + t.Fatalf("expected \"listening on\" in captured output") +} + +// safeBuffer is a bytes.Buffer wrapper safe for concurrent writes from +// the LogConsumer goroutine and reads from the test goroutine. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (s *safeBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *safeBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.String() +} + +func TestAuthCode_PasswordConnector_Basic(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const redirectURI = "http://localhost:18080/cb" + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "e2e-app", + Secret: "e2e-secret", + RedirectURIs: []string{redirectURI}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Name: "E2E App", + }), + dex.WithUser(dex.User{ + Email: "alice@example.com", + Username: "alice", + Password: "pass", + }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := oauth2.Config{ + ClientID: "e2e-app", + ClientSecret: "e2e-secret", + RedirectURL: redirectURI, + Endpoint: oauth2.Endpoint{ + AuthURL: c.AuthEndpoint(), + TokenURL: c.TokenEndpoint(), + }, + Scopes: []string{"openid", "email", "profile"}, + } + + tok := drivePasswordAuthCode(t, ctx, cfg, "alice@example.com", "pass") + assert.NotEmpty(t, tok.AccessToken) + + idToken, ok := tok.Extra("id_token").(string) + require.True(t, ok, "id_token missing from response") + assert.NotEmpty(t, idToken) +} + +func TestAuthCode_RefreshToken(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const redirectURI = "http://localhost:18080/cb" + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "e2e", Secret: "s", Name: "E2E", + RedirectURIs: []string{redirectURI}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }), + dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := oauth2.Config{ + ClientID: "e2e", + ClientSecret: "s", + RedirectURL: redirectURI, + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid", "offline_access"}, + } + + tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") + require.NotEmpty(t, tok.RefreshToken, "offline_access scope should yield refresh_token") + + // Swap access_token to force a refresh via the token source. + expired := *tok + expired.AccessToken = "invalid" + expired.Expiry = time.Now().Add(-1 * time.Hour) + + src := cfg.TokenSource(ctx, &expired) + newTok, err := src.Token() + require.NoError(t, err, "refresh exchange failed") + assert.NotEqual(t, tok.AccessToken, newTok.AccessToken, "refresh yields new access token") +} + +func TestAuthCode_MultipleRedirectURIs(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + uris := []string{"http://localhost:18080/cb", "http://localhost:18090/cb"} + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "e2e", Secret: "s", Name: "E2E", + RedirectURIs: uris, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }), + dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + for _, uri := range uris { + cfg := oauth2.Config{ + ClientID: "e2e", + ClientSecret: "s", + RedirectURL: uri, + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") + assert.NotEmpty(t, tok.AccessToken, "uri=%s", uri) + } +} + +func TestClientCredentials_UnsupportedByLocalConnectors(t *testing.T) { + // Locks in a known Dex limitation: the OAuth2 client_credentials grant + // is NOT implemented for the built-in password connector or mockCallback + // in Dex v2.x. Only upstream OIDC/LDAP connectors implement the + // ConnectorWithClientCredentials interface. This test asserts the + // documented failure mode so regressions are surfaced early if Dex + // ever adds local-connector CC support. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "svc", Secret: "s", Name: "Service", + GrantTypes: []string{"client_credentials"}, + }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := clientcredentials.Config{ + ClientID: "svc", + ClientSecret: "s", + TokenURL: c.TokenEndpoint(), + AuthStyle: oauth2.AuthStyleInParams, + } + _, err = cfg.TokenSource(ctx).Token() + require.Error(t, err, "Dex should reject CC against local connectors") + + // The error surfaces as an OAuth2 token response error; the wire + // message is "unsupported_grant_type" or similar. Match loosely so + // minor Dex wording changes don't flake. + msg := strings.ToLower(err.Error()) + assert.True(t, + strings.Contains(msg, "unsupported") || strings.Contains(msg, "grant"), + "expected grant-related error, got: %v", err) +} + +func TestPasswordGrant_ROPC(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "cli", Secret: "s", Name: "CLI", + GrantTypes: []string{"password"}, + }), + dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := oauth2.Config{ + ClientID: "cli", + ClientSecret: "s", + Endpoint: oauth2.Endpoint{TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + tok, err := cfg.PasswordCredentialsToken(ctx, "a@e.com", "p") + require.NoError(t, err) + assert.NotEmpty(t, tok.AccessToken) +} + +func TestMultipleClients_OneInstance(t *testing.T) { + // Registers two clients on a single Dex instance and verifies both can + // independently obtain tokens. The "svc" client uses the password grant + // (Dex's M2M pattern — see TestClientCredentials); the "web" client uses + // the authorization_code flow. Tokens from distinct clients must differ. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "svc", Secret: "s", Name: "SVC", + GrantTypes: []string{"password"}, + }), + dex.WithClient(dex.Client{ + ID: "web", Secret: "s", Name: "Web", + RedirectURIs: []string{"http://localhost/cb"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }), + dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + // Service-account client via password grant. + svcCfg := oauth2.Config{ + ClientID: "svc", + ClientSecret: "s", + Endpoint: oauth2.Endpoint{TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + svcTok, err := svcCfg.PasswordCredentialsToken(ctx, "a@e.com", "p") + require.NoError(t, err) + require.NotEmpty(t, svcTok.AccessToken) + + // Web client via auth_code flow. + webCfg := oauth2.Config{ + ClientID: "web", ClientSecret: "s", + RedirectURL: "http://localhost/cb", + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + webTok := drivePasswordAuthCode(t, ctx, webCfg, "a@e.com", "p") + assert.NotEmpty(t, webTok.AccessToken) + + assert.NotEqual(t, svcTok.AccessToken, webTok.AccessToken, "tokens from distinct clients must differ") +} + +func TestMockConnector_IssuesToken(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage, + dex.WithConnector(dex.ConnectorMock, "mock", "Mock Connector"), + dex.WithClient(dex.Client{ + ID: "e2e", Secret: "s", Name: "E2E", + RedirectURIs: []string{"http://localhost/cb"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := oauth2.Config{ + ClientID: "e2e", ClientSecret: "s", + RedirectURL: "http://localhost/cb", + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + + // Drive the /auth URL with connector_id=mock so Dex skips the login form. + authURL := cfg.AuthCodeURL("state-mock") + "&connector_id=mock" + req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil) + require.NoError(t, err) + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { + return http.ErrUseLastResponse + } + return nil + }, + } + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + loc := resp.Request.URL + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + parsed, perr := url.Parse(resp.Header.Get("Location")) + require.NoError(t, perr) + loc = parsed + } + code := loc.Query().Get("code") + require.NotEmpty(t, code, "mockCallback should redirect with ?code=...; got %q", loc.String()) + + tok, err := cfg.Exchange(ctx, code) + require.NoError(t, err) + assert.NotEmpty(t, tok.AccessToken) +} + +func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImage) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + // Seed client + user at runtime. + require.NoError(t, c.AddClient(ctx, dex.Client{ + ID: "late-app", Secret: "s", + RedirectURIs: []string{"http://localhost/cb"}, + Name: "Late App", + })) + require.NoError(t, c.AddUser(ctx, dex.User{ + Email: "late@e.com", Username: "late", Password: "p", + })) + + cfg := oauth2.Config{ + ClientID: "late-app", ClientSecret: "s", + RedirectURL: "http://localhost/cb", + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + tok := drivePasswordAuthCode(t, ctx, cfg, "late@e.com", "p") + assert.NotEmpty(t, tok.AccessToken) + + // Remove user — subsequent login attempt must fail. + require.NoError(t, c.RemoveUser(ctx, "late@e.com")) + + // Do the login dance manually — drivePasswordAuthCode uses require.NoError + // which would abort the test on the expected failure. + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { + return http.ErrUseLastResponse + } + return nil + }, + } + + authURL := cfg.AuthCodeURL("s1") + resp, err := client.Get(authURL) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + loginURL := resp.Request.URL.String() + + // Use the same action-extractor approach as the helper — but for a + // negative path we just POST blindly to the request URL. Dex's + // local login endpoint accepts POSTs at the same URL the GET returned. + _ = body + + form := url.Values{"login": {"late@e.com"}, "password": {"p"}} + r2, err := client.Post(loginURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + body2, _ := io.ReadAll(r2.Body) + r2.Body.Close() + // Dex renders the login page again with a failure marker. The exact + // copy ("Invalid Email Address and password") may drift across + // versions; match loosely. + lower := strings.ToLower(string(body2)) + assert.True(t, + strings.Contains(lower, "invalid") || strings.Contains(lower, "authentication failed") || r2.StatusCode >= 400, + "removed user should fail login; got status=%d body-prefix=%.200q", r2.StatusCode, lower) +} + +func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + net, err := network.New(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = net.Remove(ctx) }) + + const issuer = "http://dex:5556/dex" + + c, err := dex.Run(ctx, dexImage, + dex.WithIssuer(issuer), + dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + network.WithNetwork([]string{"dex"}, net), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + // Sidecar: curl the discovery endpoint through the network alias. + sidecarReq := testcontainers.ContainerRequest{ + Image: "curlimages/curl:8.10.1", + Networks: []string{net.Name}, + NetworkAliases: map[string][]string{net.Name: {"sidecar"}}, + Cmd: []string{ + "sh", "-c", + "curl -fsS http://dex:5556/dex/.well-known/openid-configuration", + }, + WaitingFor: wait.ForExit(), + } + sidecar, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: sidecarReq, + Started: true, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(sidecar) }) + + logs, err := sidecar.Logs(ctx) + require.NoError(t, err) + body, err := io.ReadAll(logs) + require.NoError(t, err) + + assert.Contains(t, string(body), issuer, + "discovery doc fetched via network alias must echo overridden issuer") +} + +func TestClientCredentials_WithFeatureFlag(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + c, err := dex.Run(ctx, dexImageWithCC, + dex.WithEnableClientCredentials(), + dex.WithClient(dex.Client{ + ID: "svc", Secret: "s", Name: "Service", + GrantTypes: []string{"client_credentials"}, + }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := clientcredentials.Config{ + ClientID: "svc", + ClientSecret: "s", + TokenURL: c.TokenEndpoint(), + AuthStyle: oauth2.AuthStyleInParams, + } + tok, err := cfg.TokenSource(ctx).Token() + require.NoError(t, err, "CC grant should succeed with feature flag enabled on master image") + assert.NotEmpty(t, tok.AccessToken) +} + +func TestConsumer_IDTokenVerifies_CoreosOIDC(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const redirectURI = "http://localhost:18080/cb" + + c, err := dex.Run(ctx, dexImage, + dex.WithClient(dex.Client{ + ID: "e2e", Secret: "s", Name: "E2E", + RedirectURIs: []string{redirectURI}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + }), + dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + + cfg := oauth2.Config{ + ClientID: "e2e", ClientSecret: "s", + RedirectURL: redirectURI, + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid", "email", "profile"}, + } + tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") + + rawIDToken, ok := tok.Extra("id_token").(string) + require.True(t, ok, "id_token missing from response") + + provider, err := oidc.NewProvider(ctx, c.IssuerURL()) + require.NoError(t, err) + + verifier := provider.Verifier(&oidc.Config{ClientID: "e2e"}) + idToken, err := verifier.Verify(ctx, rawIDToken) + require.NoError(t, err, "coreos/go-oidc failed to verify Dex-issued token") + + // Dex maps the password-connector username to the "name" claim (profile scope). + // "preferred_username" is not set in the ID token for the built-in password connector. + var claims struct { + Email string `json:"email"` + Name string `json:"name"` + Sub string `json:"sub"` + } + require.NoError(t, idToken.Claims(&claims)) + assert.Equal(t, "a@e.com", claims.Email) + assert.Equal(t, "a", claims.Name) + assert.NotEmpty(t, claims.Sub) +} diff --git a/modules/dex/dextest_helpers_test.go b/modules/dex/dextest_helpers_test.go new file mode 100644 index 0000000000..463d89132f --- /dev/null +++ b/modules/dex/dextest_helpers_test.go @@ -0,0 +1,111 @@ +package dex_test + +import ( + "context" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// drivePasswordAuthCode exercises Dex's /dex/auth/local password connector. +// It performs: GET /dex/auth?... → follow to /dex/auth/local?req=... +// → parse the form action URL from the page body +// → POST credentials to the form action → follow the redirect to cfg.RedirectURL with ?code=... → +// exchange the code for a token. +// +// Returns the token response. Uses require (fatal) on protocol errors so +// callers don't need their own defensive checks. +func drivePasswordAuthCode(t *testing.T, ctx context.Context, cfg oauth2.Config, email, password string) *oauth2.Token { + t.Helper() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + + client := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Stop at the redirect back to our registered redirect URI — + // we parse the code from that URL. + if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { + return http.ErrUseLastResponse + } + return nil + }, + } + + authURL := cfg.AuthCodeURL("state-xyz", oauth2.AccessTypeOffline) + + // Step 1: GET /auth → follow redirects until the login form page. + req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + pageBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + // The form action is a relative path like /dex/auth/local/login?back=&state=. + // Extract it from the HTML rather than using resp.Request.URL — the page URL + // (/dex/auth/local?req=...) and the form action (/dex/auth/local/login?...) differ. + formAction := extractFormAction(t, string(pageBody)) + + // Resolve the action against the base URL of the login page response. + base := resp.Request.URL + actionURL, err := base.Parse(formAction) + require.NoError(t, err, "could not resolve form action %q against %q", formAction, base) + + // Step 2: POST the login form. + form := url.Values{"login": {email}, "password": {password}} + postReq, err := http.NewRequestWithContext(ctx, "POST", actionURL.String(), strings.NewReader(form.Encode())) + require.NoError(t, err) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + postResp, err := client.Do(postReq) + require.NoError(t, err) + defer postResp.Body.Close() + _, _ = io.Copy(io.Discard, postResp.Body) + + // The code lands in the redirect URL. Depending on whether the client + // stopped at a 302 Location or followed through to the redirect URI as + // the final request, check both. + var codeLoc *url.URL + if postResp.Request.URL != nil && strings.HasPrefix(postResp.Request.URL.String(), cfg.RedirectURL) { + codeLoc = postResp.Request.URL + } else if loc := postResp.Header.Get("Location"); loc != "" { + parsed, perr := url.Parse(loc) + require.NoError(t, perr) + codeLoc = parsed + } + require.NotNil(t, codeLoc, "expected redirect to %q with ?code=…, got final URL %q", + cfg.RedirectURL, postResp.Request.URL) + + code := codeLoc.Query().Get("code") + require.NotEmpty(t, code, "no ?code= in redirect URL %q", codeLoc.String()) + + // Step 3: Exchange the code for a token using a context-aware HTTP client + // so the exchange honours the test deadline. + httpCtxClient := &http.Client{} + tokenCtx := context.WithValue(ctx, oauth2.HTTPClient, httpCtxClient) + tok, err := cfg.Exchange(tokenCtx, code) + require.NoError(t, err, "token exchange") + return tok +} + +// extractFormAction parses the value of the first
attribute +// from an HTML page body. Uses simple string search — sufficient for Dex's +// single-form login page without pulling in golang.org/x/net/html. +func extractFormAction(t *testing.T, body string) string { + t.Helper() + _, rest, ok := strings.Cut(body, `action="`) + require.True(t, ok, "could not find form action in login page HTML") + raw, _, ok := strings.Cut(rest, `"`) + require.True(t, ok, "malformed form action attribute in login page HTML") + // HTML-unescape & → & + return strings.ReplaceAll(raw, "&", "&") +} diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go new file mode 100644 index 0000000000..762ccb85fd --- /dev/null +++ b/modules/dex/examples_test.go @@ -0,0 +1,73 @@ +package dex_test + +import ( + "context" + "fmt" + "log" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/dex" + "golang.org/x/oauth2" +) + +func ExampleRun_authorizationCode() { + ctx := context.Background() + c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", + dex.WithClient(dex.Client{ + ID: "my-app", + Secret: "secret", + RedirectURIs: []string{"http://localhost:8080/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Name: "My App", + }), + dex.WithUser(dex.User{ + Email: "u@example.com", Username: "u", Password: "p", + }), + ) + if err != nil { + log.Fatalf("run: %v", err) + } + defer testcontainers.TerminateContainer(c) + + _ = oauth2.Config{ + ClientID: "my-app", + ClientSecret: "secret", + RedirectURL: "http://localhost:8080/callback", + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid", "email"}, + } + fmt.Println("has issuer:", c.IssuerURL() != "") + // Output: has issuer: true +} + +func ExampleRun_passwordGrant() { + // Dex's recommended machine-to-machine pattern: ROPC with a dedicated + // service-account user. (client_credentials requires an upstream + // connector — see module README.) + ctx := context.Background() + c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", + dex.WithClient(dex.Client{ + ID: "svc", Secret: "s", Name: "Service", + GrantTypes: []string{"password"}, + }), + dex.WithUser(dex.User{ + Email: "svc@svc.local", Username: "svc", Password: "svc-secret", + }), + ) + if err != nil { + log.Fatalf("run: %v", err) + } + defer testcontainers.TerminateContainer(c) + + cfg := oauth2.Config{ + ClientID: "svc", ClientSecret: "s", + Endpoint: oauth2.Endpoint{TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + tok, err := cfg.PasswordCredentialsToken(ctx, "svc@svc.local", "svc-secret") + if err != nil { + log.Fatalf("token: %v", err) + } + fmt.Println("has access token:", tok.AccessToken != "") + // Output: has access token: true +} diff --git a/modules/dex/go.mod b/modules/dex/go.mod new file mode 100644 index 0000000000..2426fe693c --- /dev/null +++ b/modules/dex/go.mod @@ -0,0 +1,71 @@ +module github.com/testcontainers/testcontainers-go/modules/dex + +go 1.25.0 + +toolchain go1.25.9 + +require ( + github.com/coreos/go-oidc/v3 v3.18.0 + github.com/dexidp/dex/api/v2 v2.4.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.42.0 + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/grpc v1.75.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/modules/dex/go.sum b/modules/dex/go.sum new file mode 100644 index 0000000000..5f6c17b450 --- /dev/null +++ b/modules/dex/go.sum @@ -0,0 +1,157 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= +github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go new file mode 100644 index 0000000000..fc4ac5b8fd --- /dev/null +++ b/modules/dex/grpc.go @@ -0,0 +1,148 @@ +package dex + +import ( + "context" + "fmt" + + "github.com/dexidp/dex/api/v2" + "golang.org/x/crypto/bcrypt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// AddClient creates a client via Dex's gRPC admin API. +// +// Note: Dex's api.Client proto has no grant_types field — clients added this +// way inherit Dex's defaults (authorization_code + refresh_token). For +// custom grants, register the client pre-start via WithClient. +// +// Not safe for concurrent use. +func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { + target, err := c.grpcEndpoint(ctx) + if err != nil { + return err + } + conn, err := dial(target) + if err != nil { + return err + } + defer conn.Close() + + resp, err := api.NewDexClient(conn).CreateClient(ctx, &api.CreateClientReq{ + Client: &api.Client{ + Id: cl.ID, + Secret: cl.Secret, + RedirectUris: cl.RedirectURIs, + Name: cl.Name, + Public: cl.Public, + }, + }) + if err != nil { + return fmt.Errorf("dex: create client: %w", err) + } + if resp.AlreadyExists { + return ErrClientExists + } + return nil +} + +// RemoveClient deletes a client by ID. +// +// Not safe for concurrent use. +func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { + target, err := c.grpcEndpoint(ctx) + if err != nil { + return err + } + conn, err := dial(target) + if err != nil { + return err + } + defer conn.Close() + + resp, err := api.NewDexClient(conn).DeleteClient(ctx, &api.DeleteClientReq{Id: id}) + if err != nil { + return fmt.Errorf("dex: delete client: %w", err) + } + if resp.NotFound { + return fmt.Errorf("dex: client %q not found", id) + } + return nil +} + +// AddUser registers a user in Dex's password DB via gRPC. +// +// Not safe for concurrent use. +func (c *DexContainer) AddUser(ctx context.Context, u User) error { + target, err := c.grpcEndpoint(ctx) + if err != nil { + return err + } + conn, err := dial(target) + if err != nil { + return err + } + defer conn.Close() + + hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("dex: bcrypt: %w", err) + } + userID := u.UserID + if userID == "" { + userID = newUUIDv4() + } + + resp, err := api.NewDexClient(conn).CreatePassword(ctx, &api.CreatePasswordReq{ + Password: &api.Password{ + Email: u.Email, + Hash: hash, + Username: u.Username, + UserId: userID, + }, + }) + if err != nil { + return fmt.Errorf("dex: create password: %w", err) + } + if resp.AlreadyExists { + return ErrUserExists + } + return nil +} + +// RemoveUser deletes a user by email. +// +// Not safe for concurrent use. +func (c *DexContainer) RemoveUser(ctx context.Context, email string) error { + target, err := c.grpcEndpoint(ctx) + if err != nil { + return err + } + conn, err := dial(target) + if err != nil { + return err + } + defer conn.Close() + + resp, err := api.NewDexClient(conn).DeletePassword(ctx, &api.DeletePasswordReq{Email: email}) + if err != nil { + return fmt.Errorf("dex: delete password: %w", err) + } + if resp.NotFound { + return fmt.Errorf("dex: user %q not found", email) + } + return nil +} + +func dial(target string) (*grpc.ClientConn, error) { + if target == "" { + return nil, fmt.Errorf("dex: grpc endpoint empty (container not started)") + } + conn, err := grpc.NewClient(target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("dex: grpc dial %s: %w", target, err) + } + return conn, nil +} diff --git a/modules/dex/log.go b/modules/dex/log.go new file mode 100644 index 0000000000..beb260d039 --- /dev/null +++ b/modules/dex/log.go @@ -0,0 +1,134 @@ +package dex + +import ( + "context" + "log/slog" + "strings" + + "github.com/testcontainers/testcontainers-go" +) + +// slogConsumer adapts a *slog.Logger to testcontainers.LogConsumer. Dex +// emits logfmt-style lines (key=value); we parse level + msg and preserve +// remaining fields as slog attrs. +// +// Stderr lines are promoted to at least slog.LevelWarn because Dex writes +// runtime errors there. +type slogConsumer struct { + logger *slog.Logger +} + +// Compile check: *slogConsumer implements testcontainers.LogConsumer. +var _ testcontainers.LogConsumer = (*slogConsumer)(nil) + +func newSlogConsumer(l *slog.Logger) *slogConsumer { + return &slogConsumer{logger: l} +} + +// Accept implements testcontainers.LogConsumer. +func (s *slogConsumer) Accept(l testcontainers.Log) { + s.accept(string(l.Content), l.LogType) +} + +// accept is the testable inner method. It takes the raw content string and +// the testcontainers log type (STDOUT/STDERR) so unit tests don't need to +// construct a real tc-go Log value. +func (s *slogConsumer) accept(content, logType string) { + line := strings.TrimRight(content, "\n") + if line == "" { + return + } + level, msg, attrs := parseLogfmt(line) + if logType == "STDERR" && level < slog.LevelWarn { + level = slog.LevelWarn + } + s.logger.LogAttrs(context.Background(), level, msg, attrs...) +} + +// parseLogfmt is a minimal logfmt parser — enough for Dex's default +// format (level=... msg=...). Unknown keys become slog attrs. Quoted +// values are unquoted. +func parseLogfmt(line string) (slog.Level, string, []slog.Attr) { + level := slog.LevelInfo + msg := "" + var attrs []slog.Attr + + pairs := tokenizeLogfmt(line) + for _, p := range pairs { + switch p.key { + case "level": + level = mapLevel(p.val) + case "msg": + msg = p.val + case "time": + // Dex timestamps are redundant — slog adds its own. + default: + attrs = append(attrs, slog.String(p.key, p.val)) + } + } + if msg == "" { + msg = line + } + return level, msg, attrs +} + +type kv struct{ key, val string } + +func tokenizeLogfmt(line string) []kv { + var out []kv + i := 0 + for i < len(line) { + for i < len(line) && line[i] == ' ' { + i++ + } + if i >= len(line) { + break + } + kStart := i + for i < len(line) && line[i] != '=' && line[i] != ' ' { + i++ + } + if i >= len(line) || line[i] != '=' { + out = append(out, kv{line[kStart:i], ""}) + continue + } + k := line[kStart:i] + i++ // skip '=' + if i < len(line) && line[i] == '"' { + i++ + vStart := i + for i < len(line) && line[i] != '"' { + if line[i] == '\\' && i+1 < len(line) { + i++ + } + i++ + } + out = append(out, kv{k, line[vStart:i]}) + if i < len(line) { + i++ + } + } else { + vStart := i + for i < len(line) && line[i] != ' ' { + i++ + } + out = append(out, kv{k, line[vStart:i]}) + } + } + return out +} + +func mapLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error", "fatal": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/modules/dex/log_test.go b/modules/dex/log_test.go new file mode 100644 index 0000000000..e77b1ceef5 --- /dev/null +++ b/modules/dex/log_test.go @@ -0,0 +1,78 @@ +package dex + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSlogConsumer_EmitsRecord(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + consumer := newSlogConsumer(logger) + + line := `time="2026-01-01T00:00:00Z" level=warning msg="test message" component=server` + consumer.accept(line, "STDOUT") + + out := buf.String() + assert.Contains(t, out, "test message") + assert.Contains(t, out, "level=WARN") + assert.Contains(t, out, "component=server") +} + +func TestSlogConsumer_StderrMinWarn(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + consumer := newSlogConsumer(logger) + + line := `level=info msg="stderr line"` + consumer.accept(line, "STDERR") + + out := buf.String() + require.NotEmpty(t, out) + assert.Contains(t, out, "level=WARN", "stderr lines promoted to at least WARN") +} + +func TestSlogConsumer_EmptyLineIgnored(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + consumer := newSlogConsumer(logger) + + consumer.accept("", "STDOUT") + consumer.accept("\n", "STDOUT") + + assert.Empty(t, buf.String(), "empty lines must not emit records") +} + +func TestParseLogfmt_UnknownKeysBecomeAttrs(t *testing.T) { + _, msg, attrs := parseLogfmt(`level=info msg=hello foo=bar baz=qux`) + assert.Equal(t, "hello", msg) + require.Len(t, attrs, 2) + assert.Equal(t, "foo", attrs[0].Key) + assert.Equal(t, "bar", attrs[0].Value.String()) + assert.Equal(t, "baz", attrs[1].Key) + assert.Equal(t, "qux", attrs[1].Value.String()) +} + +func TestParseLogfmt_QuotedValue(t *testing.T) { + _, msg, _ := parseLogfmt(`level=error msg="something went wrong: boom"`) + assert.Equal(t, "something went wrong: boom", msg) +} + +func TestMapLevel(t *testing.T) { + cases := map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warn": slog.LevelWarn, + "warning": slog.LevelWarn, + "error": slog.LevelError, + "fatal": slog.LevelError, + "bogus": slog.LevelInfo, // default + } + for in, want := range cases { + assert.Equal(t, want, mapLevel(in), "input=%q", in) + } +} diff --git a/modules/dex/options.go b/modules/dex/options.go new file mode 100644 index 0000000000..b247bf5264 --- /dev/null +++ b/modules/dex/options.go @@ -0,0 +1,135 @@ +package dex + +import ( + "log/slog" + + "github.com/testcontainers/testcontainers-go" +) + +// connector is an internal record of a WithConnector call. +type connector struct { + Type ConnectorType + ID string + Name string +} + +// options is the module-internal accumulator for Run(). +type options struct { + clients []Client + users []User + connectors []connector + issuer string // empty means derive from host:mapped at post-start + skipApprovalScreen bool + storage string + logLevel string + logger *slog.Logger + enablePasswordDB bool // default true; flipped off only if user sets ConnectorMock-only + enableClientCredentials bool +} + +func defaultOptions() options { + return options{ + skipApprovalScreen: true, + storage: "sqlite3", + logLevel: "info", + enablePasswordDB: true, + } +} + +// Compiler check: Option implements testcontainers.ContainerCustomizer. +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is a functional option for the Dex module. +type Option func(*options) + +// Customize is a no-op; Option satisfies the testcontainers.ContainerCustomizer +// interface so callers can pass Options through any API that accepts +// tc-go customizers. Real state mutation happens in Run(). +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + return nil +} + +// WithClient registers a static client in Dex's YAML config. Unlike +// gRPC-added clients, these may declare custom grant types. +func WithClient(c Client) Option { + return func(o *options) { + o.clients = append(o.clients, c) + } +} + +// WithUser registers a static password entry. The password DB connector is +// enabled by default, so no extra option is needed to consume the entry. +func WithUser(u User) Option { + return func(o *options) { + o.users = append(o.users, u) + } +} + +// WithConnector enables a Dex connector by type. For ConnectorPassword, this +// is a no-op (the password DB is enabled by default whenever WithUser is +// passed or no other connector is configured). For ConnectorMock, the +// mockCallback connector is added. +func WithConnector(t ConnectorType, id, name string) Option { + return func(o *options) { + // ConnectorPassword is handled via enablePasswordDB in the YAML template; + // appending it to o.connectors would emit a spurious `type: password` + // entry that Dex does not recognize. + if t == ConnectorPassword { + return + } + o.connectors = append(o.connectors, connector{Type: t, ID: id, Name: name}) + } +} + +// WithIssuer overrides the default host:mappedPort-derived issuer. When set, +// Run uses the fast-path (direct YAML bind-mount). Callers are responsible +// for ensuring the URL is reachable from every client (tests and sibling +// containers). +func WithIssuer(url string) Option { + return func(o *options) { + o.issuer = url + } +} + +// WithSkipApprovalScreen toggles Dex's oauth2.skipApprovalScreen. Default: true. +func WithSkipApprovalScreen(skip bool) Option { + return func(o *options) { + o.skipApprovalScreen = skip + } +} + +// WithStorage sets Dex's storage backend. Default: "sqlite3". "memory" is +// also supported by Dex. +func WithStorage(kind string) Option { + return func(o *options) { + o.storage = kind + } +} + +// WithLogger routes Dex container logs through the supplied slog.Logger. +// When nil or not called, Dex container logs are discarded. +func WithLogger(logger *slog.Logger) Option { + return func(o *options) { + o.logger = logger + } +} + +// WithLogLevel sets Dex's own --log-level flag. Valid: "debug", "info", +// "warn", "error". Default: "info". +func WithLogLevel(level string) Option { + return func(o *options) { + o.logLevel = level + } +} + +// WithEnableClientCredentials enables Dex's OAuth2 client_credentials grant +// via the DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true environment +// variable. Requires Dex ≥ v2.46.0 (not yet released at time of writing) +// or the dexidp/dex:master image tag; earlier releases do not recognize +// the flag. The module logs a warning when an older image tag is +// detected. See the module README for image compatibility. +func WithEnableClientCredentials() Option { + return func(o *options) { + o.enableClientCredentials = true + } +} diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go new file mode 100644 index 0000000000..a84ff76fee --- /dev/null +++ b/modules/dex/options_test.go @@ -0,0 +1,55 @@ +package dex + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOptions_Defaults(t *testing.T) { + o := defaultOptions() + assert.True(t, o.skipApprovalScreen) + assert.Equal(t, "sqlite3", o.storage) + assert.Equal(t, "info", o.logLevel) + assert.True(t, o.enablePasswordDB) + assert.Empty(t, o.issuer) +} + +func TestOptions_Apply(t *testing.T) { + o := defaultOptions() + applyAll := []Option{ + WithClient(Client{ID: "a", Secret: "s"}), + WithClient(Client{ID: "b"}), + WithUser(User{Email: "u@e.com", Username: "u", Password: "p"}), + WithConnector(ConnectorMock, "m", "Mock"), + WithIssuer("http://dex:5556/dex"), + WithSkipApprovalScreen(false), + WithStorage("memory"), + WithLogLevel("debug"), + WithLogger(slog.Default()), + } + for _, opt := range applyAll { + opt(&o) + } + + assert.Len(t, o.clients, 2) + assert.Equal(t, "b", o.clients[1].ID) + assert.Len(t, o.users, 1) + assert.Len(t, o.connectors, 1) + assert.Equal(t, ConnectorMock, o.connectors[0].Type) + assert.Equal(t, "m", o.connectors[0].ID) + assert.Equal(t, "Mock", o.connectors[0].Name) + assert.Equal(t, "http://dex:5556/dex", o.issuer) + assert.False(t, o.skipApprovalScreen) + assert.Equal(t, "memory", o.storage) + assert.Equal(t, "debug", o.logLevel) + assert.NotNil(t, o.logger) +} + +func TestOptions_WithConnectorPassword_IsNoOp(t *testing.T) { + o := defaultOptions() + WithConnector(ConnectorPassword, "local", "Local")(&o) + assert.Empty(t, o.connectors, "ConnectorPassword must not be appended; password DB covers it") + assert.True(t, o.enablePasswordDB, "password DB stays enabled by default") +} diff --git a/modules/dex/types.go b/modules/dex/types.go new file mode 100644 index 0000000000..9e406dd64b --- /dev/null +++ b/modules/dex/types.go @@ -0,0 +1,64 @@ +package dex + +import "errors" + +// Client is a static OAuth2 client registered with Dex. +type Client struct { + // ID is the client_id. Required. + ID string + // Secret is the client secret. Required unless Public is true. + Secret string + // RedirectURIs lists allowed redirect URIs. At least one is required for + // authorization_code clients. + RedirectURIs []string + // GrantTypes lists OAuth2 grants the client may use. Defaults to + // ["authorization_code", "refresh_token"]. Values: authorization_code, + // refresh_token, client_credentials, password. + // + // Only takes effect for clients registered via WithClient (YAML). + // Clients added at runtime via AddClient inherit Dex's defaults + // (authorization_code + refresh_token) because the gRPC api.Client proto + // has no grant_types field. + GrantTypes []string + // Public marks the client as public (no secret). Used for PKCE flows. + Public bool + // Name is the human-readable display name shown on Dex's consent screen. + Name string +} + +// User is a static password entry in Dex's password connector. +type User struct { + // Email is the user's email address. Required. + Email string + // Username is the preferred_username claim. Required. + Username string + // Password is the cleartext password. Bcrypted internally. Required. + Password string + // UserID is the stable subject claim. If empty, a UUID is generated. + UserID string +} + +// ConnectorType selects a Dex connector kind. +type ConnectorType string + +const ( + // ConnectorPassword enables Dex's built-in static password connector. + // Users must be registered via WithUser or AddUser. + ConnectorPassword ConnectorType = "password" + // ConnectorMock enables Dex's mockCallback connector — a test-only + // connector that bypasses the login form and returns a fixed user. + ConnectorMock ConnectorType = "mockCallback" +) + +var ( + // ErrClientExists is returned by AddClient when a client with the given + // ID is already registered. + ErrClientExists = errors.New("dex: client already exists") + // ErrUserExists is returned by AddUser when a user with the given email + // is already registered. + ErrUserExists = errors.New("dex: user already exists") + // ErrNoAuthSource is returned when the rendered Dex config would boot + // with no working authentication source — neither the password DB nor + // any connector. The password DB is enabled by default. + ErrNoAuthSource = errors.New("dex: no auth source configured") +) From bcdafe3f21a4cc953849b00fa4916f43cf1bb002 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Tue, 21 Apr 2026 01:56:44 -0300 Subject: [PATCH 02/21] refactor(modules/dex): rename module path to guilycst for fork-hosted distribution While the module incubates on the fork before upstream merge, consumers import it at github.com/guilycst/testcontainers-go/modules/dex so Go can resolve the package from the fork repo without a replace directive. Reverts to github.com/testcontainers/testcontainers-go/modules/dex once upstreamed. --- modules/dex/README.md | 7 ++++++- modules/dex/dex_test.go | 2 +- modules/dex/examples_test.go | 2 +- modules/dex/go.mod | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/dex/README.md b/modules/dex/README.md index 9a20a5402e..2599ad53fd 100644 --- a/modules/dex/README.md +++ b/modules/dex/README.md @@ -9,9 +9,14 @@ The Testcontainers module for [Dex](https://github.com/dexidp/dex), a CNCF OIDC ## Adding this module to your project dependencies ``` -go get github.com/testcontainers/testcontainers-go/modules/dex +go get github.com/guilycst/testcontainers-go/modules/dex ``` +> Note: this is a fork-hosted distribution path on `guilycst/testcontainers-go` +> while the module incubates. When upstreamed to +> `testcontainers/testcontainers-go`, the module path reverts to +> `github.com/testcontainers/testcontainers-go/modules/dex`. + ## Usage example ```go diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index d8686903eb..b0cfd80547 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/dex" + "github.com/guilycst/testcontainers-go/modules/dex" "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" "golang.org/x/oauth2" diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go index 762ccb85fd..6feb059061 100644 --- a/modules/dex/examples_test.go +++ b/modules/dex/examples_test.go @@ -6,7 +6,7 @@ import ( "log" "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/dex" + "github.com/guilycst/testcontainers-go/modules/dex" "golang.org/x/oauth2" ) diff --git a/modules/dex/go.mod b/modules/dex/go.mod index 2426fe693c..89f1e89e0b 100644 --- a/modules/dex/go.mod +++ b/modules/dex/go.mod @@ -1,4 +1,4 @@ -module github.com/testcontainers/testcontainers-go/modules/dex +module github.com/guilycst/testcontainers-go/modules/dex go 1.25.0 From 7cea7de72b379359eb66395b6a4ad3cf69e94d97 Mon Sep 17 00:00:00 2001 From: tks-subtree-bot Date: Fri, 24 Apr 2026 05:27:18 +0000 Subject: [PATCH 03/21] feat(testcontainers-go): add Dex module with documentation, option refactor, constructors, validation, and logger support --- .github/dependabot.yml | 1 + .vscode/.testcontainers-go.code-workspace | 4 + docs/modules/dex.md | 268 ++++++++++++++++++++++ mkdocs.yml | 1 + modules/dex/README.md | 92 +++++--- modules/dex/config.go | 45 ++-- modules/dex/config_test.go | 82 ++++--- modules/dex/dex.go | 123 ++-------- modules/dex/dex_test.go | 189 ++++++++------- modules/dex/examples_test.go | 56 +++-- modules/dex/go.mod | 2 +- modules/dex/grpc.go | 26 +-- modules/dex/options.go | 134 +++++++---- modules/dex/options_test.go | 71 +++++- modules/dex/types.go | 190 ++++++++++++--- 15 files changed, 881 insertions(+), 403 deletions(-) create mode 100644 docs/modules/dex.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 216d5b4fc0..208581f321 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,6 +28,7 @@ updates: - /modules/consul - /modules/couchbase - /modules/databend + - /modules/dex - /modules/dind - /modules/dockermcpgateway - /modules/dockermodelrunner diff --git a/.vscode/.testcontainers-go.code-workspace b/.vscode/.testcontainers-go.code-workspace index f2c3af7372..bc4ee6abe0 100644 --- a/.vscode/.testcontainers-go.code-workspace +++ b/.vscode/.testcontainers-go.code-workspace @@ -61,6 +61,10 @@ "name": "module / databend", "path": "../modules/databend" }, + { + "name": "module / dex", + "path": "../modules/dex" + }, { "name": "module / dind", "path": "../modules/dind" diff --git a/docs/modules/dex.md b/docs/modules/dex.md new file mode 100644 index 0000000000..3f3f36a66a --- /dev/null +++ b/docs/modules/dex.md @@ -0,0 +1,268 @@ +# Dex + +Since :material-tag: v0.42.0 + +## Introduction + +The Testcontainers module for [Dex](https://dexidp.io/), a CNCF-sandbox OIDC +provider. The module lets Go tests spin up a real Dex instance and exercise +OAuth2/OIDC flows end-to-end instead of mocking the token endpoint. + +## Adding this module to your project dependencies + +Please run the following command to add the Dex module to your Go dependencies: + +``` +go get github.com/testcontainers/testcontainers-go/modules/dex +``` + +## Usage example + + +[Creating a Dex container](../../modules/dex/examples_test.go) inside_block:runDexContainer + + +## Module Reference + +### Run function + +- Since :material-tag: v0.42.0 + +The Dex module exposes one entrypoint function to create the Dex container, +and this function receives three parameters: + +```golang +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DexContainer, error) +``` + +- `context.Context`, the Go context. +- `string`, the Docker image to use. +- `testcontainers.ContainerCustomizer`, a variadic argument for passing + options. + +#### Image + +Use the second argument in the `Run` function to set a valid Docker image. +In example: `Run(context.Background(), "dexidp/dex:v2.45.1")`. + +!!! warning + The `client_credentials` grant requires Dex ≥ v2.46.0 or the + `dexidp/dex:master` image. On `v2.45.x` and earlier the token endpoint + returns `unsupported_grant_type` even with `WithEnableClientCredentials()` + set. The module does not validate the image tag — pin a compatible image + when using that grant. + +### Container Options + +When starting the Dex container, you can pass options in a variadic way to +configure it. + +#### Clients + +- Since :material-tag: v0.42.0 + +`Client` is an opaque value type. Construct it with `NewClient(id, opts...)` +so invalid values surface at call-site. `NewClient` returns +`(Client, error)`; client options accept variadic URIs and grant types. + +```golang +app, err := dex.NewClient("my-app", + dex.WithClientSecret("secret"), + dex.WithClientRedirectURIs("http://localhost:8080/callback"), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + dex.WithClientName("My App"), +) +// ... +c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", dex.WithClient(app)) +``` + +Additional client options: `WithClientPublic()` marks the client as public +(PKCE, no secret). + +Clients added at runtime via `AddClient` inherit Dex's defaults +(`authorization_code` + `refresh_token`) because Dex's gRPC `api.Client` +proto has no `grant_types` field. Clients needing other grants must be +declared pre-start via `WithClient`. + +#### Users + +- Since :material-tag: v0.42.0 + +`User` is an opaque value type. `NewUser(email, username, password, opts...)` +returns `(User, error)`. Use `WithUserID(id)` to pin the stable `sub` claim; +otherwise a UUIDv4 is generated at YAML render time. + +```golang +user, err := dex.NewUser("user@example.com", "user", "password", + dex.WithUserID("stable-sub-123"), +) +// ... +c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", dex.WithUser(user)) +``` + +Adding `WithUser` keeps Dex's built-in password DB connector enabled. Use +`WithDisablePasswordDB()` to turn it off when the configuration only uses +other connectors. + +#### Connectors + +- Since :material-tag: v0.42.0 + +`WithConnector(type, id, name)` enables a Dex connector. Supported types: + +- `ConnectorPassword` — Dex's built-in static password DB. Enabled by + default; no-op when passed explicitly. +- `ConnectorMock` — Dex's `mockCallback` test connector. Returns a fixed + user (`kilgore@kilgore.trout`) and bypasses the login form. + +Blank `id` or `name` values are rejected at `Run` time. + +#### Issuer + +- Since :material-tag: v0.42.0 + +By default the issuer is derived from the host and mapped HTTP port: +`http://:/dex`. `WithIssuer(url)` overrides the default. +Use it when the issuer must be reachable from other containers (shared +Docker network with a network alias); the caller owns reachability. + +#### Storage + +- Since :material-tag: v0.42.0 + +`WithStorage(Storage)` selects Dex's storage backend. Available constants: + +- `StorageSQLite` — on-disk SQLite database (default). Ephemeral; destroyed + with the container. +- `StorageMemory` — in-process. Fastest; not shared across replicas. + +#### Logging + +- Since :material-tag: v0.42.0 + +`WithLogger(*slog.Logger)` routes Dex container logs through a `slog.Logger`. +Unset by default — container logs are discarded. Stderr lines are promoted +to at least `slog.LevelWarn` because Dex writes runtime errors there. + +`WithLogLevel(slog.Level)` sets Dex's own `logger.level` YAML key. Values +between slog's fixed levels round down. Default: `slog.LevelInfo`. + +#### Client credentials grant + +- Since :material-tag: v0.42.0 + +`WithEnableClientCredentials()` sets the env var +`DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`, which Dex ≥ v2.46.0 +reads to enable the OAuth2 `client_credentials` grant. Pair with a +compatible image (see the `Image` warning above). + +#### Approval screen + +- Since :material-tag: v0.42.0 + +`WithSkipApprovalScreen(bool)` toggles Dex's `oauth2.skipApprovalScreen`. +Default: `true` (tests rarely want a human-in-the-loop prompt). + +{% include "../features/common_functional_options_list.md" %} + +### Container Methods + +The Dex container exposes the following methods: + +#### IssuerURL + +- Since :material-tag: v0.42.0 + +Returns Dex's issuer URL. Empty if `Run` has not started. + +#### ConfigEndpoint + +- Since :material-tag: v0.42.0 + +Returns the OIDC discovery document URL (`/.well-known/openid-configuration`). + +#### JWKSEndpoint + +- Since :material-tag: v0.42.0 + +Returns the JSON Web Key Set URL (`/keys`). + +#### TokenEndpoint + +- Since :material-tag: v0.42.0 + +Returns the OAuth2 token URL (`/token`). + +#### AuthEndpoint + +- Since :material-tag: v0.42.0 + +Returns the OAuth2 authorization URL (`/auth`). + +#### GRPCEndpoint + +- Since :material-tag: v0.42.0 + +Returns `host:mappedPort` for Dex's gRPC admin API. Takes a `context.Context` +and returns an error if the container is not started or the Docker API +call fails. + +```golang +target, err := c.GRPCEndpoint(ctx) +``` + +#### AddClient + +- Since :material-tag: v0.42.0 + +Registers a client at runtime via Dex's gRPC admin API. Returns +`ErrClientExists` when the ID is already registered. Not safe for +concurrent use. + +#### RemoveClient + +- Since :material-tag: v0.42.0 + +Deletes a client by ID. Returns a plain error containing `"not found"` for +unknown IDs. Not safe for concurrent use. + +#### AddUser + +- Since :material-tag: v0.42.0 + +Registers a user in Dex's password DB at runtime via gRPC. Returns +`ErrUserExists` when the email is already registered. Not safe for +concurrent use. + +#### RemoveUser + +- Since :material-tag: v0.42.0 + +Deletes a user by email. Returns a plain error containing `"not found"` for +unknown emails. Not safe for concurrent use. + +### ID token claims + +Dex's password connector emits these standard claims in ID tokens: + +- `sub` — stable user ID (auto-generated UUIDv4 when `NewUser` is called + without `WithUserID`). +- `email` — user's email address. +- `email_verified` — always `true` for static password entries. +- `name` — the username. +- `iss` — the issuer URL. +- `aud` — the client ID. + +Dex does NOT emit the `preferred_username` claim; use the `name` claim +instead when a human-readable identifier is needed. + +### Known limitations + +- Runtime-added clients inherit Dex's default grants. +- `client_credentials` requires `WithEnableClientCredentials()` and Dex ≥ + v2.46.0 or the `:master` image tag. +- gRPC admin API is plaintext. TLS not supported day 1. +- `mockCallback` emits a fixed user; parameterized flows need the password + connector with a pre-seeded user. +- SQLite storage is container-local and ephemeral. +- Bcrypt cost ≥ 10 is enforced by Dex for both YAML and gRPC password paths. diff --git a/mkdocs.yml b/mkdocs.yml index b77c004fec..f2435810ab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - modules/consul.md - modules/couchbase.md - modules/databend.md + - modules/dex.md - modules/dind.md - modules/dockermcpgateway.md - modules/dockermodelrunner.md diff --git a/modules/dex/README.md b/modules/dex/README.md index 2599ad53fd..587bc8232a 100644 --- a/modules/dex/README.md +++ b/modules/dex/README.md @@ -1,6 +1,6 @@ # Dex -Not available until the next release of testcontainers-go :material-tag: main +Since :material-tag: v0.42.0 ## Introduction @@ -9,32 +9,32 @@ The Testcontainers module for [Dex](https://github.com/dexidp/dex), a CNCF OIDC ## Adding this module to your project dependencies ``` -go get github.com/guilycst/testcontainers-go/modules/dex +go get github.com/testcontainers/testcontainers-go/modules/dex ``` -> Note: this is a fork-hosted distribution path on `guilycst/testcontainers-go` -> while the module incubates. When upstreamed to -> `testcontainers/testcontainers-go`, the module path reverts to -> `github.com/testcontainers/testcontainers-go/modules/dex`. - ## Usage example ```go ctx := context.Background() +app, err := dex.NewClient("my-app", + dex.WithClientSecret("secret"), + dex.WithClientRedirectURIs("http://localhost:8080/callback"), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + dex.WithClientName("My App"), +) +if err != nil { + log.Fatalf("new client: %v", err) +} + +user, err := dex.NewUser("user@example.com", "user", "password") +if err != nil { + log.Fatalf("new user: %v", err) +} + c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", - dex.WithClient(dex.Client{ - ID: "my-app", - Secret: "secret", - RedirectURIs: []string{"http://localhost:8080/callback"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - Name: "My App", - }), - dex.WithUser(dex.User{ - Email: "user@example.com", - Username: "user", - Password: "password", - }), + dex.WithClient(app), + dex.WithUser(user), ) if err != nil { log.Fatalf("run dex: %v", err) @@ -47,29 +47,29 @@ fmt.Println("issuer:", c.IssuerURL()) ## Supported grants `authorization_code`, `refresh_token`, `password`. Declare per-client via -`WithClient(Client{GrantTypes: ...})`. +`WithClientGrantTypes(...)`. **`client_credentials` requires Dex ≥ v2.46.0 (not yet released at time of writing) with the feature flag enabled.** Dex gates this grant behind the env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`. Use `WithEnableClientCredentials()` to set it automatically. Currently available in `dexidp/dex:master` / `:latest` images; the first tagged release -containing it (likely `v2.46.0`) will also support it. +containing it (likely `v2.46.0`) will also support it. The module does not +validate the image tag — the caller must pin a compatible image. ```go +svc, err := dex.NewClient("svc", + dex.WithClientSecret("s"), + dex.WithClientName("Service"), + dex.WithClientGrantTypes("client_credentials"), +) +// ... c, err := dex.Run(ctx, "dexidp/dex:master", dex.WithEnableClientCredentials(), - dex.WithClient(dex.Client{ - ID: "svc", Secret: "s", Name: "Service", - GrantTypes: []string{"client_credentials"}, - }), + dex.WithClient(svc), ) ``` -The module logs a warning when `WithEnableClientCredentials()` is set but -the image tag predates the feature (`v2.45.x` or earlier). Token exchanges -will fail with `unsupported_grant_type` in that case. - Clients added at runtime via `AddClient` inherit Dex's defaults (`authorization_code` + `refresh_token`) because Dex's gRPC `api.Client` proto has no `grant_types` field. Clients needing other grants must be declared @@ -78,7 +78,8 @@ pre-start via `WithClient`. ## Connectors - `ConnectorPassword` — Dex's built-in static password DB (default; enabled - automatically when a user is seeded via `WithUser`). + automatically when a user is seeded via `WithUser`). Disable via + `WithDisablePasswordDB()` when running connector-only flows. - `ConnectorMock` — Dex's `mockCallback` test connector (returns a fixed user, `kilgore@kilgore.trout`). @@ -93,10 +94,11 @@ and a network alias). The caller owns reachability when overriding. Dex's password connector emits these standard claims in ID tokens: -- `sub` — stable user ID (auto-generated UUID if `User.UserID` is empty). +- `sub` — stable user ID (auto-generated UUID when constructed via `NewUser` + without `WithUserID`). - `email` — user's email address. - `email_verified` — always `true` for static password entries. -- `name` — the value of `User.Username`. +- `name` — the value of `User`'s username. - `iss` — the issuer URL. - `aud` — the client ID. @@ -107,26 +109,40 @@ when a human-readable identifier is needed. ### Types -- `Client{ID, Secret, RedirectURIs, GrantTypes, Public, Name}` -- `User{Email, Username, Password, UserID}` +- `Client` — opaque OAuth2 client value. Construct with `NewClient(id, opts...)`. +- `User` — opaque password entry. Construct with `NewUser(email, username, password, opts...)`. - `ConnectorType` — `ConnectorPassword`, `ConnectorMock` +- `Storage` — `StorageSQLite` (default), `StorageMemory` + +### Client options (`ClientOption`) + +- `WithClientSecret(string)` +- `WithClientName(string)` +- `WithClientRedirectURIs(...string)` +- `WithClientGrantTypes(...string)` +- `WithClientPublic()` + +### User options (`UserOption`) + +- `WithUserID(string)` — pin a stable subject claim. Omit to auto-generate UUIDv4. -### Options +### Module options - `WithClient(Client)` - `WithUser(User)` - `WithConnector(type, id, name)` - `WithIssuer(url)` - `WithSkipApprovalScreen(bool)` -- `WithStorage(kind)` — `"sqlite3"` (default) or `"memory"` +- `WithStorage(Storage)` — `StorageSQLite` (default) or `StorageMemory` +- `WithDisablePasswordDB()` — opt out of the built-in password DB - `WithLogger(*slog.Logger)` — captures Dex logs -- `WithLogLevel(level)` — sets Dex's `logger.level` YAML key (`debug|info|warn|error`) +- `WithLogLevel(slog.Level)` — sets Dex's `logger.level` YAML key - `WithEnableClientCredentials()` — enables the OAuth2 `client_credentials` grant via feature flag (requires Dex ≥ v2.46.0 or `:master`) ### Endpoint getters `IssuerURL`, `ConfigEndpoint`, `JWKSEndpoint`, `TokenEndpoint`, -`AuthEndpoint`, `GRPCEndpoint`. +`AuthEndpoint`, `GRPCEndpoint(ctx) (string, error)`. ### Runtime mutation (gRPC) diff --git a/modules/dex/config.go b/modules/dex/config.go index 2f2f2fd854..97cdf7a55e 100644 --- a/modules/dex/config.go +++ b/modules/dex/config.go @@ -3,6 +3,7 @@ package dex import ( "crypto/rand" "fmt" + "log/slog" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" @@ -92,37 +93,37 @@ func render(o options) ([]byte, error) { return nil, ErrNoAuthSource } - storage := storageBlock{Type: o.storage} - if o.storage == "sqlite3" { + storage := storageBlock{Type: string(o.storage)} + if o.storage == StorageSQLite { storage.Config = map[string]string{"file": "/var/dex/dex.db"} } clients := make([]yamlClient, 0, len(o.clients)) for _, c := range o.clients { clients = append(clients, yamlClient{ - ID: c.ID, - Secret: c.Secret, - Name: c.Name, - Public: c.Public, - RedirectURIs: c.RedirectURIs, - GrantTypes: c.GrantTypes, + ID: c.id, + Secret: c.secret, + Name: c.name, + Public: c.public, + RedirectURIs: c.redirectURIs, + GrantTypes: c.grantTypes, }) } passwords := make([]yamlPassword, 0, len(o.users)) for _, u := range o.users { - hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), testBcryptCost) + hash, err := bcrypt.GenerateFromPassword([]byte(u.password), testBcryptCost) if err != nil { - return nil, fmt.Errorf("dex: bcrypt user %q: %w", u.Email, err) + return nil, fmt.Errorf("dex: bcrypt user %q: %w", u.email, err) } - uid := u.UserID + uid := u.userID if uid == "" { uid = newUUIDv4() } passwords = append(passwords, yamlPassword{ - Email: u.Email, + Email: u.email, Hash: string(hash), - Username: u.Username, + Username: u.username, UserID: uid, }) } @@ -152,7 +153,7 @@ func render(o options) ([]byte, error) { Storage: storage, Web: endpointBlock{HTTP: "0.0.0.0:5556"}, GRPC: grpcBlock{Addr: "0.0.0.0:5557"}, - Logger: loggerBlock{Level: o.logLevel}, + Logger: loggerBlock{Level: dexLogLevel(o.logLevel)}, OAuth2: oauth2, EnablePasswordDB: o.enablePasswordDB, StaticClients: clients, @@ -167,6 +168,22 @@ func render(o options) ([]byte, error) { return out, nil } +// dexLogLevel maps a standard library slog.Level to the string vocabulary +// Dex recognises in its YAML `logger.level` field. Values between slog's +// fixed levels round down (e.g. slog.LevelInfo+1 → "info"). +func dexLogLevel(l slog.Level) string { + switch { + case l <= slog.LevelDebug: + return "debug" + case l <= slog.LevelInfo: + return "info" + case l <= slog.LevelWarn: + return "warn" + default: + return "error" + } +} + // newUUIDv4 generates an RFC 4122 v4 UUID without importing a third-party dep. func newUUIDv4() string { var b [16]byte diff --git a/modules/dex/config_test.go b/modules/dex/config_test.go index 03c281531b..863bae5996 100644 --- a/modules/dex/config_test.go +++ b/modules/dex/config_test.go @@ -1,6 +1,7 @@ package dex import ( + "log/slog" "strings" "testing" @@ -10,6 +11,20 @@ import ( "gopkg.in/yaml.v3" ) +func mustClient(t *testing.T, id string, opts ...ClientOption) Client { + t.Helper() + c, err := NewClient(id, opts...) + require.NoError(t, err) + return c +} + +func mustUser(t *testing.T, email, username, password string, opts ...UserOption) User { + t.Helper() + u, err := NewUser(email, username, password, opts...) + require.NoError(t, err) + return u +} + func TestRender_MinimalDefaults(t *testing.T) { o := defaultOptions() o.issuer = "http://localhost:5556/dex" @@ -36,17 +51,17 @@ func TestRender_WithClients(t *testing.T) { o := defaultOptions() o.issuer = "http://h:5556/dex" o.clients = []Client{ - { - ID: "app1", Secret: "s1", - RedirectURIs: []string{"http://a/cb", "http://b/cb"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - Name: "App 1", - }, - { - ID: "svc", Secret: "s2", - GrantTypes: []string{"client_credentials"}, - Name: "Service", - }, + mustClient(t, "app1", + WithClientSecret("s1"), + WithClientRedirectURIs("http://a/cb", "http://b/cb"), + WithClientGrantTypes("authorization_code", "refresh_token"), + WithClientName("App 1"), + ), + mustClient(t, "svc", + WithClientSecret("s2"), + WithClientGrantTypes("client_credentials"), + WithClientName("Service"), + ), } out, err := render(o) @@ -71,7 +86,7 @@ func TestRender_WithClients(t *testing.T) { func TestRender_WithUsers_BcryptShape(t *testing.T) { o := defaultOptions() o.issuer = "http://h:5556/dex" - o.users = []User{{Email: "u@e.com", Username: "u", Password: "p"}} + o.users = []User{mustUser(t, "u@e.com", "u", "p")} out, err := render(o) require.NoError(t, err) @@ -133,7 +148,7 @@ func TestRender_IssuerRequired(t *testing.T) { func TestRender_BcryptCost(t *testing.T) { o := defaultOptions() o.issuer = "http://h:5556/dex" - o.users = []User{{Email: "u@e.com", Username: "u", Password: "p"}} + o.users = []User{mustUser(t, "u@e.com", "u", "p")} out, err := render(o) require.NoError(t, err) @@ -154,7 +169,7 @@ func TestRender_BcryptCost(t *testing.T) { func TestRender_UserWithExplicitUserID(t *testing.T) { o := defaultOptions() o.issuer = "http://h:5556/dex" - o.users = []User{{Email: "u@e.com", Username: "u", Password: "p", UserID: "fixed-id-123"}} + o.users = []User{mustUser(t, "u@e.com", "u", "p", WithUserID("fixed-id-123"))} out, err := render(o) require.NoError(t, err) @@ -213,7 +228,7 @@ func TestRender_YAMLInjection_NameField(t *testing.T) { // template-based renderer. yaml.Marshal must escape it such that // the round-tripped value equals the input verbatim. malicious := "real-name\nmalicious_key: poison" - o.clients = []Client{{ID: "c", Secret: "s", Name: malicious}} + o.clients = []Client{mustClient(t, "c", WithClientSecret("s"), WithClientName(malicious))} out, err := render(o) require.NoError(t, err) @@ -230,34 +245,17 @@ func TestRender_YAMLInjection_NameField(t *testing.T) { assert.Empty(t, got.Malicious, "structural injection must not create a top-level key") } -func TestParseImageTag(t *testing.T) { - cases := map[string]string{ - "dexidp/dex:v2.45.1": "v2.45.1", - "dexidp/dex:master": "master", - "dexidp/dex:latest-alpine": "latest-alpine", - "ghcr.io/dexidp/dex:v2.46.0": "v2.46.0", - "localhost:5000/dex:v2.46.0": "v2.46.0", - "dexidp/dex": "", - "dexidp/dex@sha256:abcd": "", - "dexidp/dex:v2.46.0@sha256:deadbeefcafe": "v2.46.0", +func TestDexLogLevel(t *testing.T) { + cases := map[slog.Level]string{ + slog.LevelDebug: "debug", + slog.LevelDebug - 1: "debug", + slog.LevelInfo: "info", + slog.LevelInfo + 1: "warn", + slog.LevelWarn: "warn", + slog.LevelWarn + 1: "error", + slog.LevelError: "error", } for in, want := range cases { - assert.Equal(t, want, parseImageTag(in), "input=%q", in) - } -} - -func TestImageSupportsClientCredentials(t *testing.T) { - good := []string{"master", "latest", "master-alpine", "latest-distroless", - "v2.46.0", "v2.46.1", "v2.47.0", "v3.0.0", - "custom-build-abc", // unknown non-semver → trusted - } - for _, tag := range good { - assert.True(t, imageSupportsClientCredentials(tag), "tag=%q should be supported", tag) - } - - bad := []string{"v2.45.0", "v2.45.1", "v2.45.1-alpine", "v2.44.0", - "v2.0.0", "v1.99.99"} - for _, tag := range bad { - assert.False(t, imageSupportsClientCredentials(tag), "tag=%q should NOT be supported", tag) + assert.Equal(t, want, dexLogLevel(in), "slog.Level=%v", in) } } diff --git a/modules/dex/dex.go b/modules/dex/dex.go index 1021bd9c86..9339b94c7c 100644 --- a/modules/dex/dex.go +++ b/modules/dex/dex.go @@ -2,16 +2,24 @@ // // Supported grants: authorization_code, refresh_token, password. The // client_credentials grant requires Dex ≥ v2.46.0 (or dexidp/dex:master) -// with WithEnableClientCredentials() — this sets the -// DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true env var that gates the -// feature. Earlier releases (v2.45.x and below) return unsupported_grant_type. +// together with WithEnableClientCredentials() — this sets the +// DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true env var that gates +// the feature. Earlier releases return unsupported_grant_type. // // Example: // // ctx := context.Background() +// app, err := dex.NewClient("my-app", +// dex.WithClientSecret("s3cr3t"), +// dex.WithClientRedirectURIs("http://localhost/callback"), +// ) +// if err != nil { log.Fatal(err) } +// user, err := dex.NewUser("u@example.com", "u", "p") +// if err != nil { log.Fatal(err) } +// // c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", -// dex.WithClient(dex.Client{ID: "my-app", Secret: "s3cr3t", RedirectURIs: []string{"http://localhost/callback"}}), -// dex.WithUser(dex.User{Email: "u@example.com", Username: "u", Password: "p"}), +// dex.WithClient(app), +// dex.WithUser(user), // ) // defer testcontainers.TerminateContainer(c) package dex @@ -19,9 +27,6 @@ package dex import ( "context" "fmt" - "log/slog" - "strconv" - "strings" "time" "github.com/testcontainers/testcontainers-go" @@ -48,7 +53,9 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(&settings) + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("dex: apply option: %w", err) + } } } @@ -109,17 +116,6 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom testcontainers.WithEnv(map[string]string{ "DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT": "true", })) - if tag := parseImageTag(img); tag != "" && !imageSupportsClientCredentials(tag) { - warnLogger := settings.logger - if warnLogger == nil { - warnLogger = slog.Default() - } - warnLogger.Warn("dex: client_credentials grant requested but image tag predates feature flag support; token exchanges will fail", - slog.String("image", img), - slog.String("tag", tag), - slog.String("minimum_tag", "v2.46.0"), - slog.String("workaround", "use dexidp/dex:master or dexidp/dex:latest")) - } } moduleOpts = append(moduleOpts, opts...) @@ -150,27 +146,10 @@ func (c *DexContainer) TokenEndpoint() string { return c.issuer + "/token" } // AuthEndpoint returns the OAuth2 authorization URL. func (c *DexContainer) AuthEndpoint() string { return c.issuer + "/auth" } -// GRPCEndpoint returns host:mappedPort for Dex's gRPC admin API. Empty -// before Run has started. -func (c *DexContainer) GRPCEndpoint() string { - if c.Container == nil { - return "" - } - host, err := c.Host(context.Background()) - if err != nil { - return "" - } - port, err := c.MappedPort(context.Background(), grpcPort) - if err != nil { - return "" - } - return fmt.Sprintf("%s:%s", host, port.Port()) -} - -// grpcEndpoint resolves host:mappedPort for Dex's gRPC admin API. Unlike -// GRPCEndpoint(), it propagates Docker API errors so callers can tell a -// pre-start container apart from a Docker-layer failure mid-test. -func (c *DexContainer) grpcEndpoint(ctx context.Context) (string, error) { +// GRPCEndpoint returns host:mappedPort for Dex's gRPC admin API. Errors +// propagate from the Docker API, or report that the container has not been +// started. +func (c *DexContainer) GRPCEndpoint(ctx context.Context) (string, error) { if c.Container == nil { return "", fmt.Errorf("dex: container not started") } @@ -184,65 +163,3 @@ func (c *DexContainer) grpcEndpoint(ctx context.Context) (string, error) { } return fmt.Sprintf("%s:%s", host, port.Port()), nil } - -// parseImageTag extracts the tag portion from a Docker image reference like -// "dexidp/dex:v2.45.1" or "ghcr.io/dex:v2.45.1-alpine". Returns empty -// string when no tag is present or the image uses a digest reference. -func parseImageTag(img string) string { - // Drop any digest suffix; tag lives after the LAST colon that comes - // after the LAST slash (so we don't mistake the port in a registry - // like localhost:5000/dex for a tag). - if idx := strings.LastIndex(img, "@"); idx >= 0 { - img = img[:idx] - } - lastSlash := strings.LastIndex(img, "/") - colon := strings.LastIndex(img[lastSlash+1:], ":") - if colon < 0 { - return "" - } - return img[lastSlash+1+colon+1:] -} - -// imageSupportsClientCredentials reports whether a Dex image tag is known -// to include the client_credentials feature flag. -// -// Known-good tags: -// - master, latest (always rebuild from HEAD) -// - any tag ≥ v2.46.0 once released -// -// Known-bad tags: v2.45.x and earlier. -// Unknown tags (custom builds, non-semver) return true — the caller is -// presumed to know what they're doing. -func imageSupportsClientCredentials(tag string) bool { - // Strip common tag suffixes the Dex image uses: -alpine, -distroless. - base := tag - for _, suffix := range []string{"-alpine", "-distroless"} { - base = strings.TrimSuffix(base, suffix) - } - switch base { - case "master", "latest": - return true - } - // Parse semver vX.Y.Z. Accept anything ≥ v2.46.0. - if !strings.HasPrefix(base, "v") { - // Non-semver custom build — give the caller the benefit of the doubt. - return true - } - parts := strings.SplitN(strings.TrimPrefix(base, "v"), ".", 3) - if len(parts) < 2 { - return true // odd shape — trust the caller - } - major, err1 := strconv.Atoi(parts[0]) - minor, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - return true - } - switch { - case major > 2: - return true - case major < 2: - return false - default: // major == 2 - return minor >= 46 - } -} diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index b0cfd80547..f70fb3bb97 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" - "github.com/guilycst/testcontainers-go/modules/dex" + "github.com/testcontainers/testcontainers-go/modules/dex" "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" "golang.org/x/oauth2" @@ -31,12 +31,30 @@ const ( dexImageWithCC = "dexidp/dex:master" ) +// mustClient + mustUser are test helpers: the module's NewClient / NewUser +// constructors return (Client, error), but every test here uses valid input. +// Wrapping them with require lets the test read like the old field-literal +// form while preserving constructor validation. +func mustClient(t *testing.T, id string, opts ...dex.ClientOption) dex.Client { + t.Helper() + c, err := dex.NewClient(id, opts...) + require.NoError(t, err) + return c +} + +func mustUser(t *testing.T, email, username, password string, opts ...dex.UserOption) dex.User { + t.Helper() + u, err := dex.NewUser(email, username, password, opts...) + require.NoError(t, err) + return u +} + func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() c, err := dex.Run(ctx, dexImage, - dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -46,7 +64,9 @@ func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { assert.Equal(t, c.IssuerURL()+"/keys", c.JWKSEndpoint()) assert.Equal(t, c.IssuerURL()+"/token", c.TokenEndpoint()) assert.Equal(t, c.IssuerURL()+"/auth", c.AuthEndpoint()) - assert.NotEmpty(t, c.GRPCEndpoint()) + grpcEP, err := c.GRPCEndpoint(ctx) + require.NoError(t, err) + assert.NotEmpty(t, grpcEP) resp, err := http.Get(c.ConfigEndpoint()) require.NoError(t, err) @@ -72,7 +92,7 @@ func TestRun_WithIssuerOverride(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithIssuer(issuer), - dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -105,17 +125,16 @@ func TestGRPC_AddRemoveClient(t *testing.T) { defer cancel() c, err := dex.Run(ctx, dexImage, - dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) - cl := dex.Client{ - ID: "runtime-app", - Secret: "s", - RedirectURIs: []string{"http://localhost/cb"}, - Name: "Runtime App", - } + cl := mustClient(t, "runtime-app", + dex.WithClientSecret("s"), + dex.WithClientRedirectURIs("http://localhost/cb"), + dex.WithClientName("Runtime App"), + ) require.NoError(t, c.AddClient(ctx, cl)) // Idempotency: second Add returns ErrClientExists. @@ -123,10 +142,10 @@ func TestGRPC_AddRemoveClient(t *testing.T) { assert.ErrorIs(t, err, dex.ErrClientExists) // Removal succeeds. - require.NoError(t, c.RemoveClient(ctx, cl.ID)) + require.NoError(t, c.RemoveClient(ctx, cl.ID())) // Second remove errors (not-found). - err = c.RemoveClient(ctx, cl.ID) + err = c.RemoveClient(ctx, cl.ID()) assert.Error(t, err) } @@ -138,7 +157,7 @@ func TestGRPC_AddRemoveUser(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) - u := dex.User{Email: "runtime@example.com", Username: "runtime", Password: "p"} + u := mustUser(t, "runtime@example.com", "runtime", "p") require.NoError(t, c.AddUser(ctx, u)) // Duplicate add errors. @@ -146,10 +165,10 @@ func TestGRPC_AddRemoveUser(t *testing.T) { assert.ErrorIs(t, err, dex.ErrUserExists) // Removal succeeds. - require.NoError(t, c.RemoveUser(ctx, u.Email)) + require.NoError(t, c.RemoveUser(ctx, u.Email())) // Second removal errors. - err = c.RemoveUser(ctx, u.Email) + err = c.RemoveUser(ctx, u.Email()) assert.Error(t, err) } @@ -162,7 +181,7 @@ func TestWithLogger_CapturesDexOutput(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithLogger(logger), - dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -218,18 +237,13 @@ func TestAuthCode_PasswordConnector_Basic(t *testing.T) { const redirectURI = "http://localhost:18080/cb" c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "e2e-app", - Secret: "e2e-secret", - RedirectURIs: []string{redirectURI}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - Name: "E2E App", - }), - dex.WithUser(dex.User{ - Email: "alice@example.com", - Username: "alice", - Password: "pass", - }), + dex.WithClient(mustClient(t, "e2e-app", + dex.WithClientSecret("e2e-secret"), + dex.WithClientRedirectURIs(redirectURI), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + dex.WithClientName("E2E App"), + )), + dex.WithUser(mustUser(t, "alice@example.com", "alice", "pass")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -260,12 +274,13 @@ func TestAuthCode_RefreshToken(t *testing.T) { const redirectURI = "http://localhost:18080/cb" c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "e2e", Secret: "s", Name: "E2E", - RedirectURIs: []string{redirectURI}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - }), - dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + dex.WithClient(mustClient(t, "e2e", + dex.WithClientSecret("s"), + dex.WithClientName("E2E"), + dex.WithClientRedirectURIs(redirectURI), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + )), + dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -299,12 +314,13 @@ func TestAuthCode_MultipleRedirectURIs(t *testing.T) { uris := []string{"http://localhost:18080/cb", "http://localhost:18090/cb"} c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "e2e", Secret: "s", Name: "E2E", - RedirectURIs: uris, - GrantTypes: []string{"authorization_code", "refresh_token"}, - }), - dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + dex.WithClient(mustClient(t, "e2e", + dex.WithClientSecret("s"), + dex.WithClientName("E2E"), + dex.WithClientRedirectURIs(uris...), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + )), + dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -333,10 +349,11 @@ func TestClientCredentials_UnsupportedByLocalConnectors(t *testing.T) { defer cancel() c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "svc", Secret: "s", Name: "Service", - GrantTypes: []string{"client_credentials"}, - }), + dex.WithClient(mustClient(t, "svc", + dex.WithClientSecret("s"), + dex.WithClientName("Service"), + dex.WithClientGrantTypes("client_credentials"), + )), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -364,11 +381,12 @@ func TestPasswordGrant_ROPC(t *testing.T) { defer cancel() c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "cli", Secret: "s", Name: "CLI", - GrantTypes: []string{"password"}, - }), - dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + dex.WithClient(mustClient(t, "cli", + dex.WithClientSecret("s"), + dex.WithClientName("CLI"), + dex.WithClientGrantTypes("password"), + )), + dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -393,16 +411,18 @@ func TestMultipleClients_OneInstance(t *testing.T) { defer cancel() c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "svc", Secret: "s", Name: "SVC", - GrantTypes: []string{"password"}, - }), - dex.WithClient(dex.Client{ - ID: "web", Secret: "s", Name: "Web", - RedirectURIs: []string{"http://localhost/cb"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - }), - dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + dex.WithClient(mustClient(t, "svc", + dex.WithClientSecret("s"), + dex.WithClientName("SVC"), + dex.WithClientGrantTypes("password"), + )), + dex.WithClient(mustClient(t, "web", + dex.WithClientSecret("s"), + dex.WithClientName("Web"), + dex.WithClientRedirectURIs("http://localhost/cb"), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + )), + dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -437,11 +457,12 @@ func TestMockConnector_IssuesToken(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithConnector(dex.ConnectorMock, "mock", "Mock Connector"), - dex.WithClient(dex.Client{ - ID: "e2e", Secret: "s", Name: "E2E", - RedirectURIs: []string{"http://localhost/cb"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - }), + dex.WithClient(mustClient(t, "e2e", + dex.WithClientSecret("s"), + dex.WithClientName("E2E"), + dex.WithClientRedirectURIs("http://localhost/cb"), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + )), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -493,14 +514,12 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) // Seed client + user at runtime. - require.NoError(t, c.AddClient(ctx, dex.Client{ - ID: "late-app", Secret: "s", - RedirectURIs: []string{"http://localhost/cb"}, - Name: "Late App", - })) - require.NoError(t, c.AddUser(ctx, dex.User{ - Email: "late@e.com", Username: "late", Password: "p", - })) + require.NoError(t, c.AddClient(ctx, mustClient(t, "late-app", + dex.WithClientSecret("s"), + dex.WithClientRedirectURIs("http://localhost/cb"), + dex.WithClientName("Late App"), + ))) + require.NoError(t, c.AddUser(ctx, mustUser(t, "late@e.com", "late", "p"))) cfg := oauth2.Config{ ClientID: "late-app", ClientSecret: "s", @@ -565,7 +584,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithIssuer(issuer), - dex.WithUser(dex.User{Email: "u@e.com", Username: "u", Password: "p"}), + dex.WithUser(mustUser(t, "u@e.com", "u", "p")), network.WithNetwork([]string{"dex"}, net), ) require.NoError(t, err) @@ -604,10 +623,11 @@ func TestClientCredentials_WithFeatureFlag(t *testing.T) { c, err := dex.Run(ctx, dexImageWithCC, dex.WithEnableClientCredentials(), - dex.WithClient(dex.Client{ - ID: "svc", Secret: "s", Name: "Service", - GrantTypes: []string{"client_credentials"}, - }), + dex.WithClient(mustClient(t, "svc", + dex.WithClientSecret("s"), + dex.WithClientName("Service"), + dex.WithClientGrantTypes("client_credentials"), + )), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) @@ -630,12 +650,13 @@ func TestConsumer_IDTokenVerifies_CoreosOIDC(t *testing.T) { const redirectURI = "http://localhost:18080/cb" c, err := dex.Run(ctx, dexImage, - dex.WithClient(dex.Client{ - ID: "e2e", Secret: "s", Name: "E2E", - RedirectURIs: []string{redirectURI}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - }), - dex.WithUser(dex.User{Email: "a@e.com", Username: "a", Password: "p"}), + dex.WithClient(mustClient(t, "e2e", + dex.WithClientSecret("s"), + dex.WithClientName("E2E"), + dex.WithClientRedirectURIs(redirectURI), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + )), + dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go index 6feb059061..2f08b95be8 100644 --- a/modules/dex/examples_test.go +++ b/modules/dex/examples_test.go @@ -6,28 +6,37 @@ import ( "log" "github.com/testcontainers/testcontainers-go" - "github.com/guilycst/testcontainers-go/modules/dex" + "github.com/testcontainers/testcontainers-go/modules/dex" "golang.org/x/oauth2" ) func ExampleRun_authorizationCode() { + // runDexContainer { ctx := context.Background() + + app, err := dex.NewClient("my-app", + dex.WithClientSecret("secret"), + dex.WithClientRedirectURIs("http://localhost:8080/callback"), + dex.WithClientGrantTypes("authorization_code", "refresh_token"), + dex.WithClientName("My App"), + ) + if err != nil { + log.Fatalf("new client: %v", err) + } + user, err := dex.NewUser("u@example.com", "u", "p") + if err != nil { + log.Fatalf("new user: %v", err) + } + c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", - dex.WithClient(dex.Client{ - ID: "my-app", - Secret: "secret", - RedirectURIs: []string{"http://localhost:8080/callback"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - Name: "My App", - }), - dex.WithUser(dex.User{ - Email: "u@example.com", Username: "u", Password: "p", - }), + dex.WithClient(app), + dex.WithUser(user), ) + defer testcontainers.TerminateContainer(c) if err != nil { log.Fatalf("run: %v", err) } - defer testcontainers.TerminateContainer(c) + // } _ = oauth2.Config{ ClientID: "my-app", @@ -45,14 +54,23 @@ func ExampleRun_passwordGrant() { // service-account user. (client_credentials requires an upstream // connector — see module README.) ctx := context.Background() + + svc, err := dex.NewClient("svc", + dex.WithClientSecret("s"), + dex.WithClientName("Service"), + dex.WithClientGrantTypes("password"), + ) + if err != nil { + log.Fatalf("new client: %v", err) + } + user, err := dex.NewUser("svc@svc.local", "svc", "svc-secret") + if err != nil { + log.Fatalf("new user: %v", err) + } + c, err := dex.Run(ctx, "dexidp/dex:v2.45.1", - dex.WithClient(dex.Client{ - ID: "svc", Secret: "s", Name: "Service", - GrantTypes: []string{"password"}, - }), - dex.WithUser(dex.User{ - Email: "svc@svc.local", Username: "svc", Password: "svc-secret", - }), + dex.WithClient(svc), + dex.WithUser(user), ) if err != nil { log.Fatalf("run: %v", err) diff --git a/modules/dex/go.mod b/modules/dex/go.mod index 89f1e89e0b..2426fe693c 100644 --- a/modules/dex/go.mod +++ b/modules/dex/go.mod @@ -1,4 +1,4 @@ -module github.com/guilycst/testcontainers-go/modules/dex +module github.com/testcontainers/testcontainers-go/modules/dex go 1.25.0 diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index fc4ac5b8fd..de8874b9d7 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -18,7 +18,7 @@ import ( // // Not safe for concurrent use. func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { - target, err := c.grpcEndpoint(ctx) + target, err := c.GRPCEndpoint(ctx) if err != nil { return err } @@ -30,11 +30,11 @@ func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { resp, err := api.NewDexClient(conn).CreateClient(ctx, &api.CreateClientReq{ Client: &api.Client{ - Id: cl.ID, - Secret: cl.Secret, - RedirectUris: cl.RedirectURIs, - Name: cl.Name, - Public: cl.Public, + Id: cl.id, + Secret: cl.secret, + RedirectUris: cl.redirectURIs, + Name: cl.name, + Public: cl.public, }, }) if err != nil { @@ -50,7 +50,7 @@ func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { // // Not safe for concurrent use. func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { - target, err := c.grpcEndpoint(ctx) + target, err := c.GRPCEndpoint(ctx) if err != nil { return err } @@ -74,7 +74,7 @@ func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { // // Not safe for concurrent use. func (c *DexContainer) AddUser(ctx context.Context, u User) error { - target, err := c.grpcEndpoint(ctx) + target, err := c.GRPCEndpoint(ctx) if err != nil { return err } @@ -84,20 +84,20 @@ func (c *DexContainer) AddUser(ctx context.Context, u User) error { } defer conn.Close() - hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("dex: bcrypt: %w", err) } - userID := u.UserID + userID := u.userID if userID == "" { userID = newUUIDv4() } resp, err := api.NewDexClient(conn).CreatePassword(ctx, &api.CreatePasswordReq{ Password: &api.Password{ - Email: u.Email, + Email: u.email, Hash: hash, - Username: u.Username, + Username: u.username, UserId: userID, }, }) @@ -114,7 +114,7 @@ func (c *DexContainer) AddUser(ctx context.Context, u User) error { // // Not safe for concurrent use. func (c *DexContainer) RemoveUser(ctx context.Context, email string) error { - target, err := c.grpcEndpoint(ctx) + target, err := c.GRPCEndpoint(ctx) if err != nil { return err } diff --git a/modules/dex/options.go b/modules/dex/options.go index b247bf5264..14628ab1cf 100644 --- a/modules/dex/options.go +++ b/modules/dex/options.go @@ -1,6 +1,7 @@ package dex import ( + "errors" "log/slog" "github.com/testcontainers/testcontainers-go" @@ -15,36 +16,39 @@ type connector struct { // options is the module-internal accumulator for Run(). type options struct { - clients []Client - users []User - connectors []connector - issuer string // empty means derive from host:mapped at post-start - skipApprovalScreen bool - storage string - logLevel string - logger *slog.Logger - enablePasswordDB bool // default true; flipped off only if user sets ConnectorMock-only - enableClientCredentials bool + clients []Client + users []User + connectors []connector + issuer string + skipApprovalScreen bool + storage Storage + logLevel slog.Level + logger *slog.Logger + enablePasswordDB bool + enableClientCredentials bool } func defaultOptions() options { return options{ skipApprovalScreen: true, - storage: "sqlite3", - logLevel: "info", + storage: StorageSQLite, + logLevel: slog.LevelInfo, enablePasswordDB: true, } } -// Compiler check: Option implements testcontainers.ContainerCustomizer. -var _ testcontainers.ContainerCustomizer = (Option)(nil) +// Option is a functional option for the Dex module. Options return an error +// so user-supplied values can be validated at Run time rather than failing +// silently in the rendered YAML. +type Option func(*options) error -// Option is a functional option for the Dex module. -type Option func(*options) +// Compiler check: Option implements testcontainers.ContainerCustomizer. +var _ testcontainers.ContainerCustomizer = Option(nil) // Customize is a no-op; Option satisfies the testcontainers.ContainerCustomizer -// interface so callers can pass Options through any API that accepts -// tc-go customizers. Real state mutation happens in Run(). +// interface so callers can pass Options through any API that accepts tc-go +// customizers. Real state mutation happens in Run, which drives the Option +// functions against an internal accumulator. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { return nil } @@ -52,32 +56,46 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // WithClient registers a static client in Dex's YAML config. Unlike // gRPC-added clients, these may declare custom grant types. func WithClient(c Client) Option { - return func(o *options) { + return func(o *options) error { + if c.id == "" { + return errors.New("dex: WithClient requires a Client constructed via NewClient") + } o.clients = append(o.clients, c) + return nil } } // WithUser registers a static password entry. The password DB connector is // enabled by default, so no extra option is needed to consume the entry. func WithUser(u User) Option { - return func(o *options) { + return func(o *options) error { + if u.email == "" { + return errors.New("dex: WithUser requires a User constructed via NewUser") + } o.users = append(o.users, u) + return nil } } -// WithConnector enables a Dex connector by type. For ConnectorPassword, this -// is a no-op (the password DB is enabled by default whenever WithUser is -// passed or no other connector is configured). For ConnectorMock, the -// mockCallback connector is added. +// WithConnector enables a Dex connector by type. For ConnectorPassword this +// is a no-op — the password DB is enabled by default and the template +// handles it separately. For other connectors (e.g. ConnectorMock), the +// entry is added to the rendered YAML. +// +// Returns an error when id or name is blank for a non-password connector. func WithConnector(t ConnectorType, id, name string) Option { - return func(o *options) { - // ConnectorPassword is handled via enablePasswordDB in the YAML template; - // appending it to o.connectors would emit a spurious `type: password` - // entry that Dex does not recognize. + return func(o *options) error { if t == ConnectorPassword { - return + return nil + } + if id == "" { + return errors.New("dex: connector id must not be blank") + } + if name == "" { + return errors.New("dex: connector name must not be blank") } o.connectors = append(o.connectors, connector{Type: t, ID: id, Name: name}) + return nil } } @@ -86,50 +104,74 @@ func WithConnector(t ConnectorType, id, name string) Option { // for ensuring the URL is reachable from every client (tests and sibling // containers). func WithIssuer(url string) Option { - return func(o *options) { + return func(o *options) error { + if url == "" { + return errors.New("dex: issuer URL must not be blank") + } o.issuer = url + return nil } } // WithSkipApprovalScreen toggles Dex's oauth2.skipApprovalScreen. Default: true. func WithSkipApprovalScreen(skip bool) Option { - return func(o *options) { + return func(o *options) error { o.skipApprovalScreen = skip + return nil + } +} + +// WithStorage sets Dex's storage backend. Default: StorageSQLite. +func WithStorage(s Storage) Option { + return func(o *options) error { + if s == "" { + return errors.New("dex: storage must not be blank") + } + o.storage = s + return nil } } -// WithStorage sets Dex's storage backend. Default: "sqlite3". "memory" is -// also supported by Dex. -func WithStorage(kind string) Option { - return func(o *options) { - o.storage = kind +// WithDisablePasswordDB disables Dex's built-in password connector. The +// caller must then configure at least one other connector via WithConnector, +// otherwise Run returns ErrNoAuthSource. +func WithDisablePasswordDB() Option { + return func(o *options) error { + o.enablePasswordDB = false + return nil } } // WithLogger routes Dex container logs through the supplied slog.Logger. // When nil or not called, Dex container logs are discarded. func WithLogger(logger *slog.Logger) Option { - return func(o *options) { + return func(o *options) error { o.logger = logger + return nil } } -// WithLogLevel sets Dex's own --log-level flag. Valid: "debug", "info", -// "warn", "error". Default: "info". -func WithLogLevel(level string) Option { - return func(o *options) { +// WithLogLevel sets Dex's own --log-level flag. Accepts a standard library +// slog.Level; values are mapped to Dex's level vocabulary (debug, info, +// warn, error). Default: slog.LevelInfo. +func WithLogLevel(level slog.Level) Option { + return func(o *options) error { o.logLevel = level + return nil } } // WithEnableClientCredentials enables Dex's OAuth2 client_credentials grant // via the DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true environment -// variable. Requires Dex ≥ v2.46.0 (not yet released at time of writing) -// or the dexidp/dex:master image tag; earlier releases do not recognize -// the flag. The module logs a warning when an older image tag is -// detected. See the module README for image compatibility. +// variable. +// +// Requires Dex ≥ v2.46.0 or the dexidp/dex:master image tag. Earlier +// releases silently ignore the flag and token exchanges fail with +// unsupported_grant_type. This module does not validate the image tag — +// the caller must pin a compatible image. func WithEnableClientCredentials() Option { - return func(o *options) { + return func(o *options) error { o.enableClientCredentials = true + return nil } } diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go index a84ff76fee..cc9e0e0839 100644 --- a/modules/dex/options_test.go +++ b/modules/dex/options_test.go @@ -5,36 +5,46 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOptions_Defaults(t *testing.T) { o := defaultOptions() assert.True(t, o.skipApprovalScreen) - assert.Equal(t, "sqlite3", o.storage) - assert.Equal(t, "info", o.logLevel) + assert.Equal(t, StorageSQLite, o.storage) + assert.Equal(t, slog.LevelInfo, o.logLevel) assert.True(t, o.enablePasswordDB) assert.Empty(t, o.issuer) } func TestOptions_Apply(t *testing.T) { o := defaultOptions() + + clientA, err := NewClient("a", WithClientSecret("s")) + require.NoError(t, err) + clientB, err := NewClient("b") + require.NoError(t, err) + user, err := NewUser("u@e.com", "u", "p") + require.NoError(t, err) + applyAll := []Option{ - WithClient(Client{ID: "a", Secret: "s"}), - WithClient(Client{ID: "b"}), - WithUser(User{Email: "u@e.com", Username: "u", Password: "p"}), + WithClient(clientA), + WithClient(clientB), + WithUser(user), WithConnector(ConnectorMock, "m", "Mock"), WithIssuer("http://dex:5556/dex"), WithSkipApprovalScreen(false), - WithStorage("memory"), - WithLogLevel("debug"), + WithStorage(StorageMemory), + WithLogLevel(slog.LevelDebug), WithLogger(slog.Default()), + WithDisablePasswordDB(), } for _, opt := range applyAll { - opt(&o) + require.NoError(t, opt(&o)) } assert.Len(t, o.clients, 2) - assert.Equal(t, "b", o.clients[1].ID) + assert.Equal(t, "b", o.clients[1].id) assert.Len(t, o.users, 1) assert.Len(t, o.connectors, 1) assert.Equal(t, ConnectorMock, o.connectors[0].Type) @@ -42,14 +52,51 @@ func TestOptions_Apply(t *testing.T) { assert.Equal(t, "Mock", o.connectors[0].Name) assert.Equal(t, "http://dex:5556/dex", o.issuer) assert.False(t, o.skipApprovalScreen) - assert.Equal(t, "memory", o.storage) - assert.Equal(t, "debug", o.logLevel) + assert.Equal(t, StorageMemory, o.storage) + assert.Equal(t, slog.LevelDebug, o.logLevel) assert.NotNil(t, o.logger) + assert.False(t, o.enablePasswordDB) } func TestOptions_WithConnectorPassword_IsNoOp(t *testing.T) { o := defaultOptions() - WithConnector(ConnectorPassword, "local", "Local")(&o) + require.NoError(t, WithConnector(ConnectorPassword, "local", "Local")(&o)) assert.Empty(t, o.connectors, "ConnectorPassword must not be appended; password DB covers it") assert.True(t, o.enablePasswordDB, "password DB stays enabled by default") } + +func TestOptions_WithConnector_RejectsBlankFields(t *testing.T) { + o := defaultOptions() + + err := WithConnector(ConnectorMock, "", "Mock")(&o) + assert.Error(t, err, "blank id must be rejected") + + err = WithConnector(ConnectorMock, "id", "")(&o) + assert.Error(t, err, "blank name must be rejected") + + assert.Empty(t, o.connectors, "rejected options must not mutate state") +} + +func TestOptions_WithIssuer_RejectsBlank(t *testing.T) { + o := defaultOptions() + assert.Error(t, WithIssuer("")(&o)) + assert.Empty(t, o.issuer) +} + +func TestOptions_WithStorage_RejectsBlank(t *testing.T) { + o := defaultOptions() + assert.Error(t, WithStorage("")(&o)) + assert.Equal(t, StorageSQLite, o.storage, "failed option must leave default intact") +} + +func TestOptions_WithClient_RejectsZeroValue(t *testing.T) { + o := defaultOptions() + assert.Error(t, WithClient(Client{})(&o), "zero Client must be rejected — force NewClient") + assert.Empty(t, o.clients) +} + +func TestOptions_WithUser_RejectsZeroValue(t *testing.T) { + o := defaultOptions() + assert.Error(t, WithUser(User{})(&o), "zero User must be rejected — force NewUser") + assert.Empty(t, o.users) +} diff --git a/modules/dex/types.go b/modules/dex/types.go index 9e406dd64b..f1de667451 100644 --- a/modules/dex/types.go +++ b/modules/dex/types.go @@ -2,40 +2,155 @@ package dex import "errors" -// Client is a static OAuth2 client registered with Dex. +// Client is a static OAuth2 client registered with Dex at boot time via +// WithClient. Construct with NewClient so invalid configuration surfaces at +// call-site rather than at Run. type Client struct { - // ID is the client_id. Required. - ID string - // Secret is the client secret. Required unless Public is true. - Secret string - // RedirectURIs lists allowed redirect URIs. At least one is required for - // authorization_code clients. - RedirectURIs []string - // GrantTypes lists OAuth2 grants the client may use. Defaults to - // ["authorization_code", "refresh_token"]. Values: authorization_code, - // refresh_token, client_credentials, password. - // - // Only takes effect for clients registered via WithClient (YAML). - // Clients added at runtime via AddClient inherit Dex's defaults - // (authorization_code + refresh_token) because the gRPC api.Client proto - // has no grant_types field. - GrantTypes []string - // Public marks the client as public (no secret). Used for PKCE flows. - Public bool - // Name is the human-readable display name shown on Dex's consent screen. - Name string + id string + secret string + name string + redirectURIs []string + grantTypes []string + public bool } -// User is a static password entry in Dex's password connector. +// ID returns the client_id. +func (c Client) ID() string { return c.id } + +// ClientOption configures a Client during NewClient. +type ClientOption func(*Client) error + +// NewClient creates a Client registered statically at boot. ID is required; +// every other field is optional and may be set through ClientOption values. +// +// Returns an error when the ID is blank or any option rejects its input. +func NewClient(id string, opts ...ClientOption) (Client, error) { + if id == "" { + return Client{}, errors.New("dex: client id must not be blank") + } + c := Client{id: id} + for _, opt := range opts { + if err := opt(&c); err != nil { + return Client{}, err + } + } + return c, nil +} + +// WithClientSecret sets the client secret. Required for confidential clients; +// omit for public (PKCE) clients via WithClientPublic. +func WithClientSecret(s string) ClientOption { + return func(c *Client) error { + if s == "" { + return errors.New("dex: client secret must not be blank") + } + c.secret = s + return nil + } +} + +// WithClientName sets the human-readable display name shown on Dex's consent +// screen. +func WithClientName(n string) ClientOption { + return func(c *Client) error { + if n == "" { + return errors.New("dex: client name must not be blank") + } + c.name = n + return nil + } +} + +// WithClientRedirectURIs appends to the list of allowed redirect URIs. At +// least one is required for authorization_code clients. Values are appended +// across calls; blank entries are rejected. +func WithClientRedirectURIs(uris ...string) ClientOption { + return func(c *Client) error { + for _, u := range uris { + if u == "" { + return errors.New("dex: client redirect URI must not be blank") + } + } + c.redirectURIs = append(c.redirectURIs, uris...) + return nil + } +} + +// WithClientGrantTypes appends to the allowed OAuth2 grants. Defaults to +// ["authorization_code", "refresh_token"] when unset. Accepted values: +// authorization_code, refresh_token, client_credentials, password. +// +// Only takes effect for clients registered via WithClient (YAML). Clients +// added at runtime via AddClient inherit Dex's defaults because the gRPC +// api.Client proto has no grant_types field. +func WithClientGrantTypes(grants ...string) ClientOption { + return func(c *Client) error { + for _, g := range grants { + if g == "" { + return errors.New("dex: client grant type must not be blank") + } + } + c.grantTypes = append(c.grantTypes, grants...) + return nil + } +} + +// WithClientPublic marks the client as public — no secret, intended for PKCE +// flows from untrusted clients (mobile, SPA). +func WithClientPublic() ClientOption { + return func(c *Client) error { + c.public = true + return nil + } +} + +// User is a static password entry in Dex's password connector. Construct +// with NewUser. type User struct { - // Email is the user's email address. Required. - Email string - // Username is the preferred_username claim. Required. - Username string - // Password is the cleartext password. Bcrypted internally. Required. - Password string - // UserID is the stable subject claim. If empty, a UUID is generated. - UserID string + email string + username string + password string + userID string +} + +// Email returns the email address. +func (u User) Email() string { return u.email } + +// UserOption configures a User during NewUser. +type UserOption func(*User) error + +// NewUser creates a static password entry. Email, username and password are +// required; a user ID may be pinned via WithUserID (else a UUIDv4 is +// generated at YAML render time). +func NewUser(email, username, password string, opts ...UserOption) (User, error) { + if email == "" { + return User{}, errors.New("dex: user email must not be blank") + } + if username == "" { + return User{}, errors.New("dex: user username must not be blank") + } + if password == "" { + return User{}, errors.New("dex: user password must not be blank") + } + u := User{email: email, username: username, password: password} + for _, opt := range opts { + if err := opt(&u); err != nil { + return User{}, err + } + } + return u, nil +} + +// WithUserID pins the stable subject claim. When unset, NewUser leaves +// userID blank and a UUIDv4 is generated at YAML render time. +func WithUserID(id string) UserOption { + return func(u *User) error { + if id == "" { + return errors.New("dex: user id must not be blank") + } + u.userID = id + return nil + } } // ConnectorType selects a Dex connector kind. @@ -50,6 +165,18 @@ const ( ConnectorMock ConnectorType = "mockCallback" ) +// Storage selects Dex's storage backend. Defaults to StorageSQLite. +type Storage string + +const ( + // StorageSQLite uses an on-disk SQLite database inside the container. + // Ephemeral — destroyed when the container is removed. + StorageSQLite Storage = "sqlite3" + // StorageMemory keeps all state in process memory. Fastest; unsuitable + // when multiple Dex replicas need to share state. + StorageMemory Storage = "memory" +) + var ( // ErrClientExists is returned by AddClient when a client with the given // ID is already registered. @@ -59,6 +186,7 @@ var ( ErrUserExists = errors.New("dex: user already exists") // ErrNoAuthSource is returned when the rendered Dex config would boot // with no working authentication source — neither the password DB nor - // any connector. The password DB is enabled by default. + // any connector. The password DB is enabled by default; callers must + // explicitly disable it via WithDisablePasswordDB to trigger this error. ErrNoAuthSource = errors.New("dex: no auth source configured") ) From 2f99c333280cea76d2c34b82cb15c8b37503dd16 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 10:39:30 -0300 Subject: [PATCH 04/21] review: apply PR #3659 feedback Actionable: - config.go: omit client_credentials from server-level grantTypes unless WithEnableClientCredentials() is set - dex.go: derive readiness probe path from configured issuer - config.go: fix dexLogLevel doc comment (rounds UP) - docs/modules/dex.md, README.md: add bash hint to go-get fenced block Nits accepted: Option doc note, WithLogger(nil) no-op, WithConnector ConnectorPassword doc, newUUIDv4 returns error, ErrClientNotFound / ErrUserNotFound sentinels, cookiejar error captured, 5s->30s log poll, dead `_ = body` removed, DEX_TEST_MASTER gate, http.Method* constants, examples_test.go defer ordering, README timeless wording + endpoint style. Deferred: WithClientGrantTypes value whitelist (too restrictive), log.go quoted-value unescape (low value). --- docs/modules/dex.md | 2 +- modules/dex/README.md | 21 +++++++++--------- modules/dex/config.go | 34 ++++++++++++++++++++++------- modules/dex/dex.go | 14 ++++++++++-- modules/dex/dex_test.go | 24 +++++++++++++------- modules/dex/dextest_helpers_test.go | 4 ++-- modules/dex/examples_test.go | 2 +- modules/dex/grpc.go | 10 ++++++--- modules/dex/options.go | 28 ++++++++++++++++-------- modules/dex/types.go | 6 +++++ 10 files changed, 101 insertions(+), 44 deletions(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index 3f3f36a66a..eabe33a17c 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -12,7 +12,7 @@ OAuth2/OIDC flows end-to-end instead of mocking the token endpoint. Please run the following command to add the Dex module to your Go dependencies: -``` +```bash go get github.com/testcontainers/testcontainers-go/modules/dex ``` diff --git a/modules/dex/README.md b/modules/dex/README.md index 587bc8232a..46efef942a 100644 --- a/modules/dex/README.md +++ b/modules/dex/README.md @@ -8,7 +8,7 @@ The Testcontainers module for [Dex](https://github.com/dexidp/dex), a CNCF OIDC ## Adding this module to your project dependencies -``` +```bash go get github.com/testcontainers/testcontainers-go/modules/dex ``` @@ -49,13 +49,11 @@ fmt.Println("issuer:", c.IssuerURL()) `authorization_code`, `refresh_token`, `password`. Declare per-client via `WithClientGrantTypes(...)`. -**`client_credentials` requires Dex ≥ v2.46.0 (not yet released at time of -writing) with the feature flag enabled.** Dex gates this grant behind the -env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`. Use -`WithEnableClientCredentials()` to set it automatically. Currently available -in `dexidp/dex:master` / `:latest` images; the first tagged release -containing it (likely `v2.46.0`) will also support it. The module does not -validate the image tag — the caller must pin a compatible image. +**`client_credentials` requires Dex ≥ v2.46.0 (or `dexidp/dex:master` until +that release ships) with the feature flag enabled.** Dex gates this grant +behind the env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`. +Use `WithEnableClientCredentials()` to set it automatically. The module +does not validate the image tag — the caller must pin a compatible image. ```go svc, err := dex.NewClient("svc", @@ -142,7 +140,9 @@ when a human-readable identifier is needed. ### Endpoint getters `IssuerURL`, `ConfigEndpoint`, `JWKSEndpoint`, `TokenEndpoint`, -`AuthEndpoint`, `GRPCEndpoint(ctx) (string, error)`. +`AuthEndpoint`, `GRPCEndpoint`. See godoc for signatures — `GRPCEndpoint` +takes `context.Context` and may return an error; the others are +string-only getters. ### Runtime mutation (gRPC) @@ -151,7 +151,8 @@ use. - `AddClient` returns `ErrClientExists` when the ID is already registered. - `AddUser` returns `ErrUserExists` when the email is already registered. -- `Remove*` return a plain error containing `"not found"` on unknown IDs. +- `RemoveClient` wraps `ErrClientNotFound` when the ID is absent. +- `RemoveUser` wraps `ErrUserNotFound` when the email is absent. ## Known limitations diff --git a/modules/dex/config.go b/modules/dex/config.go index 97cdf7a55e..99f057da73 100644 --- a/modules/dex/config.go +++ b/modules/dex/config.go @@ -74,10 +74,16 @@ type yamlConnector struct { Name string `yaml:"name"` } -var defaultGrantTypes = []string{ +// baseGrantTypes is the server-level grantTypes list emitted into the +// Dex config by default. client_credentials is intentionally omitted — Dex +// ≥ v2.46.0 rejects it at startup unless +// DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true is set, and v2.45.x +// treats advertising an unenabled grant as a configuration error. render +// appends client_credentials when WithEnableClientCredentials() has set +// the matching env var on the container. +var baseGrantTypes = []string{ "authorization_code", "refresh_token", - "client_credentials", "password", } @@ -118,7 +124,11 @@ func render(o options) ([]byte, error) { } uid := u.userID if uid == "" { - uid = newUUIDv4() + var uidErr error + uid, uidErr = newUUIDv4() + if uidErr != nil { + return nil, fmt.Errorf("dex: generate user id for %q: %w", u.email, uidErr) + } } passwords = append(passwords, yamlPassword{ Email: u.email, @@ -137,9 +147,13 @@ func render(o options) ([]byte, error) { }) } + grantTypes := append([]string(nil), baseGrantTypes...) + if o.enableClientCredentials { + grantTypes = append(grantTypes, "client_credentials") + } oauth2 := oauth2Block{ SkipApprovalScreen: o.skipApprovalScreen, - GrantTypes: defaultGrantTypes, + GrantTypes: grantTypes, } // Dex requires oauth2.passwordConnector to name the connector ID used for // the password grant (ROPC). When the built-in password DB is active its @@ -170,7 +184,9 @@ func render(o options) ([]byte, error) { // dexLogLevel maps a standard library slog.Level to the string vocabulary // Dex recognises in its YAML `logger.level` field. Values between slog's -// fixed levels round down (e.g. slog.LevelInfo+1 → "info"). +// fixed levels round up to the next defined level (e.g. slog.LevelInfo+1 +// → "warn", slog.LevelWarn+1 → "error"); sub-debug values clamp to +// "debug". func dexLogLevel(l slog.Level) string { switch { case l <= slog.LevelDebug: @@ -185,13 +201,15 @@ func dexLogLevel(l slog.Level) string { } // newUUIDv4 generates an RFC 4122 v4 UUID without importing a third-party dep. -func newUUIDv4() string { +// Returns an error from crypto/rand.Read rather than panicking so callers in +// the container-startup and gRPC-admin paths can surface it up the chain. +func newUUIDv4() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Errorf("dex: read randomness: %w", err)) + return "", fmt.Errorf("dex: read randomness: %w", err) } b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil } diff --git a/modules/dex/dex.go b/modules/dex/dex.go index 9339b94c7c..d2ca8f0d68 100644 --- a/modules/dex/dex.go +++ b/modules/dex/dex.go @@ -27,6 +27,8 @@ package dex import ( "context" "fmt" + "net/url" + "strings" "time" "github.com/testcontainers/testcontainers-go" @@ -83,9 +85,17 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return fmt.Errorf("dex: copy yaml: %w", err) } - // Wait for Dex to serve discovery + gRPC port before returning. + // Wait for Dex to serve discovery + gRPC port before returning. The + // discovery path is derived from the issuer's path component so + // WithIssuer("http://host/idp") probes "/idp/.well-known/...", not + // the default "/dex/...". + issuerURL, err := url.Parse(settings.issuer) + if err != nil { + return fmt.Errorf("dex: parse issuer: %w", err) + } + discoveryPath := strings.TrimRight(issuerURL.Path, "/") + "/.well-known/openid-configuration" ready := wait.ForAll( - wait.ForHTTP("/dex/.well-known/openid-configuration"). + wait.ForHTTP(discoveryPath). WithPort(httpPort). WithStartupTimeout(60*time.Second), wait.ForListeningPort(grpcPort). diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index f70fb3bb97..bda6b8c80b 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "os" "strings" "sync" "testing" @@ -195,7 +196,7 @@ func TestWithLogger_CapturesDexOutput(t *testing.T) { // CreatePassword, etc.) — only boot and key-rotation events reach the // log stream. "listening on" is the last boot-time line and therefore // the strongest stable signal we can assert on. - deadline := time.Now().Add(5 * time.Second) + deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if strings.Contains(buf.String(), "listening on") { return @@ -535,7 +536,8 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { // Do the login dance manually — drivePasswordAuthCode uses require.NoError // which would abort the test on the expected failure. - jar, _ := cookiejar.New(nil) + jar, err := cookiejar.New(nil) + require.NoError(t, err) client := &http.Client{ Jar: jar, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -549,15 +551,13 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { authURL := cfg.AuthCodeURL("s1") resp, err := client.Get(authURL) require.NoError(t, err) - body, _ := io.ReadAll(resp.Body) resp.Body.Close() loginURL := resp.Request.URL.String() - // Use the same action-extractor approach as the helper — but for a - // negative path we just POST blindly to the request URL. Dex's - // local login endpoint accepts POSTs at the same URL the GET returned. - _ = body - + // Negative-path login: POST blindly to the GET's final URL. Dex's + // local login endpoint accepts POSTs at the same URL the GET returned; + // the helper's form-action extraction is unnecessary here because we + // only care that the POST fails, not which rendered path it takes. form := url.Values{"login": {"late@e.com"}, "password": {"p"}} r2, err := client.Post(loginURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) require.NoError(t, err) @@ -618,6 +618,14 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { } func TestClientCredentials_WithFeatureFlag(t *testing.T) { + // dexImageWithCC is the floating dexidp/dex:master tag. Its contents + // shift without warning, so this test opts in via DEX_TEST_MASTER=1 to + // keep the default CI run deterministic. Once Dex v2.46.0 ships, swap + // dexImageWithCC for the pinned tag and drop this gate. + if os.Getenv("DEX_TEST_MASTER") != "1" { + t.Skip("set DEX_TEST_MASTER=1 to run; uses floating dexidp/dex:master tag") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() diff --git a/modules/dex/dextest_helpers_test.go b/modules/dex/dextest_helpers_test.go index 463d89132f..324528f1fd 100644 --- a/modules/dex/dextest_helpers_test.go +++ b/modules/dex/dextest_helpers_test.go @@ -42,7 +42,7 @@ func drivePasswordAuthCode(t *testing.T, ctx context.Context, cfg oauth2.Config, authURL := cfg.AuthCodeURL("state-xyz", oauth2.AccessTypeOffline) // Step 1: GET /auth → follow redirects until the login form page. - req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) require.NoError(t, err) resp, err := client.Do(req) require.NoError(t, err) @@ -62,7 +62,7 @@ func drivePasswordAuthCode(t *testing.T, ctx context.Context, cfg oauth2.Config, // Step 2: POST the login form. form := url.Values{"login": {email}, "password": {password}} - postReq, err := http.NewRequestWithContext(ctx, "POST", actionURL.String(), strings.NewReader(form.Encode())) + postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, actionURL.String(), strings.NewReader(form.Encode())) require.NoError(t, err) postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go index 2f08b95be8..d355068075 100644 --- a/modules/dex/examples_test.go +++ b/modules/dex/examples_test.go @@ -72,10 +72,10 @@ func ExampleRun_passwordGrant() { dex.WithClient(svc), dex.WithUser(user), ) + defer testcontainers.TerminateContainer(c) if err != nil { log.Fatalf("run: %v", err) } - defer testcontainers.TerminateContainer(c) cfg := oauth2.Config{ ClientID: "svc", ClientSecret: "s", diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index de8874b9d7..4dc6e1c335 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -65,7 +65,7 @@ func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { return fmt.Errorf("dex: delete client: %w", err) } if resp.NotFound { - return fmt.Errorf("dex: client %q not found", id) + return fmt.Errorf("%w: %q", ErrClientNotFound, id) } return nil } @@ -90,7 +90,11 @@ func (c *DexContainer) AddUser(ctx context.Context, u User) error { } userID := u.userID if userID == "" { - userID = newUUIDv4() + uid, uidErr := newUUIDv4() + if uidErr != nil { + return fmt.Errorf("dex: generate user id: %w", uidErr) + } + userID = uid } resp, err := api.NewDexClient(conn).CreatePassword(ctx, &api.CreatePasswordReq{ @@ -129,7 +133,7 @@ func (c *DexContainer) RemoveUser(ctx context.Context, email string) error { return fmt.Errorf("dex: delete password: %w", err) } if resp.NotFound { - return fmt.Errorf("dex: user %q not found", email) + return fmt.Errorf("%w: %q", ErrUserNotFound, email) } return nil } diff --git a/modules/dex/options.go b/modules/dex/options.go index 14628ab1cf..f76a0d1b16 100644 --- a/modules/dex/options.go +++ b/modules/dex/options.go @@ -40,15 +40,20 @@ func defaultOptions() options { // Option is a functional option for the Dex module. Options return an error // so user-supplied values can be validated at Run time rather than failing // silently in the rendered YAML. +// +// NOTE: Options must be passed directly to dex.Run. They satisfy the +// testcontainers.ContainerCustomizer interface only so the Run signature +// can accept them alongside generic tc-go customizers (e.g. network.With*) +// — Option.Customize is a no-op, so an Option forwarded through any +// wrapper that dispatches via Customize (instead of type-asserting to +// Option) is silently dropped. type Option func(*options) error // Compiler check: Option implements testcontainers.ContainerCustomizer. var _ testcontainers.ContainerCustomizer = Option(nil) -// Customize is a no-op; Option satisfies the testcontainers.ContainerCustomizer -// interface so callers can pass Options through any API that accepts tc-go -// customizers. Real state mutation happens in Run, which drives the Option -// functions against an internal accumulator. +// Customize is a no-op; real state mutation happens inside Run. See the +// Option type-level doc for why this is a no-op. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { return nil } @@ -79,10 +84,10 @@ func WithUser(u User) Option { // WithConnector enables a Dex connector by type. For ConnectorPassword this // is a no-op — the password DB is enabled by default and the template -// handles it separately. For other connectors (e.g. ConnectorMock), the -// entry is added to the rendered YAML. -// -// Returns an error when id or name is blank for a non-password connector. +// handles it separately; id and name are ignored and blank-field +// validation is skipped in that case. For other connectors +// (e.g. ConnectorMock) the entry is added to the rendered YAML, and blank +// id or name returns an error. func WithConnector(t ConnectorType, id, name string) Option { return func(o *options) error { if t == ConnectorPassword { @@ -143,9 +148,14 @@ func WithDisablePasswordDB() Option { } // WithLogger routes Dex container logs through the supplied slog.Logger. -// When nil or not called, Dex container logs are discarded. +// When unset, Dex container logs are discarded. Calling WithLogger(nil) +// is a no-op; to discard logs again after setting a logger, drop the +// option rather than passing nil. func WithLogger(logger *slog.Logger) Option { return func(o *options) error { + if logger == nil { + return nil + } o.logger = logger return nil } diff --git a/modules/dex/types.go b/modules/dex/types.go index f1de667451..2efb67f265 100644 --- a/modules/dex/types.go +++ b/modules/dex/types.go @@ -181,9 +181,15 @@ var ( // ErrClientExists is returned by AddClient when a client with the given // ID is already registered. ErrClientExists = errors.New("dex: client already exists") + // ErrClientNotFound is returned by RemoveClient when no client matches + // the given ID. + ErrClientNotFound = errors.New("dex: client not found") // ErrUserExists is returned by AddUser when a user with the given email // is already registered. ErrUserExists = errors.New("dex: user already exists") + // ErrUserNotFound is returned by RemoveUser when no user matches the + // given email. + ErrUserNotFound = errors.New("dex: user not found") // ErrNoAuthSource is returned when the rendered Dex config would boot // with no working authentication source — neither the password DB nor // any connector. The password DB is enabled by default; callers must From fabe7d6f5633e5be1409881a643c7e664dd15b87 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 10:51:23 -0300 Subject: [PATCH 05/21] review: apply PR #3659 round-2 coderabbit nits - AddUser hashes with testBcryptCost (consistency + speed) - RemoveClient/RemoveUser tests use ErrorIs vs sentinels --- modules/dex/dex_test.go | 4 ++-- modules/dex/grpc.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index bda6b8c80b..cd5f9d7e9b 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -147,7 +147,7 @@ func TestGRPC_AddRemoveClient(t *testing.T) { // Second remove errors (not-found). err = c.RemoveClient(ctx, cl.ID()) - assert.Error(t, err) + assert.ErrorIs(t, err, dex.ErrClientNotFound) } func TestGRPC_AddRemoveUser(t *testing.T) { @@ -170,7 +170,7 @@ func TestGRPC_AddRemoveUser(t *testing.T) { // Second removal errors. err = c.RemoveUser(ctx, u.Email()) - assert.Error(t, err) + assert.ErrorIs(t, err, dex.ErrUserNotFound) } func TestWithLogger_CapturesDexOutput(t *testing.T) { diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index 4dc6e1c335..3e518035bc 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -84,7 +84,7 @@ func (c *DexContainer) AddUser(ctx context.Context, u User) error { } defer conn.Close() - hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(u.password), testBcryptCost) if err != nil { return fmt.Errorf("dex: bcrypt: %w", err) } From 17ab589dcb87a9bb468cdbb1b4dadeddecf7b5ea Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 10:58:01 -0300 Subject: [PATCH 06/21] review: close sidecar log stream (PR #3659 nit) --- modules/dex/dex_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index cd5f9d7e9b..aab8b6bdc4 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -610,6 +610,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { logs, err := sidecar.Logs(ctx) require.NoError(t, err) + defer logs.Close() body, err := io.ReadAll(logs) require.NoError(t, err) From 1194dcee7754f3bd8a523777767ef0a3485298b2 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 11:17:37 -0300 Subject: [PATCH 07/21] review: ctx-aware HTTP calls (PR #3659 nit) --- modules/dex/dex_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index aab8b6bdc4..ec59509e38 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -69,7 +69,9 @@ func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, grpcEP) - resp, err := http.Get(c.ConfigEndpoint()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ConfigEndpoint(), nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, 200, resp.StatusCode, "discovery endpoint must return 200") @@ -109,7 +111,9 @@ func TestRun_WithIssuerOverride(t *testing.T) { require.NoError(t, err) reachable := fmt.Sprintf("http://%s:%s/dex/.well-known/openid-configuration", host, mapped.Port()) - resp, err := http.Get(reachable) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reachable, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, 200, resp.StatusCode) @@ -477,7 +481,7 @@ func TestMockConnector_IssuesToken(t *testing.T) { // Drive the /auth URL with connector_id=mock so Dex skips the login form. authURL := cfg.AuthCodeURL("state-mock") + "&connector_id=mock" - req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) require.NoError(t, err) client := &http.Client{ @@ -549,7 +553,9 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { } authURL := cfg.AuthCodeURL("s1") - resp, err := client.Get(authURL) + getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) + require.NoError(t, err) + resp, err := client.Do(getReq) require.NoError(t, err) resp.Body.Close() loginURL := resp.Request.URL.String() @@ -559,7 +565,10 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { // the helper's form-action extraction is unnecessary here because we // only care that the POST fails, not which rendered path it takes. form := url.Values{"login": {"late@e.com"}, "password": {"p"}} - r2, err := client.Post(loginURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) + require.NoError(t, err) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r2, err := client.Do(postReq) require.NoError(t, err) body2, _ := io.ReadAll(r2.Body) r2.Body.Close() From f58a75d059ad3fe38ccd7e3df4975ae35b67e84b Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:05:31 -0300 Subject: [PATCH 08/21] =?UTF-8?q?chore(testcontainers-go/dex):=20rename=20?= =?UTF-8?q?DexContainer=20=E2=86=92=20Container=20per=20module=20conventio?= =?UTF-8?q?n=20(PR=20#3659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module guide (https://golang.testcontainers.org/modules/) states: "Use the name Container, not a module-specific name like PostgresContainer or RedisContainer". Reviewer @mdelapenya requested this explicitly on modules/dex/dex.go:46. Pure rename — type declaration, constructor return, and all method receivers in dex.go and grpc.go. Test files use type inference (c, err := dex.Run(...)) and need no edits. Doc comment in dex.md Run signature updated to match. --- docs/modules/dex.md | 2 +- modules/dex/dex.go | 20 ++++++++++---------- modules/dex/grpc.go | 8 ++++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index eabe33a17c..e1d065f15a 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -32,7 +32,7 @@ The Dex module exposes one entrypoint function to create the Dex container, and this function receives three parameters: ```golang -func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DexContainer, error) +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) ``` - `context.Context`, the Go context. diff --git a/modules/dex/dex.go b/modules/dex/dex.go index d2ca8f0d68..0db2ccef9f 100644 --- a/modules/dex/dex.go +++ b/modules/dex/dex.go @@ -42,8 +42,8 @@ const ( configPath = "/etc/dex/dex.yml" ) -// DexContainer is a running Dex OIDC provider. -type DexContainer struct { +// Container is a running Dex OIDC provider. +type Container struct { testcontainers.Container issuer string } @@ -51,7 +51,7 @@ type DexContainer struct { // Run starts Dex. The image is required (tc-go convention). Module options // (WithClient, WithUser, WithIssuer, ...) and generic tc-go customizers may // be mixed in the opts slice. -func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DexContainer, error) { +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { @@ -61,7 +61,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } } - container := &DexContainer{} + container := &Container{} postStart := func(ctx context.Context, c testcontainers.Container) error { if settings.issuer == "" { @@ -140,26 +140,26 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } // IssuerURL returns Dex's issuer URL. Empty if Run has not started. -func (c *DexContainer) IssuerURL() string { return c.issuer } +func (c *Container) IssuerURL() string { return c.issuer } // ConfigEndpoint returns the OIDC discovery document URL. -func (c *DexContainer) ConfigEndpoint() string { +func (c *Container) ConfigEndpoint() string { return c.issuer + "/.well-known/openid-configuration" } // JWKSEndpoint returns the JSON Web Key Set URL. -func (c *DexContainer) JWKSEndpoint() string { return c.issuer + "/keys" } +func (c *Container) JWKSEndpoint() string { return c.issuer + "/keys" } // TokenEndpoint returns the OAuth2 token URL. -func (c *DexContainer) TokenEndpoint() string { return c.issuer + "/token" } +func (c *Container) TokenEndpoint() string { return c.issuer + "/token" } // AuthEndpoint returns the OAuth2 authorization URL. -func (c *DexContainer) AuthEndpoint() string { return c.issuer + "/auth" } +func (c *Container) AuthEndpoint() string { return c.issuer + "/auth" } // GRPCEndpoint returns host:mappedPort for Dex's gRPC admin API. Errors // propagate from the Docker API, or report that the container has not been // started. -func (c *DexContainer) GRPCEndpoint(ctx context.Context) (string, error) { +func (c *Container) GRPCEndpoint(ctx context.Context) (string, error) { if c.Container == nil { return "", fmt.Errorf("dex: container not started") } diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index 3e518035bc..abf4f341e6 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -17,7 +17,7 @@ import ( // custom grants, register the client pre-start via WithClient. // // Not safe for concurrent use. -func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { +func (c *Container) AddClient(ctx context.Context, cl Client) error { target, err := c.GRPCEndpoint(ctx) if err != nil { return err @@ -49,7 +49,7 @@ func (c *DexContainer) AddClient(ctx context.Context, cl Client) error { // RemoveClient deletes a client by ID. // // Not safe for concurrent use. -func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { +func (c *Container) RemoveClient(ctx context.Context, id string) error { target, err := c.GRPCEndpoint(ctx) if err != nil { return err @@ -73,7 +73,7 @@ func (c *DexContainer) RemoveClient(ctx context.Context, id string) error { // AddUser registers a user in Dex's password DB via gRPC. // // Not safe for concurrent use. -func (c *DexContainer) AddUser(ctx context.Context, u User) error { +func (c *Container) AddUser(ctx context.Context, u User) error { target, err := c.GRPCEndpoint(ctx) if err != nil { return err @@ -117,7 +117,7 @@ func (c *DexContainer) AddUser(ctx context.Context, u User) error { // RemoveUser deletes a user by email. // // Not safe for concurrent use. -func (c *DexContainer) RemoveUser(ctx context.Context, email string) error { +func (c *Container) RemoveUser(ctx context.Context, email string) error { target, err := c.GRPCEndpoint(ctx) if err != nil { return err From fcdb2cea3eb0961e05544c2e692e39815fb5ff5a Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:06:20 -0300 Subject: [PATCH 09/21] docs(testcontainers-go/dex): use main placeholder for Since version refs (PR #3659) Reviewer @mdelapenya flagged hardcoded v0.42.0 ("applies to more places below"): the contributing guide requires the "main" placeholder so release automation rewrites it at release time (https://golang.testcontainers.org/contributing/#adding-functional-options). Replaces 20 refs in docs/modules/dex.md and 1 in README.md, dropping the /releases/tag/v0.42.0 href suffix and swapping ":material-tag: v0.42.0" for ":material-tag: main". --- docs/modules/dex.md | 40 ++++++++++++++++++++-------------------- modules/dex/README.md | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index e1d065f15a..17e9ed6168 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -1,6 +1,6 @@ # Dex -Since :material-tag: v0.42.0 +Since :material-tag: main ## Introduction @@ -26,7 +26,7 @@ go get github.com/testcontainers/testcontainers-go/modules/dex ### Run function -- Since :material-tag: v0.42.0 +- Since :material-tag: main The Dex module exposes one entrypoint function to create the Dex container, and this function receives three parameters: @@ -59,7 +59,7 @@ configure it. #### Clients -- Since :material-tag: v0.42.0 +- Since :material-tag: main `Client` is an opaque value type. Construct it with `NewClient(id, opts...)` so invalid values surface at call-site. `NewClient` returns @@ -86,7 +86,7 @@ declared pre-start via `WithClient`. #### Users -- Since :material-tag: v0.42.0 +- Since :material-tag: main `User` is an opaque value type. `NewUser(email, username, password, opts...)` returns `(User, error)`. Use `WithUserID(id)` to pin the stable `sub` claim; @@ -106,7 +106,7 @@ other connectors. #### Connectors -- Since :material-tag: v0.42.0 +- Since :material-tag: main `WithConnector(type, id, name)` enables a Dex connector. Supported types: @@ -119,7 +119,7 @@ Blank `id` or `name` values are rejected at `Run` time. #### Issuer -- Since :material-tag: v0.42.0 +- Since :material-tag: main By default the issuer is derived from the host and mapped HTTP port: `http://:/dex`. `WithIssuer(url)` overrides the default. @@ -128,7 +128,7 @@ Docker network with a network alias); the caller owns reachability. #### Storage -- Since :material-tag: v0.42.0 +- Since :material-tag: main `WithStorage(Storage)` selects Dex's storage backend. Available constants: @@ -138,7 +138,7 @@ Docker network with a network alias); the caller owns reachability. #### Logging -- Since :material-tag: v0.42.0 +- Since :material-tag: main `WithLogger(*slog.Logger)` routes Dex container logs through a `slog.Logger`. Unset by default — container logs are discarded. Stderr lines are promoted @@ -149,7 +149,7 @@ between slog's fixed levels round down. Default: `slog.LevelInfo`. #### Client credentials grant -- Since :material-tag: v0.42.0 +- Since :material-tag: main `WithEnableClientCredentials()` sets the env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`, which Dex ≥ v2.46.0 @@ -158,7 +158,7 @@ compatible image (see the `Image` warning above). #### Approval screen -- Since :material-tag: v0.42.0 +- Since :material-tag: main `WithSkipApprovalScreen(bool)` toggles Dex's `oauth2.skipApprovalScreen`. Default: `true` (tests rarely want a human-in-the-loop prompt). @@ -171,37 +171,37 @@ The Dex container exposes the following methods: #### IssuerURL -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns Dex's issuer URL. Empty if `Run` has not started. #### ConfigEndpoint -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns the OIDC discovery document URL (`/.well-known/openid-configuration`). #### JWKSEndpoint -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns the JSON Web Key Set URL (`/keys`). #### TokenEndpoint -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns the OAuth2 token URL (`/token`). #### AuthEndpoint -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns the OAuth2 authorization URL (`/auth`). #### GRPCEndpoint -- Since :material-tag: v0.42.0 +- Since :material-tag: main Returns `host:mappedPort` for Dex's gRPC admin API. Takes a `context.Context` and returns an error if the container is not started or the Docker API @@ -213,7 +213,7 @@ target, err := c.GRPCEndpoint(ctx) #### AddClient -- Since :material-tag: v0.42.0 +- Since :material-tag: main Registers a client at runtime via Dex's gRPC admin API. Returns `ErrClientExists` when the ID is already registered. Not safe for @@ -221,14 +221,14 @@ concurrent use. #### RemoveClient -- Since :material-tag: v0.42.0 +- Since :material-tag: main Deletes a client by ID. Returns a plain error containing `"not found"` for unknown IDs. Not safe for concurrent use. #### AddUser -- Since :material-tag: v0.42.0 +- Since :material-tag: main Registers a user in Dex's password DB at runtime via gRPC. Returns `ErrUserExists` when the email is already registered. Not safe for @@ -236,7 +236,7 @@ concurrent use. #### RemoveUser -- Since :material-tag: v0.42.0 +- Since :material-tag: main Deletes a user by email. Returns a plain error containing `"not found"` for unknown emails. Not safe for concurrent use. diff --git a/modules/dex/README.md b/modules/dex/README.md index 46efef942a..53fc149d91 100644 --- a/modules/dex/README.md +++ b/modules/dex/README.md @@ -1,6 +1,6 @@ # Dex -Since :material-tag: v0.42.0 +Since :material-tag: main ## Introduction From dd3289f2b1fb25ef40511e6dc6e5773a120379f7 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:06:42 -0300 Subject: [PATCH 10/21] docs(testcontainers-go/dex): rename codeinclude block to runContainer per module convention (PR #3659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module guide: "Testable examples use blocks marked with inside_block:runContainer for automatic documentation inclusion via codeinclude" (https://golang.testcontainers.org/modules/). The label in examples_test.go and the corresponding inside_block reference in dex.md must match — renaming both. --- docs/modules/dex.md | 2 +- modules/dex/examples_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index 17e9ed6168..3dc0cb07d4 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -19,7 +19,7 @@ go get github.com/testcontainers/testcontainers-go/modules/dex ## Usage example -[Creating a Dex container](../../modules/dex/examples_test.go) inside_block:runDexContainer +[Creating a Dex container](../../modules/dex/examples_test.go) inside_block:runContainer ## Module Reference diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go index d355068075..1cc72ff820 100644 --- a/modules/dex/examples_test.go +++ b/modules/dex/examples_test.go @@ -11,7 +11,7 @@ import ( ) func ExampleRun_authorizationCode() { - // runDexContainer { + // runContainer { ctx := context.Background() app, err := dex.NewClient("my-app", From 6fa693f015c4fcb0eedbac80d2d8104096da5bab Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:07:04 -0300 Subject: [PATCH 11/21] docs(testcontainers-go/dex): list full signatures for all endpoint getters in README (PR #3659 nit) Round-1 review (CodeRabbit nit, README.md:142-145): the prior list named five getters bare and showed GRPCEndpoint with prose about its signature, which the reviewer flagged as inconsistent. Switching to a uniform bullet list with full signatures keeps godoc and README aligned and is clearer than the mixed-style note. --- modules/dex/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/dex/README.md b/modules/dex/README.md index 53fc149d91..e2c67a701e 100644 --- a/modules/dex/README.md +++ b/modules/dex/README.md @@ -139,10 +139,13 @@ when a human-readable identifier is needed. ### Endpoint getters -`IssuerURL`, `ConfigEndpoint`, `JWKSEndpoint`, `TokenEndpoint`, -`AuthEndpoint`, `GRPCEndpoint`. See godoc for signatures — `GRPCEndpoint` -takes `context.Context` and may return an error; the others are -string-only getters. +- `IssuerURL() string` +- `ConfigEndpoint() string` +- `JWKSEndpoint() string` +- `TokenEndpoint() string` +- `AuthEndpoint() string` +- `GRPCEndpoint(ctx context.Context) (string, error)` — only getter that + may return an error (Docker host/port lookup). ### Runtime mutation (gRPC) From 1eefbf514b7a335222447d18a816ad1649acf6d1 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:07:53 -0300 Subject: [PATCH 12/21] fix(testcontainers-go/dex): unescape backslash sequences in logfmt quoted values (PR #3659 nit) Round-1 review (CodeRabbit nit, log.go:97-109): the quoted-value branch of tokenizeLogfmt advanced past escape sequences but appended the raw substring, so a Dex `msg=` field containing \" or \\ surfaced with the literal backslash in the resulting slog attr. Added a package-level strings.NewReplacer that expands the two escapes logfmt allows and a regression test covering both. --- modules/dex/log.go | 8 ++++++-- modules/dex/log_test.go | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/dex/log.go b/modules/dex/log.go index beb260d039..94dabf16e0 100644 --- a/modules/dex/log.go +++ b/modules/dex/log.go @@ -45,9 +45,13 @@ func (s *slogConsumer) accept(content, logType string) { s.logger.LogAttrs(context.Background(), level, msg, attrs...) } +// logfmtUnescaper unescapes the two backslash sequences logfmt allows +// inside quoted values: \" → " and \\ → \. +var logfmtUnescaper = strings.NewReplacer(`\\`, `\`, `\"`, `"`) + // parseLogfmt is a minimal logfmt parser — enough for Dex's default // format (level=... msg=...). Unknown keys become slog attrs. Quoted -// values are unquoted. +// values are unquoted and have their \" / \\ escapes expanded. func parseLogfmt(line string) (slog.Level, string, []slog.Attr) { level := slog.LevelInfo msg := "" @@ -103,7 +107,7 @@ func tokenizeLogfmt(line string) []kv { } i++ } - out = append(out, kv{k, line[vStart:i]}) + out = append(out, kv{k, logfmtUnescaper.Replace(line[vStart:i])}) if i < len(line) { i++ } diff --git a/modules/dex/log_test.go b/modules/dex/log_test.go index e77b1ceef5..d1b9d8ac98 100644 --- a/modules/dex/log_test.go +++ b/modules/dex/log_test.go @@ -62,6 +62,13 @@ func TestParseLogfmt_QuotedValue(t *testing.T) { assert.Equal(t, "something went wrong: boom", msg) } +func TestParseLogfmt_QuotedValueUnescapes(t *testing.T) { + // Dex msg fields with embedded quotes / backslashes must round-trip + // without raw \\ or \" sequences leaking into slog attrs. + _, msg, _ := parseLogfmt(`level=info msg="he said \"hi\" then C:\\path"`) + assert.Equal(t, `he said "hi" then C:\path`, msg) +} + func TestMapLevel(t *testing.T) { cases := map[string]slog.Level{ "debug": slog.LevelDebug, From c01b2df5b92bcb0c5c5b0cd454b7f920cd8f3676 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:08:53 -0300 Subject: [PATCH 13/21] fix(testcontainers-go/dex): validate WithClientGrantTypes against allowlist (PR #3659 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review (CodeRabbit nit, types.go:86-96): WithClientGrantTypes only rejected blanks, so typos like "authorisation_code" passed construction and surfaced much later — either when Dex parsed the rendered YAML or, worse, as unsupported_grant_type at the token endpoint. The constructor's stated goal is to surface invalid configuration at call-site, so add an allowlist matching the docstring. The allowlist mirrors Dex's four supported grants (authorization_code, refresh_token, client_credentials, password) and is kept as a package-level set so the docstring and check stay in sync. Table-driven test covers the supported set plus typo / unknown / mixed cases. --- modules/dex/options_test.go | 25 +++++++++++++++++++++++++ modules/dex/types.go | 17 ++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go index cc9e0e0839..f0c94fb0d2 100644 --- a/modules/dex/options_test.go +++ b/modules/dex/options_test.go @@ -100,3 +100,28 @@ func TestOptions_WithUser_RejectsZeroValue(t *testing.T) { assert.Error(t, WithUser(User{})(&o), "zero User must be rejected — force NewUser") assert.Empty(t, o.users) } + +func TestNewClient_WithClientGrantTypes_Allowlist(t *testing.T) { + cases := []struct { + name string + grants []string + wantErr bool + }{ + {"all four supported", []string{"authorization_code", "refresh_token", "client_credentials", "password"}, false}, + {"single supported", []string{"authorization_code"}, false}, + {"blank rejected", []string{""}, true}, + {"typo rejected", []string{"authorisation_code"}, true}, + {"unknown grant rejected", []string{"jwt-bearer"}, true}, + {"valid then invalid rejected", []string{"password", "jwt-bearer"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewClient("c", WithClientSecret("s"), WithClientGrantTypes(tc.grants...)) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/modules/dex/types.go b/modules/dex/types.go index 2efb67f265..e7bcade7a1 100644 --- a/modules/dex/types.go +++ b/modules/dex/types.go @@ -1,6 +1,18 @@ package dex -import "errors" +import ( + "errors" + "fmt" +) + +// validClientGrantTypes is the set of OAuth2 grant types Dex understands. +// Kept in sync with WithClientGrantTypes' godoc. +var validClientGrantTypes = map[string]struct{}{ + "authorization_code": {}, + "refresh_token": {}, + "client_credentials": {}, + "password": {}, +} // Client is a static OAuth2 client registered with Dex at boot time via // WithClient. Construct with NewClient so invalid configuration surfaces at @@ -89,6 +101,9 @@ func WithClientGrantTypes(grants ...string) ClientOption { if g == "" { return errors.New("dex: client grant type must not be blank") } + if _, ok := validClientGrantTypes[g]; !ok { + return fmt.Errorf("dex: unsupported client grant type %q", g) + } } c.grantTypes = append(c.grantTypes, grants...) return nil From 5986f3f8510c4812a1af380f5d0da3d62331e4d7 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:09:11 -0300 Subject: [PATCH 14/21] fix(testcontainers-go/dex): wrap ErrClientExists/ErrUserExists with id/email for symmetry (PR #3659 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review (CodeRabbit nit, grpc.go:43-45/111-113): RemoveClient and RemoveUser already return fmt.Errorf("%w: %q", ErrClientNotFound, id) (and the user variant), but AddClient and AddUser returned the bare sentinel — callers had no identifier in the error text. Mirrors the wrapping pattern. errors.Is checks against the bare sentinels (assert.ErrorIs in dex_test.go) keep working because %w preserves the chain. --- modules/dex/grpc.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index abf4f341e6..1d8636ab8d 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -41,7 +41,7 @@ func (c *Container) AddClient(ctx context.Context, cl Client) error { return fmt.Errorf("dex: create client: %w", err) } if resp.AlreadyExists { - return ErrClientExists + return fmt.Errorf("%w: %q", ErrClientExists, cl.id) } return nil } @@ -109,7 +109,7 @@ func (c *Container) AddUser(ctx context.Context, u User) error { return fmt.Errorf("dex: create password: %w", err) } if resp.AlreadyExists { - return ErrUserExists + return fmt.Errorf("%w: %q", ErrUserExists, u.email) } return nil } From f08c2e9c76813602c147ab4d754ddf00e57fd9cf Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:09:48 -0300 Subject: [PATCH 15/21] chore(testcontainers-go/dex): wrap multi-redirect auth-code test in t.Run subtests (PR #3659 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review (CodeRabbit nit, dex_test.go:315-344): TestAuthCode_MultipleRedirectURIs ran two URIs in a bare for loop, so a failure on the second short-circuited the first's reporting and -run couldn't target a single URI. Wrap each iteration in t.Run(uri, …). Drop the "uri=%s" annotation on assert.NotEmpty — the subtest name now identifies the iteration. Go ≥ 1.22 fixed loop-variable capture, so no local-shadow needed. --- modules/dex/dex_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index ec59509e38..96c77ee5db 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -331,15 +331,17 @@ func TestAuthCode_MultipleRedirectURIs(t *testing.T) { t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) for _, uri := range uris { - cfg := oauth2.Config{ - ClientID: "e2e", - ClientSecret: "s", - RedirectURL: uri, - Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, - Scopes: []string{"openid"}, - } - tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") - assert.NotEmpty(t, tok.AccessToken, "uri=%s", uri) + t.Run(uri, func(t *testing.T) { + cfg := oauth2.Config{ + ClientID: "e2e", + ClientSecret: "s", + RedirectURL: uri, + Endpoint: oauth2.Endpoint{AuthURL: c.AuthEndpoint(), TokenURL: c.TokenEndpoint()}, + Scopes: []string{"openid"}, + } + tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") + assert.NotEmpty(t, tok.AccessToken) + }) } } From dc57f25b18cf788783971396c09f2bf81ec27dfe Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 24 Apr 2026 14:10:12 -0300 Subject: [PATCH 16/21] chore(testcontainers-go/dex): drop dead loc default; assert 3xx in drivePasswordAuthCode (PR #3659 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review (CodeRabbit nit, dex_test.go:499-506): the helper assigned loc := resp.Request.URL as a fallback, but CheckRedirect returns http.ErrUseLastResponse so resp is always a 3xx on the success path — the assignment never applied. The flow read like it handled both redirect and non-redirect outcomes when in practice the non-redirect branch always failed at require.NotEmpty(code, ...). Replace with explicit 3xx require + direct Location parse so the control flow matches the actual contract. --- modules/dex/dex_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index 96c77ee5db..46743f593a 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -498,12 +498,13 @@ func TestMockConnector_IssuesToken(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - loc := resp.Request.URL - if resp.StatusCode >= 300 && resp.StatusCode < 400 { - parsed, perr := url.Parse(resp.Header.Get("Location")) - require.NoError(t, perr) - loc = parsed - } + // CheckRedirect returns http.ErrUseLastResponse on the redirect to + // cfg.RedirectURL, so resp is always a 3xx with the auth code in + // its Location header on the success path. + require.GreaterOrEqual(t, resp.StatusCode, 300, "expected redirect to %s, got status %d", cfg.RedirectURL, resp.StatusCode) + require.Less(t, resp.StatusCode, 400, "expected redirect to %s, got status %d", cfg.RedirectURL, resp.StatusCode) + loc, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) code := loc.Query().Get("code") require.NotEmpty(t, code, "mockCallback should redirect with ?code=...; got %q", loc.String()) From 84b4d19e110416e32bb143335d3668fd682a1d61 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Mon, 27 Apr 2026 13:50:09 -0300 Subject: [PATCH 17/21] fix(dex): satisfy module lint --- modules/dex/config.go | 3 ++- modules/dex/config_test.go | 2 +- modules/dex/dex.go | 3 ++- modules/dex/dex_test.go | 13 +++++++------ modules/dex/dextest_helpers_test.go | 2 +- modules/dex/examples_test.go | 9 +++++---- modules/dex/grpc.go | 3 ++- modules/dex/options_test.go | 12 ++++++------ 8 files changed, 26 insertions(+), 21 deletions(-) diff --git a/modules/dex/config.go b/modules/dex/config.go index 99f057da73..1cf839a88e 100644 --- a/modules/dex/config.go +++ b/modules/dex/config.go @@ -2,6 +2,7 @@ package dex import ( "crypto/rand" + "errors" "fmt" "log/slog" @@ -92,7 +93,7 @@ var baseGrantTypes = []string{ // issuer has been populated. func render(o options) ([]byte, error) { if o.issuer == "" { - return nil, fmt.Errorf("dex: issuer is empty (internal bug — Run should populate before render)") + return nil, errors.New("dex: issuer is empty (internal bug — Run should populate before render)") } if !o.enablePasswordDB && len(o.connectors) == 0 { diff --git a/modules/dex/config_test.go b/modules/dex/config_test.go index 863bae5996..5473700680 100644 --- a/modules/dex/config_test.go +++ b/modules/dex/config_test.go @@ -104,7 +104,7 @@ func TestRender_WithUsers_BcryptShape(t *testing.T) { p := got.StaticPasswords[0] assert.Equal(t, "u@e.com", p.Email) assert.True(t, strings.HasPrefix(p.Hash, "$2a$") || strings.HasPrefix(p.Hash, "$2b$"), "bcrypt prefix") - assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(p.Hash), []byte("p"))) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(p.Hash), []byte("p"))) assert.NotEmpty(t, p.UserID, "userID should be auto-populated") } diff --git a/modules/dex/dex.go b/modules/dex/dex.go index 0db2ccef9f..3ea8ac614c 100644 --- a/modules/dex/dex.go +++ b/modules/dex/dex.go @@ -26,6 +26,7 @@ package dex import ( "context" + "errors" "fmt" "net/url" "strings" @@ -161,7 +162,7 @@ func (c *Container) AuthEndpoint() string { return c.issuer + "/auth" } // started. func (c *Container) GRPCEndpoint(ctx context.Context) (string, error) { if c.Container == nil { - return "", fmt.Errorf("dex: container not started") + return "", errors.New("dex: container not started") } host, err := c.Host(ctx) if err != nil { diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index 46743f593a..9111521e4d 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -19,12 +19,13 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/dex" "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" - "golang.org/x/oauth2" - "golang.org/x/oauth2/clientcredentials" ) const ( @@ -144,7 +145,7 @@ func TestGRPC_AddRemoveClient(t *testing.T) { // Idempotency: second Add returns ErrClientExists. err = c.AddClient(ctx, cl) - assert.ErrorIs(t, err, dex.ErrClientExists) + require.ErrorIs(t, err, dex.ErrClientExists) // Removal succeeds. require.NoError(t, c.RemoveClient(ctx, cl.ID())) @@ -167,7 +168,7 @@ func TestGRPC_AddRemoveUser(t *testing.T) { // Duplicate add errors. err = c.AddUser(ctx, u) - assert.ErrorIs(t, err, dex.ErrUserExists) + require.ErrorIs(t, err, dex.ErrUserExists) // Removal succeeds. require.NoError(t, c.RemoveUser(ctx, u.Email())) @@ -487,7 +488,7 @@ func TestMockConnector_IssuesToken(t *testing.T) { require.NoError(t, err) client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { + CheckRedirect: func(req *http.Request, _ []*http.Request) error { if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { return http.ErrUseLastResponse } @@ -547,7 +548,7 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { require.NoError(t, err) client := &http.Client{ Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { + CheckRedirect: func(req *http.Request, _ []*http.Request) error { if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { return http.ErrUseLastResponse } diff --git a/modules/dex/dextest_helpers_test.go b/modules/dex/dextest_helpers_test.go index 324528f1fd..5f15d6dcbc 100644 --- a/modules/dex/dextest_helpers_test.go +++ b/modules/dex/dextest_helpers_test.go @@ -29,7 +29,7 @@ func drivePasswordAuthCode(t *testing.T, ctx context.Context, cfg oauth2.Config, client := &http.Client{ Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { + CheckRedirect: func(req *http.Request, _ []*http.Request) error { // Stop at the redirect back to our registered redirect URI — // we parse the code from that URL. if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) { diff --git a/modules/dex/examples_test.go b/modules/dex/examples_test.go index 1cc72ff820..307b370a82 100644 --- a/modules/dex/examples_test.go +++ b/modules/dex/examples_test.go @@ -5,9 +5,10 @@ import ( "fmt" "log" + "golang.org/x/oauth2" + "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/dex" - "golang.org/x/oauth2" ) func ExampleRun_authorizationCode() { @@ -32,10 +33,10 @@ func ExampleRun_authorizationCode() { dex.WithClient(app), dex.WithUser(user), ) - defer testcontainers.TerminateContainer(c) if err != nil { log.Fatalf("run: %v", err) } + defer func() { _ = testcontainers.TerminateContainer(c) }() // } _ = oauth2.Config{ @@ -72,10 +73,10 @@ func ExampleRun_passwordGrant() { dex.WithClient(svc), dex.WithUser(user), ) - defer testcontainers.TerminateContainer(c) if err != nil { log.Fatalf("run: %v", err) } + defer func() { _ = testcontainers.TerminateContainer(c) }() cfg := oauth2.Config{ ClientID: "svc", ClientSecret: "s", @@ -84,7 +85,7 @@ func ExampleRun_passwordGrant() { } tok, err := cfg.PasswordCredentialsToken(ctx, "svc@svc.local", "svc-secret") if err != nil { - log.Fatalf("token: %v", err) + panic(fmt.Errorf("token: %w", err)) } fmt.Println("has access token:", tok.AccessToken != "") // Output: has access token: true diff --git a/modules/dex/grpc.go b/modules/dex/grpc.go index 1d8636ab8d..e4b7c26439 100644 --- a/modules/dex/grpc.go +++ b/modules/dex/grpc.go @@ -2,6 +2,7 @@ package dex import ( "context" + "errors" "fmt" "github.com/dexidp/dex/api/v2" @@ -140,7 +141,7 @@ func (c *Container) RemoveUser(ctx context.Context, email string) error { func dial(target string) (*grpc.ClientConn, error) { if target == "" { - return nil, fmt.Errorf("dex: grpc endpoint empty (container not started)") + return nil, errors.New("dex: grpc endpoint empty (container not started)") } conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()), diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go index f0c94fb0d2..0ac92ff879 100644 --- a/modules/dex/options_test.go +++ b/modules/dex/options_test.go @@ -69,35 +69,35 @@ func TestOptions_WithConnector_RejectsBlankFields(t *testing.T) { o := defaultOptions() err := WithConnector(ConnectorMock, "", "Mock")(&o) - assert.Error(t, err, "blank id must be rejected") + require.Error(t, err, "blank id must be rejected") err = WithConnector(ConnectorMock, "id", "")(&o) - assert.Error(t, err, "blank name must be rejected") + require.Error(t, err, "blank name must be rejected") assert.Empty(t, o.connectors, "rejected options must not mutate state") } func TestOptions_WithIssuer_RejectsBlank(t *testing.T) { o := defaultOptions() - assert.Error(t, WithIssuer("")(&o)) + require.Error(t, WithIssuer("")(&o)) assert.Empty(t, o.issuer) } func TestOptions_WithStorage_RejectsBlank(t *testing.T) { o := defaultOptions() - assert.Error(t, WithStorage("")(&o)) + require.Error(t, WithStorage("")(&o)) assert.Equal(t, StorageSQLite, o.storage, "failed option must leave default intact") } func TestOptions_WithClient_RejectsZeroValue(t *testing.T) { o := defaultOptions() - assert.Error(t, WithClient(Client{})(&o), "zero Client must be rejected — force NewClient") + require.Error(t, WithClient(Client{})(&o), "zero Client must be rejected — force NewClient") assert.Empty(t, o.clients) } func TestOptions_WithUser_RejectsZeroValue(t *testing.T) { o := defaultOptions() - assert.Error(t, WithUser(User{})(&o), "zero User must be rejected — force NewUser") + require.Error(t, WithUser(User{})(&o), "zero User must be rejected — force NewUser") assert.Empty(t, o.users) } From 89064aa2aacfb4a79d0f11e01f86be5fcb01d4e7 Mon Sep 17 00:00:00 2001 From: Guilherme de Castro Date: Fri, 8 May 2026 11:08:24 -0300 Subject: [PATCH 18/21] Update docs/modules/dex.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Manuel de la Peña --- docs/modules/dex.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index 3dc0cb07d4..ef74fea839 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -26,7 +26,7 @@ go get github.com/testcontainers/testcontainers-go/modules/dex ### Run function -- Since :material-tag: main +- Not available until the next release :material-tag: main The Dex module exposes one entrypoint function to create the Dex container, and this function receives three parameters: From 081be8a21b2e145782b39f8e05f68de52a8fa74c Mon Sep 17 00:00:00 2001 From: Guilherme de Castro Date: Fri, 8 May 2026 11:08:40 -0300 Subject: [PATCH 19/21] Update docs/modules/dex.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Manuel de la Peña --- docs/modules/dex.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index ef74fea839..9619db818c 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -1,6 +1,6 @@ # Dex -Since :material-tag: main +Not available until the next release :material-tag: main ## Introduction From 16b68461ba40a2dd28cbbeeba7774902111aa3b4 Mon Sep 17 00:00:00 2001 From: Guilherme Castro Date: Fri, 8 May 2026 17:29:25 -0300 Subject: [PATCH 20/21] docs(dex): use next-release placeholders --- docs/modules/dex.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/modules/dex.md b/docs/modules/dex.md index 9619db818c..6dade1a657 100644 --- a/docs/modules/dex.md +++ b/docs/modules/dex.md @@ -59,7 +59,7 @@ configure it. #### Clients -- Since :material-tag: main +- Not available until the next release :material-tag: main `Client` is an opaque value type. Construct it with `NewClient(id, opts...)` so invalid values surface at call-site. `NewClient` returns @@ -86,7 +86,7 @@ declared pre-start via `WithClient`. #### Users -- Since :material-tag: main +- Not available until the next release :material-tag: main `User` is an opaque value type. `NewUser(email, username, password, opts...)` returns `(User, error)`. Use `WithUserID(id)` to pin the stable `sub` claim; @@ -106,7 +106,7 @@ other connectors. #### Connectors -- Since :material-tag: main +- Not available until the next release :material-tag: main `WithConnector(type, id, name)` enables a Dex connector. Supported types: @@ -119,7 +119,7 @@ Blank `id` or `name` values are rejected at `Run` time. #### Issuer -- Since :material-tag: main +- Not available until the next release :material-tag: main By default the issuer is derived from the host and mapped HTTP port: `http://:/dex`. `WithIssuer(url)` overrides the default. @@ -128,7 +128,7 @@ Docker network with a network alias); the caller owns reachability. #### Storage -- Since :material-tag: main +- Not available until the next release :material-tag: main `WithStorage(Storage)` selects Dex's storage backend. Available constants: @@ -138,7 +138,7 @@ Docker network with a network alias); the caller owns reachability. #### Logging -- Since :material-tag: main +- Not available until the next release :material-tag: main `WithLogger(*slog.Logger)` routes Dex container logs through a `slog.Logger`. Unset by default — container logs are discarded. Stderr lines are promoted @@ -149,7 +149,7 @@ between slog's fixed levels round down. Default: `slog.LevelInfo`. #### Client credentials grant -- Since :material-tag: main +- Not available until the next release :material-tag: main `WithEnableClientCredentials()` sets the env var `DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true`, which Dex ≥ v2.46.0 @@ -158,7 +158,7 @@ compatible image (see the `Image` warning above). #### Approval screen -- Since :material-tag: main +- Not available until the next release :material-tag: main `WithSkipApprovalScreen(bool)` toggles Dex's `oauth2.skipApprovalScreen`. Default: `true` (tests rarely want a human-in-the-loop prompt). @@ -171,37 +171,37 @@ The Dex container exposes the following methods: #### IssuerURL -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns Dex's issuer URL. Empty if `Run` has not started. #### ConfigEndpoint -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns the OIDC discovery document URL (`/.well-known/openid-configuration`). #### JWKSEndpoint -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns the JSON Web Key Set URL (`/keys`). #### TokenEndpoint -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns the OAuth2 token URL (`/token`). #### AuthEndpoint -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns the OAuth2 authorization URL (`/auth`). #### GRPCEndpoint -- Since :material-tag: main +- Not available until the next release :material-tag: main Returns `host:mappedPort` for Dex's gRPC admin API. Takes a `context.Context` and returns an error if the container is not started or the Docker API @@ -213,7 +213,7 @@ target, err := c.GRPCEndpoint(ctx) #### AddClient -- Since :material-tag: main +- Not available until the next release :material-tag: main Registers a client at runtime via Dex's gRPC admin API. Returns `ErrClientExists` when the ID is already registered. Not safe for @@ -221,14 +221,14 @@ concurrent use. #### RemoveClient -- Since :material-tag: main +- Not available until the next release :material-tag: main Deletes a client by ID. Returns a plain error containing `"not found"` for unknown IDs. Not safe for concurrent use. #### AddUser -- Since :material-tag: main +- Not available until the next release :material-tag: main Registers a user in Dex's password DB at runtime via gRPC. Returns `ErrUserExists` when the email is already registered. Not safe for @@ -236,7 +236,7 @@ concurrent use. #### RemoveUser -- Since :material-tag: main +- Not available until the next release :material-tag: main Deletes a user by email. Returns a plain error containing `"not found"` for unknown emails. Not safe for concurrent use. From 95679fcfaf146cc52209036f2e6a38483f559e92 Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Mon, 11 May 2026 07:33:27 +0200 Subject: [PATCH 21/21] chore(tests): use require and testcontainersCleanupContainer --- modules/dex/config_test.go | 45 +++++++++-------- modules/dex/dex_test.go | 99 ++++++++++++++++++------------------- modules/dex/log_test.go | 27 +++++----- modules/dex/options_test.go | 55 ++++++++++----------- 4 files changed, 111 insertions(+), 115 deletions(-) diff --git a/modules/dex/config_test.go b/modules/dex/config_test.go index 5473700680..5c4f898cde 100644 --- a/modules/dex/config_test.go +++ b/modules/dex/config_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" @@ -35,16 +34,16 @@ func TestRender_MinimalDefaults(t *testing.T) { var got map[string]any require.NoError(t, yaml.Unmarshal(out, &got)) - assert.Equal(t, "http://localhost:5556/dex", got["issuer"]) - assert.Equal(t, true, got["enablePasswordDB"]) + require.Equal(t, "http://localhost:5556/dex", got["issuer"]) + require.Equal(t, true, got["enablePasswordDB"]) storage := got["storage"].(map[string]any) - assert.Equal(t, "sqlite3", storage["type"]) + require.Equal(t, "sqlite3", storage["type"]) web := got["web"].(map[string]any) - assert.Equal(t, "0.0.0.0:5556", web["http"]) + require.Equal(t, "0.0.0.0:5556", web["http"]) grpc := got["grpc"].(map[string]any) - assert.Equal(t, "0.0.0.0:5557", grpc["addr"]) + require.Equal(t, "0.0.0.0:5557", grpc["addr"]) oauth2 := got["oauth2"].(map[string]any) - assert.Equal(t, true, oauth2["skipApprovalScreen"]) + require.Equal(t, true, oauth2["skipApprovalScreen"]) } func TestRender_WithClients(t *testing.T) { @@ -78,9 +77,9 @@ func TestRender_WithClients(t *testing.T) { } require.NoError(t, yaml.Unmarshal(out, &got)) require.Len(t, got.StaticClients, 2) - assert.Equal(t, "app1", got.StaticClients[0].ID) - assert.Equal(t, []string{"http://a/cb", "http://b/cb"}, got.StaticClients[0].RedirectURIs) - assert.Equal(t, []string{"client_credentials"}, got.StaticClients[1].GrantTypes) + require.Equal(t, "app1", got.StaticClients[0].ID) + require.Equal(t, []string{"http://a/cb", "http://b/cb"}, got.StaticClients[0].RedirectURIs) + require.Equal(t, []string{"client_credentials"}, got.StaticClients[1].GrantTypes) } func TestRender_WithUsers_BcryptShape(t *testing.T) { @@ -102,10 +101,10 @@ func TestRender_WithUsers_BcryptShape(t *testing.T) { require.NoError(t, yaml.Unmarshal(out, &got)) require.Len(t, got.StaticPasswords, 1) p := got.StaticPasswords[0] - assert.Equal(t, "u@e.com", p.Email) - assert.True(t, strings.HasPrefix(p.Hash, "$2a$") || strings.HasPrefix(p.Hash, "$2b$"), "bcrypt prefix") + require.Equal(t, "u@e.com", p.Email) + require.True(t, strings.HasPrefix(p.Hash, "$2a$") || strings.HasPrefix(p.Hash, "$2b$"), "bcrypt prefix") require.NoError(t, bcrypt.CompareHashAndPassword([]byte(p.Hash), []byte("p"))) - assert.NotEmpty(t, p.UserID, "userID should be auto-populated") + require.NotEmpty(t, p.UserID, "userID should be auto-populated") } func TestRender_WithConnectors(t *testing.T) { @@ -125,7 +124,7 @@ func TestRender_WithConnectors(t *testing.T) { } require.NoError(t, yaml.Unmarshal(out, &got)) require.Len(t, got.Connectors, 1) - assert.Equal(t, "mockCallback", got.Connectors[0].Type) + require.Equal(t, "mockCallback", got.Connectors[0].Type) } func TestRender_NoAuthSource_Errors(t *testing.T) { @@ -135,14 +134,14 @@ func TestRender_NoAuthSource_Errors(t *testing.T) { // no connectors _, err := render(o) - assert.ErrorIs(t, err, ErrNoAuthSource) + require.ErrorIs(t, err, ErrNoAuthSource) } func TestRender_IssuerRequired(t *testing.T) { o := defaultOptions() // issuer empty _, err := render(o) - assert.Error(t, err, "render must error when issuer is empty") + require.Error(t, err, "render must error when issuer is empty") } func TestRender_BcryptCost(t *testing.T) { @@ -163,7 +162,7 @@ func TestRender_BcryptCost(t *testing.T) { require.NoError(t, err) // Dex v2.45+ enforces a minimum bcrypt cost of 10; we use exactly 10 to // satisfy that constraint while staying well below the production default (14). - assert.Equal(t, 10, cost, "bcrypt cost must be exactly 10: meets Dex minimum, fast enough for tests") + require.Equal(t, 10, cost, "bcrypt cost must be exactly 10: meets Dex minimum, fast enough for tests") } func TestRender_UserWithExplicitUserID(t *testing.T) { @@ -181,7 +180,7 @@ func TestRender_UserWithExplicitUserID(t *testing.T) { } require.NoError(t, yaml.Unmarshal(out, &got)) require.Len(t, got.StaticPasswords, 1) - assert.Equal(t, "fixed-id-123", got.StaticPasswords[0].UserID) + require.Equal(t, "fixed-id-123", got.StaticPasswords[0].UserID) } func TestRender_PasswordConnector_SetWhenPasswordDBEnabled(t *testing.T) { @@ -198,7 +197,7 @@ func TestRender_PasswordConnector_SetWhenPasswordDBEnabled(t *testing.T) { } `yaml:"oauth2"` } require.NoError(t, yaml.Unmarshal(out, &got)) - assert.Equal(t, "local", got.OAuth2.PasswordConnector, + require.Equal(t, "local", got.OAuth2.PasswordConnector, "oauth2.passwordConnector must be 'local' when enablePasswordDB is true") } @@ -217,7 +216,7 @@ func TestRender_PasswordConnector_OmitWhenPasswordDBDisabled(t *testing.T) { } `yaml:"oauth2"` } require.NoError(t, yaml.Unmarshal(out, &got)) - assert.Empty(t, got.OAuth2.PasswordConnector, + require.Empty(t, got.OAuth2.PasswordConnector, "oauth2.passwordConnector must be omitted when enablePasswordDB is false") } @@ -241,8 +240,8 @@ func TestRender_YAMLInjection_NameField(t *testing.T) { } require.NoError(t, yaml.Unmarshal(out, &got)) require.Len(t, got.StaticClients, 1) - assert.Equal(t, malicious, got.StaticClients[0].Name, "injected characters must round-trip as data, not structure") - assert.Empty(t, got.Malicious, "structural injection must not create a top-level key") + require.Equal(t, malicious, got.StaticClients[0].Name, "injected characters must round-trip as data, not structure") + require.Empty(t, got.Malicious, "structural injection must not create a top-level key") } func TestDexLogLevel(t *testing.T) { @@ -256,6 +255,6 @@ func TestDexLogLevel(t *testing.T) { slog.LevelError: "error", } for in, want := range cases { - assert.Equal(t, want, dexLogLevel(in), "slog.Level=%v", in) + require.Equal(t, want, dexLogLevel(in), "slog.Level=%v", in) } } diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go index 9111521e4d..b1a4435199 100644 --- a/modules/dex/dex_test.go +++ b/modules/dex/dex_test.go @@ -17,7 +17,6 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" @@ -58,17 +57,17 @@ func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) - assert.NotEmpty(t, c.IssuerURL()) - assert.Equal(t, c.IssuerURL()+"/.well-known/openid-configuration", c.ConfigEndpoint()) - assert.Equal(t, c.IssuerURL()+"/keys", c.JWKSEndpoint()) - assert.Equal(t, c.IssuerURL()+"/token", c.TokenEndpoint()) - assert.Equal(t, c.IssuerURL()+"/auth", c.AuthEndpoint()) + require.NotEmpty(t, c.IssuerURL()) + require.Equal(t, c.IssuerURL()+"/.well-known/openid-configuration", c.ConfigEndpoint()) + require.Equal(t, c.IssuerURL()+"/keys", c.JWKSEndpoint()) + require.Equal(t, c.IssuerURL()+"/token", c.TokenEndpoint()) + require.Equal(t, c.IssuerURL()+"/auth", c.AuthEndpoint()) grpcEP, err := c.GRPCEndpoint(ctx) require.NoError(t, err) - assert.NotEmpty(t, grpcEP) + require.NotEmpty(t, grpcEP) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ConfigEndpoint(), nil) require.NoError(t, err) @@ -82,10 +81,10 @@ func TestRun_DefaultPath_DiscoveryMatches(t *testing.T) { require.NoError(t, err) require.NoError(t, json.Unmarshal(body, &doc)) - assert.Equal(t, c.IssuerURL(), doc["issuer"]) - assert.Equal(t, c.JWKSEndpoint(), doc["jwks_uri"]) - assert.Equal(t, c.TokenEndpoint(), doc["token_endpoint"]) - assert.Equal(t, c.AuthEndpoint(), doc["authorization_endpoint"]) + require.Equal(t, c.IssuerURL(), doc["issuer"]) + require.Equal(t, c.JWKSEndpoint(), doc["jwks_uri"]) + require.Equal(t, c.TokenEndpoint(), doc["token_endpoint"]) + require.Equal(t, c.AuthEndpoint(), doc["authorization_endpoint"]) } func TestRun_WithIssuerOverride(t *testing.T) { @@ -98,11 +97,11 @@ func TestRun_WithIssuerOverride(t *testing.T) { dex.WithIssuer(issuer), dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) - assert.Equal(t, issuer, c.IssuerURL()) - assert.Equal(t, issuer+"/.well-known/openid-configuration", c.ConfigEndpoint()) + require.Equal(t, issuer, c.IssuerURL()) + require.Equal(t, issuer+"/.well-known/openid-configuration", c.ConfigEndpoint()) // Cross-check: discovery doc MUST echo the overridden issuer, proving // the YAML was rendered with the override and Dex booted against it. @@ -123,7 +122,7 @@ func TestRun_WithIssuerOverride(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.NoError(t, json.Unmarshal(body, &doc)) - assert.Equal(t, issuer, doc["issuer"], "discovery doc must echo the overridden issuer") + require.Equal(t, issuer, doc["issuer"], "discovery doc must echo the overridden issuer") } func TestGRPC_AddRemoveClient(t *testing.T) { @@ -133,8 +132,8 @@ func TestGRPC_AddRemoveClient(t *testing.T) { c, err := dex.Run(ctx, dexImage, dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cl := mustClient(t, "runtime-app", dex.WithClientSecret("s"), @@ -152,7 +151,7 @@ func TestGRPC_AddRemoveClient(t *testing.T) { // Second remove errors (not-found). err = c.RemoveClient(ctx, cl.ID()) - assert.ErrorIs(t, err, dex.ErrClientNotFound) + require.ErrorIs(t, err, dex.ErrClientNotFound) } func TestGRPC_AddRemoveUser(t *testing.T) { @@ -160,8 +159,8 @@ func TestGRPC_AddRemoveUser(t *testing.T) { defer cancel() c, err := dex.Run(ctx, dexImage) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) u := mustUser(t, "runtime@example.com", "runtime", "p") require.NoError(t, c.AddUser(ctx, u)) @@ -175,7 +174,7 @@ func TestGRPC_AddRemoveUser(t *testing.T) { // Second removal errors. err = c.RemoveUser(ctx, u.Email()) - assert.ErrorIs(t, err, dex.ErrUserNotFound) + require.ErrorIs(t, err, dex.ErrUserNotFound) } func TestWithLogger_CapturesDexOutput(t *testing.T) { @@ -189,8 +188,8 @@ func TestWithLogger_CapturesDexOutput(t *testing.T) { dex.WithLogger(logger), dex.WithUser(mustUser(t, "u@e.com", "u", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) // Poll for the "listening on" line that Dex always emits at the end of // its startup sequence. This proves the log pipe is fully wired — not @@ -251,8 +250,8 @@ func TestAuthCode_PasswordConnector_Basic(t *testing.T) { )), dex.WithUser(mustUser(t, "alice@example.com", "alice", "pass")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cfg := oauth2.Config{ ClientID: "e2e-app", @@ -266,11 +265,11 @@ func TestAuthCode_PasswordConnector_Basic(t *testing.T) { } tok := drivePasswordAuthCode(t, ctx, cfg, "alice@example.com", "pass") - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) idToken, ok := tok.Extra("id_token").(string) require.True(t, ok, "id_token missing from response") - assert.NotEmpty(t, idToken) + require.NotEmpty(t, idToken) } func TestAuthCode_RefreshToken(t *testing.T) { @@ -288,8 +287,8 @@ func TestAuthCode_RefreshToken(t *testing.T) { )), dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cfg := oauth2.Config{ ClientID: "e2e", @@ -310,7 +309,7 @@ func TestAuthCode_RefreshToken(t *testing.T) { src := cfg.TokenSource(ctx, &expired) newTok, err := src.Token() require.NoError(t, err, "refresh exchange failed") - assert.NotEqual(t, tok.AccessToken, newTok.AccessToken, "refresh yields new access token") + require.NotEqual(t, tok.AccessToken, newTok.AccessToken, "refresh yields new access token") } func TestAuthCode_MultipleRedirectURIs(t *testing.T) { @@ -328,8 +327,8 @@ func TestAuthCode_MultipleRedirectURIs(t *testing.T) { )), dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) for _, uri := range uris { t.Run(uri, func(t *testing.T) { @@ -341,7 +340,7 @@ func TestAuthCode_MultipleRedirectURIs(t *testing.T) { Scopes: []string{"openid"}, } tok := drivePasswordAuthCode(t, ctx, cfg, "a@e.com", "p") - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) }) } } @@ -363,8 +362,8 @@ func TestClientCredentials_UnsupportedByLocalConnectors(t *testing.T) { dex.WithClientGrantTypes("client_credentials"), )), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cfg := clientcredentials.Config{ ClientID: "svc", @@ -379,7 +378,7 @@ func TestClientCredentials_UnsupportedByLocalConnectors(t *testing.T) { // message is "unsupported_grant_type" or similar. Match loosely so // minor Dex wording changes don't flake. msg := strings.ToLower(err.Error()) - assert.True(t, + require.True(t, strings.Contains(msg, "unsupported") || strings.Contains(msg, "grant"), "expected grant-related error, got: %v", err) } @@ -396,8 +395,8 @@ func TestPasswordGrant_ROPC(t *testing.T) { )), dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cfg := oauth2.Config{ ClientID: "cli", @@ -407,7 +406,7 @@ func TestPasswordGrant_ROPC(t *testing.T) { } tok, err := cfg.PasswordCredentialsToken(ctx, "a@e.com", "p") require.NoError(t, err) - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) } func TestMultipleClients_OneInstance(t *testing.T) { @@ -432,8 +431,8 @@ func TestMultipleClients_OneInstance(t *testing.T) { )), dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) // Service-account client via password grant. svcCfg := oauth2.Config{ @@ -454,9 +453,9 @@ func TestMultipleClients_OneInstance(t *testing.T) { Scopes: []string{"openid"}, } webTok := drivePasswordAuthCode(t, ctx, webCfg, "a@e.com", "p") - assert.NotEmpty(t, webTok.AccessToken) + require.NotEmpty(t, webTok.AccessToken) - assert.NotEqual(t, svcTok.AccessToken, webTok.AccessToken, "tokens from distinct clients must differ") + require.NotEqual(t, svcTok.AccessToken, webTok.AccessToken, "tokens from distinct clients must differ") } func TestMockConnector_IssuesToken(t *testing.T) { @@ -472,8 +471,8 @@ func TestMockConnector_IssuesToken(t *testing.T) { dex.WithClientGrantTypes("authorization_code", "refresh_token"), )), ) + testcontainers.CleanupContainer(t, c) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) cfg := oauth2.Config{ ClientID: "e2e", ClientSecret: "s", @@ -511,7 +510,7 @@ func TestMockConnector_IssuesToken(t *testing.T) { tok, err := cfg.Exchange(ctx, code) require.NoError(t, err) - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) } func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { @@ -520,7 +519,7 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { c, err := dex.Run(ctx, dexImage) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + testcontainers.CleanupContainer(t, c) // Seed client + user at runtime. require.NoError(t, c.AddClient(ctx, mustClient(t, "late-app", @@ -537,7 +536,7 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { Scopes: []string{"openid"}, } tok := drivePasswordAuthCode(t, ctx, cfg, "late@e.com", "p") - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) // Remove user — subsequent login attempt must fail. require.NoError(t, c.RemoveUser(ctx, "late@e.com")) @@ -580,7 +579,7 @@ func TestGRPC_RuntimeAddUsableEndToEnd(t *testing.T) { // copy ("Invalid Email Address and password") may drift across // versions; match loosely. lower := strings.ToLower(string(body2)) - assert.True(t, + require.True(t, strings.Contains(lower, "invalid") || strings.Contains(lower, "authentication failed") || r2.StatusCode >= 400, "removed user should fail login; got status=%d body-prefix=%.200q", r2.StatusCode, lower) } @@ -591,7 +590,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { net, err := network.New(ctx) require.NoError(t, err) - t.Cleanup(func() { _ = net.Remove(ctx) }) + testcontainers.CleanupNetwork(t, net) const issuer = "http://dex:5556/dex" @@ -601,7 +600,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { network.WithNetwork([]string{"dex"}, net), ) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + testcontainers.CleanupContainer(t, c) // Sidecar: curl the discovery endpoint through the network alias. sidecarReq := testcontainers.ContainerRequest{ @@ -619,7 +618,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { Started: true, }) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(sidecar) }) + testcontainers.CleanupContainer(t, sidecar) logs, err := sidecar.Logs(ctx) require.NoError(t, err) @@ -627,7 +626,7 @@ func TestWithIssuer_CrossContainerViaNetworkAlias(t *testing.T) { body, err := io.ReadAll(logs) require.NoError(t, err) - assert.Contains(t, string(body), issuer, + require.Contains(t, string(body), issuer, "discovery doc fetched via network alias must echo overridden issuer") } @@ -652,7 +651,7 @@ func TestClientCredentials_WithFeatureFlag(t *testing.T) { )), ) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + testcontainers.CleanupContainer(t, c) cfg := clientcredentials.Config{ ClientID: "svc", @@ -662,7 +661,7 @@ func TestClientCredentials_WithFeatureFlag(t *testing.T) { } tok, err := cfg.TokenSource(ctx).Token() require.NoError(t, err, "CC grant should succeed with feature flag enabled on master image") - assert.NotEmpty(t, tok.AccessToken) + require.NotEmpty(t, tok.AccessToken) } func TestConsumer_IDTokenVerifies_CoreosOIDC(t *testing.T) { @@ -681,7 +680,7 @@ func TestConsumer_IDTokenVerifies_CoreosOIDC(t *testing.T) { dex.WithUser(mustUser(t, "a@e.com", "a", "p")), ) require.NoError(t, err) - t.Cleanup(func() { _ = testcontainers.TerminateContainer(c) }) + testcontainers.CleanupContainer(t, c) cfg := oauth2.Config{ ClientID: "e2e", ClientSecret: "s", @@ -709,7 +708,7 @@ func TestConsumer_IDTokenVerifies_CoreosOIDC(t *testing.T) { Sub string `json:"sub"` } require.NoError(t, idToken.Claims(&claims)) - assert.Equal(t, "a@e.com", claims.Email) - assert.Equal(t, "a", claims.Name) - assert.NotEmpty(t, claims.Sub) + require.Equal(t, "a@e.com", claims.Email) + require.Equal(t, "a", claims.Name) + require.NotEmpty(t, claims.Sub) } diff --git a/modules/dex/log_test.go b/modules/dex/log_test.go index d1b9d8ac98..0d97acf491 100644 --- a/modules/dex/log_test.go +++ b/modules/dex/log_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,9 +17,9 @@ func TestSlogConsumer_EmitsRecord(t *testing.T) { consumer.accept(line, "STDOUT") out := buf.String() - assert.Contains(t, out, "test message") - assert.Contains(t, out, "level=WARN") - assert.Contains(t, out, "component=server") + require.Contains(t, out, "test message") + require.Contains(t, out, "level=WARN") + require.Contains(t, out, "component=server") } func TestSlogConsumer_StderrMinWarn(t *testing.T) { @@ -33,7 +32,7 @@ func TestSlogConsumer_StderrMinWarn(t *testing.T) { out := buf.String() require.NotEmpty(t, out) - assert.Contains(t, out, "level=WARN", "stderr lines promoted to at least WARN") + require.Contains(t, out, "level=WARN", "stderr lines promoted to at least WARN") } func TestSlogConsumer_EmptyLineIgnored(t *testing.T) { @@ -44,29 +43,29 @@ func TestSlogConsumer_EmptyLineIgnored(t *testing.T) { consumer.accept("", "STDOUT") consumer.accept("\n", "STDOUT") - assert.Empty(t, buf.String(), "empty lines must not emit records") + require.Empty(t, buf.String(), "empty lines must not emit records") } func TestParseLogfmt_UnknownKeysBecomeAttrs(t *testing.T) { _, msg, attrs := parseLogfmt(`level=info msg=hello foo=bar baz=qux`) - assert.Equal(t, "hello", msg) + require.Equal(t, "hello", msg) require.Len(t, attrs, 2) - assert.Equal(t, "foo", attrs[0].Key) - assert.Equal(t, "bar", attrs[0].Value.String()) - assert.Equal(t, "baz", attrs[1].Key) - assert.Equal(t, "qux", attrs[1].Value.String()) + require.Equal(t, "foo", attrs[0].Key) + require.Equal(t, "bar", attrs[0].Value.String()) + require.Equal(t, "baz", attrs[1].Key) + require.Equal(t, "qux", attrs[1].Value.String()) } func TestParseLogfmt_QuotedValue(t *testing.T) { _, msg, _ := parseLogfmt(`level=error msg="something went wrong: boom"`) - assert.Equal(t, "something went wrong: boom", msg) + require.Equal(t, "something went wrong: boom", msg) } func TestParseLogfmt_QuotedValueUnescapes(t *testing.T) { // Dex msg fields with embedded quotes / backslashes must round-trip // without raw \\ or \" sequences leaking into slog attrs. _, msg, _ := parseLogfmt(`level=info msg="he said \"hi\" then C:\\path"`) - assert.Equal(t, `he said "hi" then C:\path`, msg) + require.Equal(t, `he said "hi" then C:\path`, msg) } func TestMapLevel(t *testing.T) { @@ -80,6 +79,6 @@ func TestMapLevel(t *testing.T) { "bogus": slog.LevelInfo, // default } for in, want := range cases { - assert.Equal(t, want, mapLevel(in), "input=%q", in) + require.Equal(t, want, mapLevel(in), "input=%q", in) } } diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go index 0ac92ff879..8cb0eaa464 100644 --- a/modules/dex/options_test.go +++ b/modules/dex/options_test.go @@ -4,17 +4,16 @@ import ( "log/slog" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOptions_Defaults(t *testing.T) { o := defaultOptions() - assert.True(t, o.skipApprovalScreen) - assert.Equal(t, StorageSQLite, o.storage) - assert.Equal(t, slog.LevelInfo, o.logLevel) - assert.True(t, o.enablePasswordDB) - assert.Empty(t, o.issuer) + require.True(t, o.skipApprovalScreen) + require.Equal(t, StorageSQLite, o.storage) + require.Equal(t, slog.LevelInfo, o.logLevel) + require.True(t, o.enablePasswordDB) + require.Empty(t, o.issuer) } func TestOptions_Apply(t *testing.T) { @@ -43,26 +42,26 @@ func TestOptions_Apply(t *testing.T) { require.NoError(t, opt(&o)) } - assert.Len(t, o.clients, 2) - assert.Equal(t, "b", o.clients[1].id) - assert.Len(t, o.users, 1) - assert.Len(t, o.connectors, 1) - assert.Equal(t, ConnectorMock, o.connectors[0].Type) - assert.Equal(t, "m", o.connectors[0].ID) - assert.Equal(t, "Mock", o.connectors[0].Name) - assert.Equal(t, "http://dex:5556/dex", o.issuer) - assert.False(t, o.skipApprovalScreen) - assert.Equal(t, StorageMemory, o.storage) - assert.Equal(t, slog.LevelDebug, o.logLevel) - assert.NotNil(t, o.logger) - assert.False(t, o.enablePasswordDB) + require.Len(t, o.clients, 2) + require.Equal(t, "b", o.clients[1].id) + require.Len(t, o.users, 1) + require.Len(t, o.connectors, 1) + require.Equal(t, ConnectorMock, o.connectors[0].Type) + require.Equal(t, "m", o.connectors[0].ID) + require.Equal(t, "Mock", o.connectors[0].Name) + require.Equal(t, "http://dex:5556/dex", o.issuer) + require.False(t, o.skipApprovalScreen) + require.Equal(t, StorageMemory, o.storage) + require.Equal(t, slog.LevelDebug, o.logLevel) + require.NotNil(t, o.logger) + require.False(t, o.enablePasswordDB) } func TestOptions_WithConnectorPassword_IsNoOp(t *testing.T) { o := defaultOptions() require.NoError(t, WithConnector(ConnectorPassword, "local", "Local")(&o)) - assert.Empty(t, o.connectors, "ConnectorPassword must not be appended; password DB covers it") - assert.True(t, o.enablePasswordDB, "password DB stays enabled by default") + require.Empty(t, o.connectors, "ConnectorPassword must not be appended; password DB covers it") + require.True(t, o.enablePasswordDB, "password DB stays enabled by default") } func TestOptions_WithConnector_RejectsBlankFields(t *testing.T) { @@ -74,31 +73,31 @@ func TestOptions_WithConnector_RejectsBlankFields(t *testing.T) { err = WithConnector(ConnectorMock, "id", "")(&o) require.Error(t, err, "blank name must be rejected") - assert.Empty(t, o.connectors, "rejected options must not mutate state") + require.Empty(t, o.connectors, "rejected options must not mutate state") } func TestOptions_WithIssuer_RejectsBlank(t *testing.T) { o := defaultOptions() require.Error(t, WithIssuer("")(&o)) - assert.Empty(t, o.issuer) + require.Empty(t, o.issuer) } func TestOptions_WithStorage_RejectsBlank(t *testing.T) { o := defaultOptions() require.Error(t, WithStorage("")(&o)) - assert.Equal(t, StorageSQLite, o.storage, "failed option must leave default intact") + require.Equal(t, StorageSQLite, o.storage, "failed option must leave default intact") } func TestOptions_WithClient_RejectsZeroValue(t *testing.T) { o := defaultOptions() require.Error(t, WithClient(Client{})(&o), "zero Client must be rejected — force NewClient") - assert.Empty(t, o.clients) + require.Empty(t, o.clients) } func TestOptions_WithUser_RejectsZeroValue(t *testing.T) { o := defaultOptions() require.Error(t, WithUser(User{})(&o), "zero User must be rejected — force NewUser") - assert.Empty(t, o.users) + require.Empty(t, o.users) } func TestNewClient_WithClientGrantTypes_Allowlist(t *testing.T) { @@ -118,9 +117,9 @@ func TestNewClient_WithClientGrantTypes_Allowlist(t *testing.T) { t.Run(tc.name, func(t *testing.T) { _, err := NewClient("c", WithClientSecret("s"), WithClientGrantTypes(tc.grants...)) if tc.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { - assert.NoError(t, err) + require.NoError(t, err) } }) }