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..6dade1a657
--- /dev/null
+++ b/docs/modules/dex.md
@@ -0,0 +1,268 @@
+# Dex
+
+Not available until the next release :material-tag: main
+
+## 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:
+
+```bash
+go get github.com/testcontainers/testcontainers-go/modules/dex
+```
+
+## Usage example
+
+
+[Creating a Dex container](../../modules/dex/examples_test.go) inside_block:runContainer
+
+
+## Module Reference
+
+### Run function
+
+- 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:
+
+```golang
+func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, 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
+
+- 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
+`(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
+
+- 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;
+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
+
+- Not available until the next release :material-tag: main
+
+`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
+
+- 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.
+Use it when the issuer must be reachable from other containers (shared
+Docker network with a network alias); the caller owns reachability.
+
+#### Storage
+
+- Not available until the next release :material-tag: main
+
+`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
+
+- 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
+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
+
+- 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
+reads to enable the OAuth2 `client_credentials` grant. Pair with a
+compatible image (see the `Image` warning above).
+
+#### Approval screen
+
+- 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).
+
+{% include "../features/common_functional_options_list.md" %}
+
+### Container Methods
+
+The Dex container exposes the following methods:
+
+#### IssuerURL
+
+- Not available until the next release :material-tag: main
+
+Returns Dex's issuer URL. Empty if `Run` has not started.
+
+#### ConfigEndpoint
+
+- Not available until the next release :material-tag: main
+
+Returns the OIDC discovery document URL (`/.well-known/openid-configuration`).
+
+#### JWKSEndpoint
+
+- Not available until the next release :material-tag: main
+
+Returns the JSON Web Key Set URL (`/keys`).
+
+#### TokenEndpoint
+
+- Not available until the next release :material-tag: main
+
+Returns the OAuth2 token URL (`/token`).
+
+#### AuthEndpoint
+
+- Not available until the next release :material-tag: main
+
+Returns the OAuth2 authorization URL (`/auth`).
+
+#### GRPCEndpoint
+
+- 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
+call fails.
+
+```golang
+target, err := c.GRPCEndpoint(ctx)
+```
+
+#### AddClient
+
+- 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
+concurrent use.
+
+#### RemoveClient
+
+- 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
+
+- 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
+concurrent use.
+
+#### RemoveUser
+
+- 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.
+
+### 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/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..e2c67a701e
--- /dev/null
+++ b/modules/dex/README.md
@@ -0,0 +1,171 @@
+# Dex
+
+Since :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
+
+```bash
+go get 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(app),
+ dex.WithUser(user),
+)
+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
+`WithClientGrantTypes(...)`.
+
+**`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",
+ dex.WithClientSecret("s"),
+ dex.WithClientName("Service"),
+ dex.WithClientGrantTypes("client_credentials"),
+)
+// ...
+c, err := dex.Run(ctx, "dexidp/dex:master",
+ dex.WithEnableClientCredentials(),
+ dex.WithClient(svc),
+)
+```
+
+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`). Disable via
+ `WithDisablePasswordDB()` when running connector-only flows.
+- `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 when constructed via `NewUser`
+ without `WithUserID`).
+- `email` — user's email address.
+- `email_verified` — always `true` for static password entries.
+- `name` — the value of `User`'s 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` — 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.
+
+### Module options
+
+- `WithClient(Client)`
+- `WithUser(User)`
+- `WithConnector(type, id, name)`
+- `WithIssuer(url)`
+- `WithSkipApprovalScreen(bool)`
+- `WithStorage(Storage)` — `StorageSQLite` (default) or `StorageMemory`
+- `WithDisablePasswordDB()` — opt out of the built-in password DB
+- `WithLogger(*slog.Logger)` — captures Dex logs
+- `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() 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)
+
+`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.
+- `RemoveClient` wraps `ErrClientNotFound` when the ID is absent.
+- `RemoveUser` wraps `ErrUserNotFound` when the email is absent.
+
+## 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..1cf839a88e
--- /dev/null
+++ b/modules/dex/config.go
@@ -0,0 +1,216 @@
+package dex
+
+import (
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "log/slog"
+
+ "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"`
+}
+
+// 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",
+ "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, errors.New("dex: issuer is empty (internal bug — Run should populate before render)")
+ }
+
+ if !o.enablePasswordDB && len(o.connectors) == 0 {
+ return nil, ErrNoAuthSource
+ }
+
+ 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,
+ })
+ }
+
+ 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 == "" {
+ 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,
+ 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,
+ })
+ }
+
+ grantTypes := append([]string(nil), baseGrantTypes...)
+ if o.enableClientCredentials {
+ grantTypes = append(grantTypes, "client_credentials")
+ }
+ oauth2 := oauth2Block{
+ SkipApprovalScreen: o.skipApprovalScreen,
+ 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
+ // 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: dexLogLevel(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
+}
+
+// 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 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:
+ 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.
+// 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 {
+ 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]), nil
+}
diff --git a/modules/dex/config_test.go b/modules/dex/config_test.go
new file mode 100644
index 0000000000..5c4f898cde
--- /dev/null
+++ b/modules/dex/config_test.go
@@ -0,0 +1,260 @@
+package dex
+
+import (
+ "log/slog"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/bcrypt"
+ "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"
+
+ out, err := render(o)
+ require.NoError(t, err)
+
+ var got map[string]any
+ require.NoError(t, yaml.Unmarshal(out, &got))
+
+ require.Equal(t, "http://localhost:5556/dex", got["issuer"])
+ require.Equal(t, true, got["enablePasswordDB"])
+ storage := got["storage"].(map[string]any)
+ require.Equal(t, "sqlite3", storage["type"])
+ web := got["web"].(map[string]any)
+ require.Equal(t, "0.0.0.0:5556", web["http"])
+ grpc := got["grpc"].(map[string]any)
+ require.Equal(t, "0.0.0.0:5557", grpc["addr"])
+ oauth2 := got["oauth2"].(map[string]any)
+ require.Equal(t, true, oauth2["skipApprovalScreen"])
+}
+
+func TestRender_WithClients(t *testing.T) {
+ o := defaultOptions()
+ o.issuer = "http://h:5556/dex"
+ o.clients = []Client{
+ 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)
+ 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)
+ 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) {
+ o := defaultOptions()
+ o.issuer = "http://h:5556/dex"
+ o.users = []User{mustUser(t, "u@e.com", "u", "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]
+ 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")))
+ require.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)
+ require.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)
+ require.ErrorIs(t, err, ErrNoAuthSource)
+}
+
+func TestRender_IssuerRequired(t *testing.T) {
+ o := defaultOptions()
+ // issuer empty
+ _, err := render(o)
+ require.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{mustUser(t, "u@e.com", "u", "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).
+ require.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{mustUser(t, "u@e.com", "u", "p", WithUserID("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)
+ require.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))
+ require.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))
+ require.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{mustClient(t, "c", WithClientSecret("s"), WithClientName(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)
+ 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) {
+ 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 {
+ require.Equal(t, want, dexLogLevel(in), "slog.Level=%v", in)
+ }
+}
diff --git a/modules/dex/dex.go b/modules/dex/dex.go
new file mode 100644
index 0000000000..3ea8ac614c
--- /dev/null
+++ b/modules/dex/dex.go
@@ -0,0 +1,176 @@
+// 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)
+// 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(app),
+// dex.WithUser(user),
+// )
+// defer testcontainers.TerminateContainer(c)
+package dex
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "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"
+)
+
+// Container is a running Dex OIDC provider.
+type Container 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) (*Container, error) {
+ settings := defaultOptions()
+ for _, opt := range opts {
+ if apply, ok := opt.(Option); ok {
+ if err := apply(&settings); err != nil {
+ return nil, fmt.Errorf("dex: apply option: %w", err)
+ }
+ }
+ }
+
+ container := &Container{}
+
+ 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. 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(discoveryPath).
+ 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",
+ }))
+ }
+ 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 *Container) IssuerURL() string { return c.issuer }
+
+// ConfigEndpoint returns the OIDC discovery document URL.
+func (c *Container) ConfigEndpoint() string {
+ return c.issuer + "/.well-known/openid-configuration"
+}
+
+// JWKSEndpoint returns the JSON Web Key Set URL.
+func (c *Container) JWKSEndpoint() string { return c.issuer + "/keys" }
+
+// TokenEndpoint returns the OAuth2 token URL.
+func (c *Container) TokenEndpoint() string { return c.issuer + "/token" }
+
+// AuthEndpoint returns the OAuth2 authorization URL.
+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 *Container) GRPCEndpoint(ctx context.Context) (string, error) {
+ if c.Container == nil {
+ return "", errors.New("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
+}
diff --git a/modules/dex/dex_test.go b/modules/dex/dex_test.go
new file mode 100644
index 0000000000..b1a4435199
--- /dev/null
+++ b/modules/dex/dex_test.go
@@ -0,0 +1,714 @@
+package dex_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "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"
+)
+
+const (
+ dexImage = "dexidp/dex:v2.45.1"
+ 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(mustUser(t, "u@e.com", "u", "p")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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)
+ require.NotEmpty(t, grpcEP)
+
+ 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")
+
+ var doc map[string]any
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(body, &doc))
+
+ 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) {
+ 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(mustUser(t, "u@e.com", "u", "p")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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.
+ 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())
+ 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)
+
+ var doc map[string]any
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(body, &doc))
+ require.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(mustUser(t, "u@e.com", "u", "p")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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.
+ err = c.AddClient(ctx, cl)
+ require.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())
+ require.ErrorIs(t, err, dex.ErrClientNotFound)
+}
+
+func TestGRPC_AddRemoveUser(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ c, err := dex.Run(ctx, dexImage)
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ u := mustUser(t, "runtime@example.com", "runtime", "p")
+ require.NoError(t, c.AddUser(ctx, u))
+
+ // Duplicate add errors.
+ err = c.AddUser(ctx, u)
+ require.ErrorIs(t, err, dex.ErrUserExists)
+
+ // Removal succeeds.
+ require.NoError(t, c.RemoveUser(ctx, u.Email()))
+
+ // Second removal errors.
+ err = c.RemoveUser(ctx, u.Email())
+ require.ErrorIs(t, err, dex.ErrUserNotFound)
+}
+
+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(mustUser(t, "u@e.com", "u", "p")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ // 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(30 * 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(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")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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")
+ require.NotEmpty(t, tok.AccessToken)
+
+ idToken, ok := tok.Extra("id_token").(string)
+ require.True(t, ok, "id_token missing from response")
+ require.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(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")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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")
+ require.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(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")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ for _, uri := range uris {
+ 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")
+ require.NotEmpty(t, tok.AccessToken)
+ })
+ }
+}
+
+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(mustClient(t, "svc",
+ dex.WithClientSecret("s"),
+ dex.WithClientName("Service"),
+ dex.WithClientGrantTypes("client_credentials"),
+ )),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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())
+ require.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(mustClient(t, "cli",
+ dex.WithClientSecret("s"),
+ dex.WithClientName("CLI"),
+ dex.WithClientGrantTypes("password"),
+ )),
+ dex.WithUser(mustUser(t, "a@e.com", "a", "p")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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)
+ require.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(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")),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ // 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")
+ require.NotEmpty(t, webTok.AccessToken)
+
+ require.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(mustClient(t, "e2e",
+ dex.WithClientSecret("s"),
+ dex.WithClientName("E2E"),
+ dex.WithClientRedirectURIs("http://localhost/cb"),
+ dex.WithClientGrantTypes("authorization_code", "refresh_token"),
+ )),
+ )
+ testcontainers.CleanupContainer(t, c)
+ require.NoError(t, err)
+
+ 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, http.MethodGet, authURL, nil)
+ require.NoError(t, err)
+
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, _ []*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()
+
+ // 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())
+
+ tok, err := cfg.Exchange(ctx, code)
+ require.NoError(t, err)
+ require.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)
+ testcontainers.CleanupContainer(t, c)
+
+ // Seed client + user at runtime.
+ 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",
+ 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")
+ require.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, err := cookiejar.New(nil)
+ require.NoError(t, err)
+ client := &http.Client{
+ Jar: jar,
+ CheckRedirect: func(req *http.Request, _ []*http.Request) error {
+ if strings.HasPrefix(req.URL.String(), cfg.RedirectURL) {
+ return http.ErrUseLastResponse
+ }
+ return nil
+ },
+ }
+
+ authURL := cfg.AuthCodeURL("s1")
+ 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()
+
+ // 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"}}
+ 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()
+ // 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))
+ 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)
+}
+
+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)
+ testcontainers.CleanupNetwork(t, net)
+
+ const issuer = "http://dex:5556/dex"
+
+ c, err := dex.Run(ctx, dexImage,
+ dex.WithIssuer(issuer),
+ dex.WithUser(mustUser(t, "u@e.com", "u", "p")),
+ network.WithNetwork([]string{"dex"}, net),
+ )
+ require.NoError(t, err)
+ testcontainers.CleanupContainer(t, 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)
+ testcontainers.CleanupContainer(t, sidecar)
+
+ logs, err := sidecar.Logs(ctx)
+ require.NoError(t, err)
+ defer logs.Close()
+ body, err := io.ReadAll(logs)
+ require.NoError(t, err)
+
+ require.Contains(t, string(body), issuer,
+ "discovery doc fetched via network alias must echo overridden issuer")
+}
+
+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()
+
+ c, err := dex.Run(ctx, dexImageWithCC,
+ dex.WithEnableClientCredentials(),
+ dex.WithClient(mustClient(t, "svc",
+ dex.WithClientSecret("s"),
+ dex.WithClientName("Service"),
+ dex.WithClientGrantTypes("client_credentials"),
+ )),
+ )
+ require.NoError(t, err)
+ testcontainers.CleanupContainer(t, 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")
+ require.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(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)
+ testcontainers.CleanupContainer(t, 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))
+ require.Equal(t, "a@e.com", claims.Email)
+ require.Equal(t, "a", claims.Name)
+ require.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..5f15d6dcbc
--- /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, _ []*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, http.MethodGet, 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, http.MethodPost, 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