Files
certctl/internal/auth/oidc/testfixtures/keycloak.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

481 lines
18 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//go:build integration
// Package testfixtures provides Bundle 2 Phase 10 multi-IdP integration
// test harnesses. The package is compiled ONLY under the `integration`
// build tag so the heavy Keycloak (or Okta) container start never lands
// in `go test -short` or the default `go test ./...` developer loop.
//
// Run via:
//
// go test -tags integration -count=1 -timeout 5m ./internal/auth/oidc/...
// # or via the Makefile target:
// make keycloak-integration-test
//
// On a workstation without Docker, `go test -tags integration` will
// fail at container start with a clear error from testcontainers-go.
// The pre-commit `make verify` gate uses `-short` (no `integration`
// tag), so the absence of Docker on a contributor box does not block
// commits.
package testfixtures
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
)
// =============================================================================
// Bundle 2 Phase 10 — Keycloak testcontainers harness.
//
// Boots a single Keycloak container running in dev mode (`start-dev`),
// imports the canned realm at testfixtures/keycloak-realm.json, and
// returns a populated *oidcdomain.OIDCProvider plus a small typed
// helper struct the integration test uses to drive end-to-end flows.
//
// Realm contents (see keycloak-realm.json):
//
// - Realm `certctl` (enabled).
// - OIDC client `certctl` (confidential, secret pinned).
// - Two groups (`certctl-engineers`, `certctl-viewers`).
// - Two users with credentials:
// - `alice` / `alice-password-1` in /certctl-engineers
// - `bob` / `bob-password-1` in /certctl-viewers
// - Group-claim mapper emitting the user's groups under `groups`
// (id_token + access_token + userinfo).
//
// The harness pins the realm name + client id + secret + user creds as
// exported constants so the integration test can build OIDC requests
// without coupling to the JSON file's internals.
// =============================================================================
const (
// KeycloakImage is the version-pinned image. Change requires
// re-validating realm-import compatibility.
KeycloakImage = "quay.io/keycloak/keycloak:25.0"
// RealmName matches the `realm` key in keycloak-realm.json.
RealmName = "certctl"
// ClientID + ClientSecret match the `clients[0]` entry in the
// realm-import JSON. Pinned by the integration test when configuring
// the OIDC provider row that drives the certctl service.
ClientID = "certctl"
ClientSecret = "certctl-keycloak-test-secret"
// AdminUser + AdminPass are the bootstrap admin credentials Keycloak
// uses on first start under the `start-dev` command. They are NEVER
// surfaced by the harness for cert-issuance flows; only used to
// enable the admin REST API for JWKS-rotation flows.
AdminUser = "admin"
AdminPass = "admin"
// EngineerUser + EngineerPassword identify the alice fixture user
// (member of the engineers group). The integration test drives
// /token with these creds via the Resource Owner Password
// Credentials grant (which Keycloak supports OOTB and which we
// enable in the realm import — `directAccessGrantsEnabled: true`).
// In production certctl uses the auth-code-with-PKCE flow; ROPC is
// used here ONLY because driving a real browser through the IdP UI
// in CI is brittle. The token-validation path under test is the
// SAME — Keycloak issues structurally identical ID tokens for both
// flows.
EngineerUser = "alice"
EngineerPassword = "alice-password-1"
EngineerGroup = "certctl-engineers"
ViewerUser = "bob"
ViewerPassword = "bob-password-1"
ViewerGroup = "certctl-viewers"
)
// KeycloakFixture wraps the running container + the OIDC provider row
// the integration test feeds into the certctl service. Close() tears the
// container down; deferred from the test to keep the test surface tidy.
type KeycloakFixture struct {
Container testcontainers.Container
// IssuerURL is the canonical realm issuer (e.g.
// http://localhost:53219/realms/certctl). Used as
// OIDCProvider.IssuerURL.
IssuerURL string
// Provider is a fully-populated domain row mirroring what
// certctl-server would persist after a successful "Configure new
// OIDC provider" flow in the GUI. The integration test feeds it
// directly into the OIDC service's provider-lookup port without
// going through the HTTP API — Phase 10's contract is "drive the
// service end-to-end against a live IdP", not "drive the entire
// HTTP stack".
Provider *oidcdomain.OIDCProvider
// adminToken is the cached admin REST API bearer (10-min lifetime,
// re-fetched via getAdminToken when older than 9m).
adminToken string
adminTokenExp time.Time
}
// StartKeycloak boots a Keycloak container with the canned realm
// pre-imported and returns the populated fixture. The container is
// reachable at the IssuerURL on the host network; testcontainers
// allocates a random host port and maps to 8080/tcp inside.
//
// Boot is bounded at 90s — Keycloak's JVM start is the dominant cost
// (warm: ~12s; cold pull: ~60s). On a busy CI runner the wait may
// timeout, in which case the test t.Fatal's with a clear message so the
// operator can rerun.
func StartKeycloak(t *testing.T) *KeycloakFixture {
t.Helper()
if testing.Short() {
t.Skip("Phase 10 Keycloak integration: skipped under -short (heavy container start)")
}
ctx := context.Background()
realmPath, err := realmImportPath()
if err != nil {
t.Fatalf("realmImportPath: %v", err)
}
req := testcontainers.ContainerRequest{
Image: KeycloakImage,
ExposedPorts: []string{"8080/tcp"},
Env: map[string]string{
// Keycloak 26.x has TWO sets of admin-bootstrap env vars
// and the right pair depends on the launch command:
// - `start` (production): KC_BOOTSTRAP_ADMIN_USERNAME +
// KC_BOOTSTRAP_ADMIN_PASSWORD
// - `start-dev`: KEYCLOAK_ADMIN + KEYCLOAK_ADMIN_PASSWORD
//
// This fixture runs `start-dev` (see the Cmd line below).
// Pre-fix only the KC_BOOTSTRAP_ADMIN_* names were set —
// they're silently ignored in dev-mode, leaving the
// master-realm with no admin user. The auth-code flow tests
// passed (they authenticate test users in the certctl
// realm), but the RotateRealmKeys path 401's on the
// admin-cli token endpoint because there's no admin to
// authenticate as. Set BOTH pairs as belt-and-braces so a
// future flip to `start` doesn't re-introduce the same gap.
"KEYCLOAK_ADMIN": AdminUser,
"KEYCLOAK_ADMIN_PASSWORD": AdminPass,
"KC_BOOTSTRAP_ADMIN_USERNAME": AdminUser,
"KC_BOOTSTRAP_ADMIN_PASSWORD": AdminPass,
// Disable HTTPS in dev mode; the integration test runs
// over HTTP because the OIDC service-layer test injects
// the provider config directly + Keycloak's dev mode
// doesn't ship a TLS cert without --features=preview
// flags. Production deploys MUST enable TLS at the IdP
// (validated at OIDCProvider.Validate() time — issuer URL
// MUST be https in non-test paths).
"KC_HOSTNAME_STRICT": "false",
"KC_HOSTNAME_STRICT_HTTPS": "false",
"KC_HEALTH_ENABLED": "true",
"KC_HTTP_ENABLED": "true",
"KC_PROXY_HEADERS": "xforwarded",
},
Files: []testcontainers.ContainerFile{
{
HostFilePath: realmPath,
ContainerFilePath: "/opt/keycloak/data/import/realm.json",
FileMode: 0o644,
},
},
Cmd: []string{
"start-dev",
"--import-realm",
},
WaitingFor: wait.ForLog("Listening on:").WithStartupTimeout(90 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("Keycloak container start: %v", err)
}
host, err := container.Host(ctx)
if err != nil {
_ = container.Terminate(ctx)
t.Fatalf("container.Host: %v", err)
}
port, err := container.MappedPort(ctx, "8080")
if err != nil {
_ = container.Terminate(ctx)
t.Fatalf("container.MappedPort: %v", err)
}
issuerURL := fmt.Sprintf("http://%s:%s/realms/%s", host, port.Port(), RealmName)
// Wait for the realm endpoint to actually answer — the "Listening on"
// log line fires before realm import completes on cold-pull boots.
if err := waitForDiscovery(issuerURL, 60*time.Second); err != nil {
_ = container.Terminate(ctx)
t.Fatalf("waitForDiscovery: %v", err)
}
prov := &oidcdomain.OIDCProvider{
ID: "op-keycloak-itest",
TenantID: "t-default",
Name: "Keycloak (integration test)",
IssuerURL: issuerURL,
ClientID: ClientID,
// Enabled=true is required for HandleAuthRequest to reach the
// IdP discovery + redirect path. The field was added by Audit
// 2026-05-11 MED-9 (Bundle 2 Fix 13 Phase B); pre-fix providers
// had no enable-flag and HandleAuthRequest always proceeded.
// Default zero-value false would gate all integration tests
// behind ErrProviderDisabled.
Enabled: true,
// ClientSecretEncrypted intentionally left zero-length: the
// integration test invokes the service with encryptionKey="",
// which the Phase-3 service treats as plaintext-passthrough.
// Production MUST set CERTCTL_CONFIG_ENCRYPTION_KEY (validated
// at server boot) — the integration test exercises the wire +
// validation paths, not the encryption-at-rest path (that's
// covered by the Phase-2 repository tests).
ClientSecretEncrypted: []byte(ClientSecret),
RedirectURI: "http://localhost:8443/auth/oidc/callback",
GroupsClaimPath: "groups",
GroupsClaimFormat: oidcdomain.GroupsClaimFormatStringArray,
FetchUserinfo: false,
Scopes: []string{"openid", "profile", "email"},
IATWindowSeconds: 300,
JWKSCacheTTLSeconds: 3600,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
return &KeycloakFixture{
Container: container,
IssuerURL: issuerURL,
Provider: prov,
}
}
// Close terminates the container. Idempotent — calling twice is safe.
func (f *KeycloakFixture) Close() {
if f == nil || f.Container == nil {
return
}
_ = f.Container.Terminate(context.Background())
f.Container = nil
}
// AdminBaseURL returns the Keycloak admin REST API base for this realm.
// The integration test uses it to drive JWKS-key rotation (the only
// admin op the harness exposes; everything else flows through the
// public OIDC endpoints).
func (f *KeycloakFixture) AdminBaseURL() string {
// The realm-management API lives under /admin/realms/{realm}.
// IssuerURL is .../realms/{realm}; chop the realms-prefix and
// re-append /admin/realms/{realm}.
idx := strings.LastIndex(f.IssuerURL, "/realms/")
if idx < 0 {
return ""
}
return f.IssuerURL[:idx] + "/admin/realms/" + RealmName
}
// AdminToken returns a cached admin-realm bearer token, refreshed every
// 9 minutes (Keycloak's default 10-minute admin-token lifetime). The
// integration test passes this token into Keycloak's admin REST API via
// the Authorization header.
func (f *KeycloakFixture) AdminToken(t *testing.T) string {
t.Helper()
if f.adminToken != "" && time.Now().Before(f.adminTokenExp) {
return f.adminToken
}
// The admin-cli client lives under the master realm.
masterTokenURL := strings.Replace(f.IssuerURL, "/realms/"+RealmName, "/realms/master/protocol/openid-connect/token", 1)
form := url.Values{}
form.Set("grant_type", "password")
form.Set("client_id", "admin-cli")
form.Set("username", AdminUser)
form.Set("password", AdminPass)
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
}
resp, err := httpClient.PostForm(masterTokenURL, form)
if err != nil {
t.Fatalf("admin-cli token: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin-cli token: HTTP %d", resp.StatusCode)
}
var body struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("admin-cli token decode: %v", err)
}
if body.AccessToken == "" {
t.Fatalf("admin-cli token: empty access_token")
}
f.adminToken = body.AccessToken
// Refresh 1 minute before actual expiry so a long-running test
// doesn't trip on a token-just-expired edge.
f.adminTokenExp = time.Now().Add(time.Duration(body.ExpiresIn-60) * time.Second)
return f.adminToken
}
// FetchTokensROPC fetches an ID token + access token via the Resource
// Owner Password Credentials grant. Used by the integration test to
// drive the service-layer token-validation path against a real
// Keycloak-issued ID token without scripting a browser through the
// IdP login UI. The certctl service runs the SAME validation pipeline
// regardless of the grant type that produced the tokens — alg pin,
// iss, aud, azp, at_hash, exp, iat, nonce, JWKS — so the IdP-side
// shape is what's under test.
//
// Note: production certctl uses auth-code-with-PKCE; ROPC is enabled in
// keycloak-realm.json's `directAccessGrantsEnabled: true` for this
// fixture and ONLY this fixture.
func (f *KeycloakFixture) FetchTokensROPC(t *testing.T, username, password string) (idToken, accessToken string) {
t.Helper()
tokenURL := f.IssuerURL + "/protocol/openid-connect/token"
form := url.Values{}
form.Set("grant_type", "password")
form.Set("client_id", ClientID)
form.Set("client_secret", ClientSecret)
form.Set("username", username)
form.Set("password", password)
form.Set("scope", "openid profile email")
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.PostForm(tokenURL, form)
if err != nil {
t.Fatalf("ROPC token: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("ROPC token: HTTP %d", resp.StatusCode)
}
var body struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("ROPC token decode: %v", err)
}
if body.IDToken == "" || body.AccessToken == "" {
t.Fatalf("ROPC token: missing id_token / access_token")
}
return body.IDToken, body.AccessToken
}
// RotateRealmKeys drops + re-adds the active RSA key under the realm,
// forcing every subsequent token to be signed under a new kid. The
// integration test uses this to verify the certctl service's JWKS
// cache + downgrade-attack defense pick up the new key after a
// RefreshKeys() call.
//
// Implementation: Keycloak exposes /admin/realms/{realm}/keys for read,
// and /admin/realms/{realm}/components for rotate. The simplest
// reliable shape is to add a brand-new RSA-2048 key component (which
// becomes active because of the higher priority we set), leaving the
// old one as fallback. Any token signed under the new key must be
// validated against the JWKS doc fetched after the rotation; tokens
// signed under the old key must STILL validate (Keycloak keeps the
// old key as inactive-but-trusted until manually deleted).
func (f *KeycloakFixture) RotateRealmKeys(t *testing.T) {
t.Helper()
token := f.AdminToken(t)
body := map[string]any{
"name": fmt.Sprintf("rotated-%d", time.Now().UnixNano()),
"providerId": "rsa-generated",
"providerType": "org.keycloak.keys.KeyProvider",
"config": map[string][]string{
"priority": {"200"},
"enabled": {"true"},
"active": {"true"},
"algorithm": {"RS256"},
"keySize": {"2048"},
},
}
payload, _ := json.Marshal(body)
// Realm name on the path is the master endpoint slug; resolve it
// via the realm's own admin URL, not the master realm's. The
// rotated key is added to the certctl realm.
realmAdminURL := f.AdminBaseURL() + "/components"
req, err := http.NewRequest(http.MethodPost, realmAdminURL, strings.NewReader(string(payload)))
if err != nil {
t.Fatalf("rotate keys: build request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.Do(req)
if err != nil {
t.Fatalf("rotate keys: HTTP: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
t.Fatalf("rotate keys: HTTP %d", resp.StatusCode)
}
}
// realmImportPath resolves the absolute path to keycloak-realm.json
// next to this source file. Used to mount the realm-import volume into
// the container.
func realmImportPath() (string, error) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("runtime.Caller failed")
}
dir := filepath.Dir(filename)
candidate := filepath.Join(dir, "keycloak-realm.json")
return candidate, nil
}
// waitForDiscovery polls the OIDC discovery doc until it returns 200 OR
// the deadline elapses. Keycloak's "Listening on" log line fires before
// the realm-import completes on cold-pull boots, so we layer this poll
// on top of the WaitForLog primitive.
func waitForDiscovery(issuerURL string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
httpClient := &http.Client{Timeout: 2 * time.Second}
for {
resp, err := httpClient.Get(issuerURL + "/.well-known/openid-configuration")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("discovery doc never returned 200 within %s", timeout)
}
time.Sleep(500 * time.Millisecond)
}
}