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
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..307b370a82 --- /dev/null +++ b/modules/dex/examples_test.go @@ -0,0 +1,92 @@ +package dex_test + +import ( + "context" + "fmt" + "log" + + "golang.org/x/oauth2" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/dex" +) + +func ExampleRun_authorizationCode() { + // runContainer { + 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(app), + dex.WithUser(user), + ) + if err != nil { + log.Fatalf("run: %v", err) + } + defer func() { _ = 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() + + 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(svc), + dex.WithUser(user), + ) + if err != nil { + log.Fatalf("run: %v", err) + } + defer func() { _ = 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 { + panic(fmt.Errorf("token: %w", 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..e4b7c26439 --- /dev/null +++ b/modules/dex/grpc.go @@ -0,0 +1,153 @@ +package dex + +import ( + "context" + "errors" + "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 *Container) 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 fmt.Errorf("%w: %q", ErrClientExists, cl.id) + } + return nil +} + +// RemoveClient deletes a client by ID. +// +// Not safe for concurrent use. +func (c *Container) 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("%w: %q", ErrClientNotFound, id) + } + return nil +} + +// AddUser registers a user in Dex's password DB via gRPC. +// +// Not safe for concurrent use. +func (c *Container) 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), testBcryptCost) + if err != nil { + return fmt.Errorf("dex: bcrypt: %w", err) + } + userID := u.userID + if userID == "" { + 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{ + 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 fmt.Errorf("%w: %q", ErrUserExists, u.email) + } + return nil +} + +// RemoveUser deletes a user by email. +// +// Not safe for concurrent use. +func (c *Container) 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("%w: %q", ErrUserNotFound, email) + } + return nil +} + +func dial(target string) (*grpc.ClientConn, error) { + if target == "" { + return nil, errors.New("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..94dabf16e0 --- /dev/null +++ b/modules/dex/log.go @@ -0,0 +1,138 @@ +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...) +} + +// 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 and have their \" / \\ escapes expanded. +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, logfmtUnescaper.Replace(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..0d97acf491 --- /dev/null +++ b/modules/dex/log_test.go @@ -0,0 +1,84 @@ +package dex + +import ( + "bytes" + "log/slog" + "testing" + + "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() + require.Contains(t, out, "test message") + require.Contains(t, out, "level=WARN") + require.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) + require.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") + + 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`) + require.Equal(t, "hello", msg) + require.Len(t, attrs, 2) + 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"`) + 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"`) + require.Equal(t, `he said "hi" then C:\path`, 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 { + require.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..f76a0d1b16 --- /dev/null +++ b/modules/dex/options.go @@ -0,0 +1,187 @@ +package dex + +import ( + "errors" + "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 + skipApprovalScreen bool + storage Storage + logLevel slog.Level + logger *slog.Logger + enablePasswordDB bool + enableClientCredentials bool +} + +func defaultOptions() options { + return options{ + skipApprovalScreen: true, + storage: StorageSQLite, + logLevel: slog.LevelInfo, + enablePasswordDB: true, + } +} + +// 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; 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 +} + +// 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) 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) 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 and the template +// 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 { + 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 + } +} + +// 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) 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) 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 + } +} + +// 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 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 + } +} + +// 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 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) error { + o.enableClientCredentials = true + return nil + } +} diff --git a/modules/dex/options_test.go b/modules/dex/options_test.go new file mode 100644 index 0000000000..8cb0eaa464 --- /dev/null +++ b/modules/dex/options_test.go @@ -0,0 +1,126 @@ +package dex + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOptions_Defaults(t *testing.T) { + o := defaultOptions() + 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) { + 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(clientA), + WithClient(clientB), + WithUser(user), + WithConnector(ConnectorMock, "m", "Mock"), + WithIssuer("http://dex:5556/dex"), + WithSkipApprovalScreen(false), + WithStorage(StorageMemory), + WithLogLevel(slog.LevelDebug), + WithLogger(slog.Default()), + WithDisablePasswordDB(), + } + for _, opt := range applyAll { + require.NoError(t, opt(&o)) + } + + 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)) + 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) { + o := defaultOptions() + + err := WithConnector(ConnectorMock, "", "Mock")(&o) + require.Error(t, err, "blank id must be rejected") + + err = WithConnector(ConnectorMock, "id", "")(&o) + require.Error(t, err, "blank name must be rejected") + + 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)) + require.Empty(t, o.issuer) +} + +func TestOptions_WithStorage_RejectsBlank(t *testing.T) { + o := defaultOptions() + require.Error(t, WithStorage("")(&o)) + 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") + 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") + require.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 { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/modules/dex/types.go b/modules/dex/types.go new file mode 100644 index 0000000000..e7bcade7a1 --- /dev/null +++ b/modules/dex/types.go @@ -0,0 +1,213 @@ +package dex + +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 +// call-site rather than at Run. +type Client struct { + id string + secret string + name string + redirectURIs []string + grantTypes []string + public bool +} + +// 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") + } + if _, ok := validClientGrantTypes[g]; !ok { + return fmt.Errorf("dex: unsupported client grant type %q", g) + } + } + 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 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. +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" +) + +// 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. + 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 + // explicitly disable it via WithDisablePasswordDB to trigger this error. + ErrNoAuthSource = errors.New("dex: no auth source configured") +)