From c4e81ed09269ba0469bc6ebc8fd50e181d4620a4 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 21 Apr 2026 17:56:00 +0200 Subject: [PATCH] Inline OAuth2 signing key in config The OAuth2/OIDC server accepted its signing key via a file path (key-file), while every other PEM key in the probod config (SAML private key, ACME account key) is embedded inline. Switch the field to a private-key string so the convention is uniform. The signing key is operator-supplied material that must outlive any process restart, so the bootstrap builder now treats OAUTH2_SERVER_SIGNING_KEY as required and refuses to start without one; silently minting a fresh key per boot would break token validation across rollouts. The OAUTH2_SERVER_* env vars otherwise flow through builder.Build like the existing SAML block so the new OAuth2Server section is populated end-to-end. Rework the e2e harness to render its config via bootstrap at test setup, which removes the static e2e/console/testdata/config.yaml and the previously generated test-only PEM file. A per-run RSA key is minted via bootstrap.GenerateOAuth2SigningKey (kept public for test tooling) and injected through the builder env map. CI now passes ACME_ROOT_CA inline instead of mutating a YAML on disk. Signed-off-by: Bryan Frimin --- .github/workflows/make.yaml | 21 +----- GNUmakefile | 3 - contrib/claude/config.md | 6 +- contrib/claude/make.md | 1 - e2e/console/testdata/config.yaml | 99 -------------------------- e2e/internal/testutil/testutil.go | 114 ++++++++++++++++++++++-------- pkg/bootstrap/builder.go | 31 +++++++- pkg/bootstrap/builder_test.go | 76 ++++++++++++++++++-- pkg/bootstrap/oauth2.go | 41 +++++++++++ pkg/bootstrap/oauth2_test.go | 52 ++++++++++++++ pkg/probod/auth_config.go | 6 +- pkg/probod/probod.go | 8 +-- 12 files changed, 288 insertions(+), 170 deletions(-) delete mode 100644 e2e/console/testdata/config.yaml create mode 100644 pkg/bootstrap/oauth2.go create mode 100644 pkg/bootstrap/oauth2_test.go diff --git a/.github/workflows/make.yaml b/.github/workflows/make.yaml index 637716f2d..53514f5c9 100644 --- a/.github/workflows/make.yaml +++ b/.github/workflows/make.yaml @@ -401,29 +401,14 @@ jobs: SKIP_APPS=1 make bin/probod wait $STACK_PID - run: "make stack-ps" - - name: "Inject root CA into e2e config" - run: | - python3 << 'EOF' - import yaml - - with open('compose/pebble/certs/rootCA.pem', 'r') as f: - root_ca = f.read() - - with open('e2e/console/testdata/config.yaml', 'r') as f: - config = yaml.safe_load(f) - - config['probod']['custom-domains']['acme']['root-ca'] = root_ca - - with open('e2e/console/testdata/config.yaml', 'w') as f: - yaml.dump(config, f, default_flow_style=False, allow_unicode=True) - EOF - name: "Run e2e tests" env: PROBO_E2E_BINARY: "${{ github.workspace }}/bin/probod" - PROBO_E2E_CONFIG: "${{ github.workspace }}/e2e/console/testdata/config.yaml" GOTESTSUM_FORMAT: "testname" GOTESTSUM_JUNITFILE: "junit-e2e.xml" - run: "CGO_ENABLED=1 go tool gotestsum -- -race -cover -coverprofile=coverage.out -count=1 ./e2e/console/..." + run: | + ACME_ROOT_CA="$(cat compose/pebble/certs/rootCA.pem)" \ + CGO_ENABLED=1 go tool gotestsum -- -race -cover -coverprofile=coverage.out -count=1 ./e2e/console/... - name: "Upload test results" uses: "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7 if: "always()" diff --git a/GNUmakefile b/GNUmakefile index 1e0a49e4c..88d634974 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -38,7 +38,6 @@ GO_TOOL= $(GO_BASE) tool TEST_FLAGS?= -race -cover -coverprofile=coverage.out -E2E_CONFIG ?= $(CURDIR)/e2e/console/testdata/config.yaml E2E_COVER_DIR ?= $(CURDIR)/coverage/e2e DOCKER_IMAGE_NAME= ghcr.io/getprobo/probo @@ -129,7 +128,6 @@ test-bench: test ## Run benchmark tests test-e2e: CGO_ENABLED=1 test-e2e: bin/probod ## Run console e2e tests PROBO_E2E_BINARY=$(CURDIR)/bin/probod \ - PROBO_E2E_CONFIG=$(E2E_CONFIG) \ GOTESTSUM_FORMAT=testname $(GO_TEST) -count=1 ./e2e/console/... bin/probod-coverage: @@ -140,7 +138,6 @@ test-e2e-coverage: bin/probod-coverage ## Run e2e tests with coverage @$(RM) -rf $(E2E_COVER_DIR) && $(MKDIR) -p $(E2E_COVER_DIR) PROBO_E2E_BINARY=$(CURDIR)/bin/probod-coverage \ PROBO_E2E_COVERDIR=$(E2E_COVER_DIR) \ - PROBO_E2E_CONFIG=$(E2E_CONFIG) \ CGO_ENABLED=1 $(GO) test -count=1 -v ./e2e/console/... $(GO) tool covdata textfmt -i=$(E2E_COVER_DIR) -o=coverage-e2e.out $(GO) tool cover -html=coverage-e2e.out -o=coverage-e2e.html diff --git a/contrib/claude/config.md b/contrib/claude/config.md index ed0ce0fe5..897578aa3 100644 --- a/contrib/claude/config.md +++ b/contrib/claude/config.md @@ -11,7 +11,7 @@ When a configuration field is added, renamed, or removed in the Go config struct | 3 | `pkg/bootstrap/builder.go` | Env-var → struct mapping (`Build()` method) | | 4 | `pkg/bootstrap/builder.go` | Required-env validation (`validateRequired()`) | | 5 | `cfg/dev.yaml` | Local development config | -| 6 | `e2e/console/testdata/config.yaml` | E2E test config | +| 6 | `e2e/internal/testutil/testutil.go` | E2E env-var map fed to `bootstrap.NewBuilder` | | 7 | `contrib/lima/provision.sh` | Sandbox env vars passed to `probod-bootstrap` | | 8 | `contrib/helm/charts/probo/values.yaml` | Helm default values | | 9 | `contrib/helm/charts/probo/values-production.yaml.example` | Helm production template | @@ -28,7 +28,7 @@ Go struct (pkg/probod/) ├─► bootstrap builder.go (env var → struct) │ │ │ ├─► cfg/dev.yaml (static YAML, local dev) - │ ├─► e2e/console/testdata/ (static YAML, tests) + │ ├─► e2e/internal/testutil/ (env map → bootstrap.Build, tests) │ ├─► contrib/lima/provision.sh (env vars → probod-bootstrap) │ └─► Helm chart │ ├─ values.yaml (user-facing knobs) @@ -45,7 +45,7 @@ Go struct (pkg/probod/) 2. **Env var naming** — follow the existing convention in `builder.go`: `SECTION_FIELD_NAME` (e.g. `AUTH_COOKIE_DOMAIN`, `CUSTOM_DOMAINS_RENEWAL_INTERVAL`). 3. **Secrets** go through `secret.yaml` and are referenced via `secretKeyRef` in `deployment.yaml`. Non-secret values are set inline. 4. **`cfg/dev.yaml`** uses safe, non-production defaults (plaintext passwords, `localhost`, `secure: false`). -5. **`e2e/console/testdata/config.yaml`** mirrors `cfg/dev.yaml` but with test-specific values (different ports, `probod_test` DB, shorter intervals). +5. **`e2e/internal/testutil/testutil.go`** builds the e2e config through `bootstrap.NewBuilder` with a test-only env-var map (different ports, `probod_test` DB, shorter intervals). Any new field whose test value differs from the bootstrap default must be added to that map. 6. **`provision.sh`** only sets env vars that differ from `builder.go` defaults (e.g. `PROBOD_BASE_URL`, `AUTH_COOKIE_DOMAIN`, `AUTH_COOKIE_SECURE`). If the new field's default is acceptable in the sandbox, no env var is needed. 7. **Helm `values.yaml`** exposes the field under the appropriate `probo.*` key with a sensible default. `values-production.yaml.example` includes it only when the production value differs or the user must set it. 8. **Optional features** (custom domains, SAML, connectors, tracing) are gated by `{{- if }}` blocks in the Helm templates; follow the same pattern for new optional fields. diff --git a/contrib/claude/make.md b/contrib/claude/make.md index 461deb04a..9b616cd02 100644 --- a/contrib/claude/make.md +++ b/contrib/claude/make.md @@ -78,4 +78,3 @@ The project uses a `GNUmakefile` at the root. Builds run with `--jobs=$(nproc)` | `GOOS` | (host) | Cross-compile target OS | | `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` | | `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` | -| `E2E_CONFIG` | `e2e/console/testdata/config.yaml` | E2E test config path | diff --git a/e2e/console/testdata/config.yaml b/e2e/console/testdata/config.yaml deleted file mode 100644 index 50754d439..000000000 --- a/e2e/console/testdata/config.yaml +++ /dev/null @@ -1,99 +0,0 @@ -unit: - metrics: - addr: "localhost:19081" - tracing: - addr: "localhost:14317" - max-batch-size: 512 - batch-timeout: 5 - export-timeout: 30 - max-queue-size: 2048 - -probod: - base-url: "http://localhost:18080" - encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - chrome-dp-addr: "localhost:9222" - - api: - addr: "localhost:18080" - cors: - allowed-origins: ["http://localhost:18080"] - extra-header-fields: {} - - pg: - addr: "localhost:5432" - username: "postgres" - password: "postgres" - database: "probod_test" - pool-size: 10 - - auth: - disable-signup: false - invitation-confirmation-token-validity: 3600 - cookie: - name: "SSID" - domain: "localhost" - secret: "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes" - duration: 24 - secure: false - password: - pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes" - iterations: 600000 - oauth2-server: - signing-keys: - - key-file: "./testdata/oauth2_signing_key.pem" - kid: "test-key-1" - active: true - access-token-duration: 10 - refresh-token-duration: 10 - authorization-code-duration: 5 - device-code-duration: 15 - - trust-center: - http-addr: ":10080" - https-addr: ":10443" - - aws: - region: "us-east-1" - bucket: "probod-test" - access-key-id: "probod" - secret-access-key: "thisisnotasecret" - endpoint: "http://127.0.0.1:8333" - - notifications: - mailer: - sender-name: "Probo Test" - sender-email: "no-reply@test.getprobo.com" - smtp: - addr: "localhost:1025" - tls-required: false - mailer-interval: 1 - slack: - sender-interval: 60 - - llm: - providers: - openai: - type: "openai" - api-key: "thisisnotasecret" - defaults: - provider: "openai" - model-name: "gpt-4o" - temperature: 0.1 - max-tokens: 4096 - - evidence-describer: - interval: 10 - stale-after: 300 - max-concurrency: 10 - - custom-domains: - renewal-interval: 3600 - provision-interval: 30 - cname-target: "custom.test.getprobo.com" - acme: - directory: "https://localhost:14000/dir" - email: "admin@test.getprobo.com" - key-type: "EC256" - root-ca: "" - - connectors: [] diff --git a/e2e/internal/testutil/testutil.go b/e2e/internal/testutil/testutil.go index bafa7f6db..8dc197e59 100644 --- a/e2e/internal/testutil/testutil.go +++ b/e2e/internal/testutil/testutil.go @@ -17,20 +17,18 @@ package testutil import ( "bytes" "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "fmt" "io" "net/http" "os" "os/exec" + "path/filepath" "sync" "syscall" "time" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/bootstrap" ) var ( @@ -68,7 +66,6 @@ func (s *switchableWriter) switchTo(w io.Writer) { func Setup() { setupOnce.Do(func() { binaryPath := os.Getenv("PROBO_E2E_BINARY") - configPath := os.Getenv("PROBO_E2E_CONFIG") coverDir := os.Getenv("PROBO_E2E_COVERDIR") if binaryPath == "" { @@ -76,11 +73,6 @@ func Setup() { os.Exit(1) } - if configPath == "" { - fmt.Fprintf(os.Stderr, "e2etest: PROBO_E2E_CONFIG is required\n") - os.Exit(1) - } - // Create coverage directory if specified if coverDir != "" { if err := os.MkdirAll(coverDir, 0755); err != nil { @@ -89,8 +81,9 @@ func Setup() { } } - if err := ensureSigningKey("./testdata/oauth2_signing_key.pem"); err != nil { - fmt.Fprintf(os.Stderr, "e2etest: cannot create signing key: %v\n", err) + configPath, err := generateConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "e2etest: cannot generate config: %v\n", err) os.Exit(1) } @@ -241,30 +234,93 @@ func GetMailpitBaseURL() string { return testEnv.MailpitBaseURL } -// ensureSigningKey creates a 2048-bit RSA PEM key at path if it does not -// already exist. The key is used exclusively for e2e test JWT signing. -func ensureSigningKey(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } - - key, err := rsa.GenerateKey(rand.Reader, 2048) +// generateConfig builds a probod config for the e2e suite via the +// bootstrap package (which auto-generates SAML credentials) and +// writes it to a temp file. A fresh OAuth2 signing key is minted +// here and injected via env. Returns the path. +func generateConfig() (string, error) { + oauth2SigningKey, err := bootstrap.GenerateOAuth2SigningKey() if err != nil { - return fmt.Errorf("cannot generate RSA key: %w", err) + return "", fmt.Errorf("generate oauth2 signing key: %w", err) } - data := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), + env := map[string]string{ + // Required. + "PROBOD_ENCRYPTION_KEY": "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "AUTH_COOKIE_SECRET": "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes", + "AUTH_PASSWORD_PEPPER": "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes", + "OAUTH2_SERVER_SIGNING_KEY": oauth2SigningKey, + + // Unit. + "METRICS_ADDR": "localhost:19081", + "TRACING_ADDR": "localhost:14317", + + // Probod base. + "PROBOD_BASE_URL": "http://localhost:18080", + + // API. + "API_ADDR": "localhost:18080", + "API_CORS_ALLOWED_ORIGINS": "http://localhost:18080", + + // PG. + "PG_DATABASE": "probod_test", + "PG_POOL_SIZE": "10", + + // Auth. + "AUTH_COOKIE_SECURE": "false", + "AUTH_PASSWORD_ITERATIONS": "600000", + + // OAuth2 server durations kept small for faster e2e flows. + "OAUTH2_SERVER_ACCESS_TOKEN_DURATION": "10", + "OAUTH2_SERVER_REFRESH_TOKEN_DURATION": "10", + "OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION": "5", + "OAUTH2_SERVER_DEVICE_CODE_DURATION": "15", + + // Trust center. + "TRUST_CENTER_HTTP_ADDR": ":10080", + "TRUST_CENTER_HTTPS_ADDR": ":10443", + + // AWS / S3 (SeaweedFS). + "AWS_BUCKET": "probod-test", + "AWS_ACCESS_KEY_ID": "probod", + "AWS_SECRET_ACCESS_KEY": "thisisnotasecret", + "AWS_ENDPOINT": "http://127.0.0.1:8333", + + // Mailer. + "MAILER_SENDER_NAME": "Probo Test", + "MAILER_SENDER_EMAIL": "no-reply@test.getprobo.com", + "MAILER_INTERVAL": "1", + + // LLM. + "OPENAI_API_KEY": "thisisnotasecret", + + // Custom domains. + "CUSTOM_DOMAINS_CNAME_TARGET": "custom.test.getprobo.com", + "ACME_DIRECTORY": "https://localhost:14000/dir", + "ACME_EMAIL": "admin@test.getprobo.com", + } + + builder := bootstrap.NewBuilder(func(key string) string { + if v, ok := env[key]; ok { + return v + } + return os.Getenv(key) }) - if err := os.MkdirAll("testdata", 0755); err != nil { - return fmt.Errorf("cannot create testdata directory: %w", err) + cfg, err := builder.Build() + if err != nil { + return "", fmt.Errorf("build config: %w", err) } - if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("cannot write key file: %w", err) + tmpDir, err := os.MkdirTemp("", "probo-e2e-") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + path := filepath.Join(tmpDir, "probod.yml") + + if err := bootstrap.WriteConfig(cfg, path); err != nil { + return "", fmt.Errorf("write config: %w", err) } - return nil + return path, nil } diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 8326bcd7e..c47b18ea3 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -26,9 +26,10 @@ import ( type EnvGetter func(key string) string type Builder struct { - getEnv EnvGetter - samlCertificate string - samlPrivateKey string + getEnv EnvGetter + samlCertificate string + samlPrivateKey string + oauth2SigningKey string } func NewBuilder(getEnv EnvGetter) *Builder { @@ -48,6 +49,8 @@ func (b *Builder) Build() (*probod.FullConfig, error) { return nil, fmt.Errorf("cannot get SAML credentials: %w", err) } + oauth2SigningKey := b.getOAuth2SigningKey() + pgCACertBundle := b.getPgCACertBundle() cfg := &probod.FullConfig{ @@ -120,6 +123,17 @@ func (b *Builder) Build() (*probod.FullConfig, error) { ClientSecret: b.getEnv("AUTH_MICROSOFT_CLIENT_SECRET"), Enabled: b.getEnv("AUTH_MICROSOFT_CLIENT_ID") != "" && b.getEnv("AUTH_MICROSOFT_CLIENT_SECRET") != "", }, + OAuth2Server: probod.OAuth2ServerConfig{ + SigningKeys: []probod.OAuth2SigningKeyConfig{{ + PrivateKey: oauth2SigningKey, + KID: b.getEnvOrDefault("OAUTH2_SERVER_SIGNING_KEY_KID", "default"), + Active: true, + }}, + AccessTokenDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_ACCESS_TOKEN_DURATION", 3600), + RefreshTokenDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_REFRESH_TOKEN_DURATION", 2592000), + AuthorizationCodeDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION", 600), + DeviceCodeDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_DEVICE_CODE_DURATION", 600), + }, }, TrustCenter: probod.TrustCenterConfig{ HTTPAddr: b.getEnvOrDefault("TRUST_CENTER_HTTP_ADDR", ":80"), @@ -323,6 +337,10 @@ func (b *Builder) validateRequired() error { } } + if b.oauth2SigningKey == "" && b.getEnv("OAUTH2_SERVER_SIGNING_KEY") == "" { + missing = append(missing, "OAUTH2_SERVER_SIGNING_KEY") + } + if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" { slackRequired := []string{ "CONNECTOR_SLACK_CLIENT_SECRET", @@ -388,6 +406,13 @@ func (b *Builder) getSAMLCredentials() (cert, key string, err error) { return cert, key, nil } +func (b *Builder) getOAuth2SigningKey() string { + if b.oauth2SigningKey != "" { + return b.oauth2SigningKey + } + return b.getEnv("OAUTH2_SERVER_SIGNING_KEY") +} + func (b *Builder) getPgCACertBundle() string { if path := b.getEnv("PG_CA_BUNDLE_PATH"); path != "" { data, err := os.ReadFile(path) diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 6492e8ad7..12fe7733f 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -32,9 +32,10 @@ func mockEnv(env map[string]string) EnvGetter { func requiredEnv() map[string]string { return map[string]string{ - "PROBOD_ENCRYPTION_KEY": "test-encryption-key-32-bytes-long", - "AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!", - "AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo", + "PROBOD_ENCRYPTION_KEY": "test-encryption-key-32-bytes-long", + "AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!", + "AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo", + "OAUTH2_SERVER_SIGNING_KEY": "test-oauth2-signing-key", } } @@ -47,7 +48,16 @@ func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) { { name: "all missing", env: map[string]string{}, - wantMissing: []string{"PROBOD_ENCRYPTION_KEY", "AUTH_COOKIE_SECRET", "AUTH_PASSWORD_PEPPER"}, + wantMissing: []string{"PROBOD_ENCRYPTION_KEY", "AUTH_COOKIE_SECRET", "AUTH_PASSWORD_PEPPER", "OAUTH2_SERVER_SIGNING_KEY"}, + }, + { + name: "missing oauth2 signing key", + env: map[string]string{ + "PROBOD_ENCRYPTION_KEY": "key", + "AUTH_COOKIE_SECRET": "secret", + "AUTH_PASSWORD_PEPPER": "pepper", + }, + wantMissing: []string{"OAUTH2_SERVER_SIGNING_KEY"}, }, { name: "missing encryption key", @@ -415,6 +425,64 @@ func TestBuilder_Build_SAMLPreset(t *testing.T) { assert.Equal(t, "preset-key", cfg.Probod.Auth.SAML.PrivateKey) } +func TestBuilder_Build_OAuth2Defaults(t *testing.T) { + b := NewBuilder(mockEnv(requiredEnv())) + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) + sk := cfg.Probod.Auth.OAuth2Server.SigningKeys[0] + assert.Equal(t, "test-oauth2-signing-key", sk.PrivateKey) + assert.Equal(t, "default", sk.KID) + assert.True(t, sk.Active) + + assert.Equal(t, 3600, cfg.Probod.Auth.OAuth2Server.AccessTokenDuration) + assert.Equal(t, 2592000, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) + assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) + assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) +} + +func TestBuilder_Build_OAuth2FromEnv(t *testing.T) { + env := requiredEnv() + env["OAUTH2_SERVER_SIGNING_KEY"] = "env-signing-key" + env["OAUTH2_SERVER_SIGNING_KEY_KID"] = "env-kid" + env["OAUTH2_SERVER_ACCESS_TOKEN_DURATION"] = "10" + env["OAUTH2_SERVER_REFRESH_TOKEN_DURATION"] = "20" + env["OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION"] = "30" + env["OAUTH2_SERVER_DEVICE_CODE_DURATION"] = "40" + + b := NewBuilder(mockEnv(env)) + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) + sk := cfg.Probod.Auth.OAuth2Server.SigningKeys[0] + assert.Equal(t, "env-signing-key", sk.PrivateKey) + assert.Equal(t, "env-kid", sk.KID) + assert.True(t, sk.Active) + + assert.Equal(t, 10, cfg.Probod.Auth.OAuth2Server.AccessTokenDuration) + assert.Equal(t, 20, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) + assert.Equal(t, 30, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) + assert.Equal(t, 40, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) +} + +func TestBuilder_Build_OAuth2Preset(t *testing.T) { + env := requiredEnv() + delete(env, "OAUTH2_SERVER_SIGNING_KEY") + + b := NewBuilder(mockEnv(env)) + b.oauth2SigningKey = "preset-signing-key" + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) + assert.Equal(t, "preset-signing-key", cfg.Probod.Auth.OAuth2Server.SigningKeys[0].PrivateKey) +} + func TestBuilder_Build_PgCABundleFromEnv(t *testing.T) { env := requiredEnv() env["PG_CA_BUNDLE"] = "test-ca-bundle-content" diff --git a/pkg/bootstrap/oauth2.go b/pkg/bootstrap/oauth2.go new file mode 100644 index 000000000..799957b92 --- /dev/null +++ b/pkg/bootstrap/oauth2.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" +) + +const oauth2SigningKeyBits = 2048 + +// GenerateOAuth2SigningKey returns a freshly generated 2048-bit RSA +// private key PKCS#1-encoded as PEM. +func GenerateOAuth2SigningKey() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, oauth2SigningKeyBits) + if err != nil { + return "", fmt.Errorf("generate RSA key: %w", err) + } + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return string(keyPEM), nil +} diff --git a/pkg/bootstrap/oauth2_test.go b/pkg/bootstrap/oauth2_test.go new file mode 100644 index 000000000..9c01528f3 --- /dev/null +++ b/pkg/bootstrap/oauth2_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateOAuth2SigningKey(t *testing.T) { + t.Parallel() + + key, err := GenerateOAuth2SigningKey() + require.NoError(t, err) + + block, _ := pem.Decode([]byte(key)) + require.NotNil(t, block, "private key should be valid PEM") + assert.Equal(t, "RSA PRIVATE KEY", block.Type) + + parsed, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + assert.Equal(t, oauth2SigningKeyBits, parsed.N.BitLen()) +} + +func TestGenerateOAuth2SigningKey_Unique(t *testing.T) { + t.Parallel() + + key1, err := GenerateOAuth2SigningKey() + require.NoError(t, err) + + key2, err := GenerateOAuth2SigningKey() + require.NoError(t, err) + + assert.NotEqual(t, key1, key2) +} diff --git a/pkg/probod/auth_config.go b/pkg/probod/auth_config.go index c0e9b4807..71b9fd0e2 100644 --- a/pkg/probod/auth_config.go +++ b/pkg/probod/auth_config.go @@ -41,9 +41,9 @@ type OAuth2ServerConfig struct { } type OAuth2SigningKeyConfig struct { - KeyFile string `json:"key-file"` - KID string `json:"kid"` - Active bool `json:"active"` + PrivateKey string `json:"private-key"` + KID string `json:"kid"` + Active bool `json:"active"` } type CookieConfig struct { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d2d7203e7..31af1f518 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -25,7 +25,6 @@ import ( "fmt" "net" "net/http" - "os" "strings" "sync" "time" @@ -389,12 +388,7 @@ func (impl *Implm) Run( var oauth2SigningKeys oauth2server.SigningKeys var hasActive bool for _, keyCfg := range impl.cfg.Auth.OAuth2Server.SigningKeys { - keyPEM, err := os.ReadFile(keyCfg.KeyFile) - if err != nil { - return fmt.Errorf("cannot read OAuth2 server signing key file: %w", err) - } - - signer, err := pemutil.DecodePrivateKey(keyPEM) + signer, err := pemutil.DecodePrivateKey([]byte(keyCfg.PrivateKey)) if err != nil { return fmt.Errorf("cannot decode OAuth2 server signing key: %w", err) }