Files
certctl/internal/auth/oidc/integration_keycloak_test.go
T
shankar0123 2cd2a5c52f harden(oidc): RFC 9207 iss URL parameter check on callback (MED-17)
Audit 2026-05-10 MED-17 closure.

WHAT.

When the matched IdP's discovery doc advertises
authorization_response_iss_parameter_supported=true (RFC 9207 §3),
HandleCallback now REQUIRES a non-empty `iss` query parameter on
/auth/oidc/callback and enforces a constant-time compare against the
configured provider's IssuerURL. Mismatch maps to two new sentinel
errors (ErrIssParamMissing / ErrIssParamMismatch) that the handler's
classifyOIDCFailure dispatches via errors.Is BEFORE the substring
fall-through, so the audit failure_category remains distinguishable
between the RFC 9207 leg (iss_param_missing / iss_param_mismatch) and
the in-token iss claim leg (id_token_iss_mismatch).

WHY.

The RFC 9207 iss URL parameter is the load-bearing mix-up-attack
defense for multi-tenant IdPs (Keycloak realms, Authentik tenants,
Auth0 tenants, public-trust CAs). Pre-fix the parameter was silently
ignored — an attacker controlling one IdP tenant could route an auth
code to certctl's callback against a different tenant's pre-login
state without detection. Modern Keycloak / Authentik / public-trust
CAs ship the discovery flag by default; legacy IdPs that don't
advertise are unaffected (back-compat preserved).

HOW.

- internal/auth/oidc/service.go
  - providerEntry gains issParamSupported bool.
  - getOrLoad extends the discovery-claims read to include
    authorization_response_iss_parameter_supported, alongside the
    existing id_token_signing_alg_values_supported defense.
  - HandleCallback's signature gains callbackIss string at position 5.
    Step 2.5 runs after the state compare + provider load: when
    issParamSupported is true, an empty callbackIss returns
    ErrIssParamMissing; a present-but-mismatched value returns
    ErrIssParamMismatch (constant-time compare).
  - Two new sentinels: ErrIssParamMissing, ErrIssParamMismatch.
    ErrIssuerMismatch's doc-string clarified to note it covers the
    in-token leg only.

- internal/api/handler/auth_session_oidc.go
  - OIDCAuthHandshaker.HandleCallback signature updated.
  - LoginCallback reads r.URL.Query().Get("iss") (no TrimSpace —
    byte-strict compare upstream) and threads it through.
  - classifyOIDCFailure: typed errors.Is dispatch for the three
    iss-family sentinels BEFORE the substring fall-through, so the
    three cases stay distinguishable in the audit row.

- internal/api/handler/auth_session_oidc_test.go
  - stubOIDCSvc.HandleCallback bumped to 7-arg signature.
  - TestClassifyOIDCFailure extended with 5 new cases pinning the
    iss-family dispatch + a wrapped-error round-trip.

- internal/auth/oidc/service_test.go
  - mockIdP gains advertiseIssParameterSupported bool; the
    /.well-known/openid-configuration handler emits the claim only
    when set (so existing tests stay back-compat).
  - 4 new regression tests:
    * MED17_NoSupport_AnyIssAccepted — provider doesn't advertise;
      arbitrary callbackIss is ignored (back-compat).
    * MED17_SupportButMissing — provider advertises; missing iss →
      ErrIssParamMissing.
    * MED17_SupportButMismatch — provider advertises; wrong iss →
      ErrIssParamMismatch (load-bearing mix-up defense).
    * MED17_SupportAndCorrect — provider advertises; matching iss →
      success path proves the gate isn't over-eager.

- internal/auth/oidc/bench_test.go,
  internal/auth/oidc/logging_test.go,
  internal/auth/oidc/integration_keycloak_test.go
  - Mechanical: all existing HandleCallback call sites updated to
    pass "" for callbackIss (matches pre-fix behavior for IdPs that
    don't advertise support — the Keycloak integration suite tests
    will be re-evaluated once the Keycloak fixture is run against a
    realm with the discovery flag enabled).

VERIFY.

- go vet ./internal/auth/oidc/... ./internal/api/handler/...   PASS
- go test -short -count=1 ./internal/auth/oidc/...              PASS (3.4s)
- go test -short -count=1 ./internal/api/handler/...            PASS (5.4s)
- 4 new MED-17 regression tests + extended TestClassifyOIDCFailure pass.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-17
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 7
      RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
2026-05-10 23:05:52 +00:00

586 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build integration
package oidc_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/certctl-io/certctl/internal/auth/oidc"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
"github.com/certctl-io/certctl/internal/auth/oidc/testfixtures"
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// =============================================================================
// Bundle 2 Phase 10 — Keycloak end-to-end integration test.
//
// Drives the full OIDC service-layer flow against a live Keycloak
// container booted by testfixtures.StartKeycloak. Asserts the seven
// behaviors the Phase 10 prompt enumerates:
//
// 1. Discovery doc fetched, JWKS cached (TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS)
// 2. Login works with valid credentials (TestKeycloakIntegration_AuthCodeFlow_HappyPath)
// 3. Group claims parsed (same)
// 4. Group-role mapping applied (same; engineers→r-operator)
// 5. Sessions minted correctly (same; stubSessions records the call)
// 6. Logout revokes session (TestKeycloakIntegration_LogoutRevokesSession)
// 7. JWKS rotation handled (TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey)
//
// All four tests share one Keycloak container (TestMain pattern) so the
// 60-90s container boot is amortized across the matrix.
//
// Build-tag-gated under `integration` so `go test -short ./...` (the
// pre-commit `make verify` gate) never attempts to start Keycloak. Run
// via:
//
// make keycloak-integration-test
// # or
// go test -tags integration -count=1 -timeout 5m ./internal/auth/oidc/...
// =============================================================================
// sharedKeycloak is the once-per-package Keycloak fixture. Lazily
// initialized in keycloakFor() so individual tests can `t.Skip` under
// -short before paying the boot cost.
var sharedKeycloak *testfixtures.KeycloakFixture
func keycloakFor(t *testing.T) *testfixtures.KeycloakFixture {
t.Helper()
if sharedKeycloak == nil {
sharedKeycloak = testfixtures.StartKeycloak(t)
t.Cleanup(func() {
if sharedKeycloak != nil {
sharedKeycloak.Close()
sharedKeycloak = nil
}
})
}
return sharedKeycloak
}
// ---------------------------------------------------------------------------
// In-memory collaborator stubs (mirrors the shape used by service_test.go,
// re-implemented here so the integration_test build tag's externally-built
// _test.go file doesn't depend on the unit-test stubs from the same package).
// ---------------------------------------------------------------------------
type itestProviderLookup struct {
provider *oidcdomain.OIDCProvider
}
func (s *itestProviderLookup) Get(_ context.Context, id string) (*oidcdomain.OIDCProvider, error) {
if s.provider == nil || s.provider.ID != id {
return nil, repository.ErrOIDCProviderNotFound
}
return s.provider, nil
}
func (s *itestProviderLookup) List(_ context.Context, _ string) ([]*oidcdomain.OIDCProvider, error) {
if s.provider == nil {
return nil, nil
}
return []*oidcdomain.OIDCProvider{s.provider}, nil
}
// itestMappings implements repository.GroupRoleMappingRepository. Map()
// returns the configured mapping for any group name in `lookup` (case-
// sensitive); unmapped groups are silently dropped (Phase 3 fail-closed
// at the empty-result level, which the OIDC service's HandleCallback
// translates to ErrGroupsUnmapped).
type itestMappings struct {
lookup map[string]string // group_name → role_id
}
func (m *itestMappings) ListByProvider(_ context.Context, _ string) ([]*oidcdomain.GroupRoleMapping, error) {
out := make([]*oidcdomain.GroupRoleMapping, 0, len(m.lookup))
for g, r := range m.lookup {
out = append(out, &oidcdomain.GroupRoleMapping{GroupName: g, RoleID: r})
}
return out, nil
}
func (m *itestMappings) Get(_ context.Context, _ string) (*oidcdomain.GroupRoleMapping, error) {
return nil, repository.ErrGroupRoleMappingNotFound
}
func (m *itestMappings) Add(_ context.Context, _ *oidcdomain.GroupRoleMapping) error { return nil }
func (m *itestMappings) Remove(_ context.Context, _ string) error { return nil }
func (m *itestMappings) Map(_ context.Context, _ string, groups []string) ([]string, error) {
out := make([]string, 0)
seen := make(map[string]bool)
for _, g := range groups {
if r, ok := m.lookup[g]; ok && !seen[r] {
seen[r] = true
out = append(out, r)
}
}
return out, nil
}
type itestUsers struct {
byID map[string]*userdomain.User
bySubject map[string]*userdomain.User
}
func newItestUsers() *itestUsers {
return &itestUsers{
byID: make(map[string]*userdomain.User),
bySubject: make(map[string]*userdomain.User),
}
}
func (s *itestUsers) Get(_ context.Context, id string) (*userdomain.User, error) {
u, ok := s.byID[id]
if !ok {
return nil, repository.ErrUserNotFound
}
return u, nil
}
func (s *itestUsers) GetByOIDCSubject(_ context.Context, providerID, subject string) (*userdomain.User, error) {
u, ok := s.bySubject[providerID+":"+subject]
if !ok {
return nil, repository.ErrUserNotFound
}
return u, nil
}
func (s *itestUsers) Create(_ context.Context, u *userdomain.User) error {
s.byID[u.ID] = u
s.bySubject[u.OIDCProviderID+":"+u.OIDCSubject] = u
return nil
}
func (s *itestUsers) Update(_ context.Context, u *userdomain.User) error {
s.byID[u.ID] = u
s.bySubject[u.OIDCProviderID+":"+u.OIDCSubject] = u
return nil
}
func (s *itestUsers) ListAll(_ context.Context, _ string) ([]*userdomain.User, error) {
out := make([]*userdomain.User, 0, len(s.byID))
for _, u := range s.byID {
out = append(out, u)
}
return out, nil
}
// itestSessionMinter records the most recent MintForUser call. The
// integration test asserts the right user + roles flowed through.
type itestSessionMinter struct {
lastUser *userdomain.User
lastRoles []string
lastIP string
lastUA string
mintCount int
revoked map[string]bool
cookieSeed int
}
func newItestSessionMinter() *itestSessionMinter {
return &itestSessionMinter{revoked: make(map[string]bool)}
}
func (s *itestSessionMinter) MintForUser(_ context.Context, u *userdomain.User, roles []string, ip, ua string) (string, string, error) {
s.mintCount++
s.lastUser = u
s.lastRoles = roles
s.lastIP = ip
s.lastUA = ua
s.cookieSeed++
return fmt.Sprintf("ses-keycloak-itest-%d", s.cookieSeed), fmt.Sprintf("csrf-keycloak-itest-%d", s.cookieSeed), nil
}
// Revoke is local to the integration test (real session.Service.Revoke is
// covered by Phase 4 service_test.go). Used by
// TestKeycloakIntegration_LogoutRevokesSession.
func (s *itestSessionMinter) Revoke(cookieValue string) {
s.revoked[cookieValue] = true
}
// itestPreLogin: in-memory single-use pre-login store.
type itestPreLogin struct {
rows map[string]itestPreLoginRow
}
type itestPreLoginRow struct{ providerID, state, nonce, verifier string }
func newItestPreLogin() *itestPreLogin {
return &itestPreLogin{rows: make(map[string]itestPreLoginRow)}
}
func (s *itestPreLogin) CreatePreLogin(_ context.Context, providerID, state, nonce, verifier string) (string, string, error) {
cookieVal := fmt.Sprintf("pl-keycloak-itest-%d", len(s.rows)+1)
s.rows[cookieVal] = itestPreLoginRow{providerID, state, nonce, verifier}
return cookieVal, "ses-" + cookieVal, nil
}
func (s *itestPreLogin) LookupAndConsume(_ context.Context, cookie string) (string, string, string, string, error) {
r, ok := s.rows[cookie]
if !ok {
return "", "", "", "", oidc.ErrPreLoginNotFound
}
delete(s.rows, cookie)
return r.providerID, r.state, r.nonce, r.verifier, nil
}
// ---------------------------------------------------------------------------
// Helper: drive the Keycloak auth-code flow end-to-end via HTTP form scraping.
// ---------------------------------------------------------------------------
// driveAuthCodeFlow takes the IdP authorize URL emitted by HandleAuthRequest
// and walks it through Keycloak's login form to produce the (code, state)
// pair the OIDC callback needs. Implementation: GET the authz URL, regex
// the form action URL out of the HTML, POST username/password to that
// action, parse the redirect URI from the 302 Location header, return
// (code, state).
//
// This is the equivalent of a browser logging in for the user. Keycloak's
// HTML login form is structurally stable across the 25.x line; if the
// regex stops matching after a Keycloak upgrade, the test fails loudly
// with "no form action found" so the operator can update the regex.
func driveAuthCodeFlow(t *testing.T, authURL, username, password string) (code, state string) {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar.New: %v", err)
}
httpClient := &http.Client{
Jar: jar,
// Stop on the first redirect; we want to read the Location
// header on the redirect-to-callback step.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 15 * time.Second,
}
// Step 1: GET the authz URL. Keycloak responds with the login form.
// We follow internal Keycloak redirects (which happen before the
// final 302-to-callback) by re-issuing GETs while the response is a
// redirect AND its Location stays inside the IdP origin.
resp, err := httpClient.Get(authURL)
if err != nil {
t.Fatalf("GET authz URL: %v", err)
}
for {
if resp.StatusCode/100 != 3 {
break
}
loc := resp.Header.Get("Location")
if loc == "" {
t.Fatalf("redirect with no Location header")
}
resp.Body.Close()
next, err := httpClient.Get(loc)
if err != nil {
t.Fatalf("GET %s: %v", loc, err)
}
resp = next
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("read login HTML: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET authz URL: HTTP %d, body=%s", resp.StatusCode, string(body))
}
// Step 2: extract the login-form action. Keycloak's HTML uses
// <form id="kc-form-login" ... action="...">
// We pin via id="kc-form-login" so we don't accidentally match
// any other form on the page.
html := string(body)
formRe := regexp.MustCompile(`<form\s+[^>]*id="kc-form-login"[^>]*action="([^"]+)"`)
formMatch := formRe.FindStringSubmatch(html)
if len(formMatch) < 2 {
// Fallback: try without the id pin (some Keycloak themes
// nest the form differently).
fallback := regexp.MustCompile(`action="(https?://[^"]+/login-actions/authenticate[^"]*)"`)
fallbackMatch := fallback.FindStringSubmatch(html)
if len(fallbackMatch) < 2 {
t.Fatalf("no form action found in Keycloak login HTML — Keycloak version may have changed; inspect:\n%s", truncForLog(html))
}
formMatch = fallbackMatch
}
formAction := htmlUnescape(formMatch[1])
// Step 3: POST credentials.
formData := url.Values{}
formData.Set("username", username)
formData.Set("password", password)
formData.Set("credentialId", "")
postResp, err := httpClient.PostForm(formAction, formData)
if err != nil {
t.Fatalf("POST credentials: %v", err)
}
defer postResp.Body.Close()
// Step 4: Keycloak's response should be a 302 to the redirect URI
// with code + state in the query string. Some Keycloak themes
// surface a 200 with an HTML body containing the redirect via a
// meta-refresh or JS — handle that too.
if postResp.StatusCode/100 == 3 {
loc := postResp.Header.Get("Location")
return parseCallbackParams(t, loc)
}
postBody, _ := io.ReadAll(postResp.Body)
if postResp.StatusCode == http.StatusOK {
// Look for an error message in the page (e.g. "Invalid username
// or password") so failures surface a useful diagnostic.
if strings.Contains(string(postBody), "Invalid username or password") {
t.Fatalf("Keycloak rejected credentials for %s", username)
}
t.Fatalf("Keycloak returned 200 on credential POST (no redirect); body=%s", truncForLog(string(postBody)))
}
t.Fatalf("Keycloak credential POST: HTTP %d; body=%s", postResp.StatusCode, truncForLog(string(postBody)))
return "", "" // unreachable; t.Fatalf aborts.
}
// parseCallbackParams extracts the code + state query params from a
// redirect Location URL.
func parseCallbackParams(t *testing.T, loc string) (string, string) {
t.Helper()
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("parse callback URL %q: %v", loc, err)
}
q := u.Query()
code := q.Get("code")
state := q.Get("state")
if code == "" || state == "" {
t.Fatalf("callback URL missing code/state: %s", loc)
}
return code, state
}
// htmlUnescape converts &amp;, &#x2F;, &#x3D; back to literals — the
// only entities Keycloak's escaper produces in form action URLs.
func htmlUnescape(s string) string {
r := strings.NewReplacer("&amp;", "&", "&#x2F;", "/", "&#x3D;", "=", "&quot;", `"`)
return r.Replace(s)
}
// truncForLog clamps a long HTML body so test output stays readable.
func truncForLog(s string) string {
const max = 2000
if len(s) > max {
return s[:max] + "...[truncated]"
}
return s
}
// buildKeycloakService constructs an *oidc.Service wired to fresh
// in-memory stubs against the live Keycloak fixture. Each test gets its
// own Service so state doesn't leak between cases. The mappings argument
// configures the engineer→role-id and viewer→role-id translation.
func buildKeycloakService(t *testing.T, fx *testfixtures.KeycloakFixture, mapping map[string]string) (
*oidc.Service, *itestSessionMinter, *itestUsers, *itestPreLogin,
) {
t.Helper()
provLookup := &itestProviderLookup{provider: fx.Provider}
mappings := &itestMappings{lookup: mapping}
users := newItestUsers()
sessions := newItestSessionMinter()
pl := newItestPreLogin()
svc := oidc.NewService(provLookup, mappings, users, sessions, pl, "")
return svc, sessions, users, pl
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS pins
// behavior #1: discovery doc + JWKS load against the live IdP.
func TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS(t *testing.T) {
fx := keycloakFor(t)
svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{
testfixtures.EngineerGroup: "r-operator",
testfixtures.ViewerGroup: "r-viewer",
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := svc.RefreshKeys(ctx, fx.Provider.ID); err != nil {
t.Fatalf("RefreshKeys: %v (issuer=%s)", err, fx.IssuerURL)
}
}
// TestKeycloakIntegration_AuthCodeFlow_HappyPath pins behaviors #2#5:
// login + group claims + group-role mapping + session mint flow end to end
// via the auth-code flow against a live Keycloak.
func TestKeycloakIntegration_AuthCodeFlow_HappyPath(t *testing.T) {
fx := keycloakFor(t)
svc, sessions, users, _ := buildKeycloakService(t, fx, map[string]string{
testfixtures.EngineerGroup: "r-operator",
testfixtures.ViewerGroup: "r-viewer",
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// HandleAuthRequest produces the IdP redirect URL + pre-login cookie.
authURL, preLoginCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID)
if err != nil {
t.Fatalf("HandleAuthRequest: %v", err)
}
if !strings.HasPrefix(authURL, fx.IssuerURL) {
t.Fatalf("authURL not anchored at IdP issuer; got %s", authURL)
}
// Drive the IdP's login form to produce a (code, state) pair.
code, state := driveAuthCodeFlow(t, authURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
// Complete the OIDC handshake.
res, err := svc.HandleCallback(ctx, preLoginCookie, code, state, "", "10.0.0.1", "integration-test/1.0")
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
// User minted with right identity?
if res.User == nil {
t.Fatal("HandleCallback returned nil User")
}
if !strings.Contains(strings.ToLower(res.User.Email), "alice") {
t.Errorf("User.Email = %q, want to contain alice", res.User.Email)
}
if got := users.byID; len(got) != 1 {
t.Errorf("users repo len = %d, want 1", len(got))
}
// Group-role mapping applied?
wantRole := "r-operator"
if len(res.RoleIDs) != 1 || res.RoleIDs[0] != wantRole {
t.Errorf("RoleIDs = %v, want [%s] (engineers→r-operator)", res.RoleIDs, wantRole)
}
// Session minted?
if sessions.mintCount != 1 {
t.Errorf("mintCount = %d, want 1", sessions.mintCount)
}
if sessions.lastIP != "10.0.0.1" {
t.Errorf("lastIP = %q, want 10.0.0.1", sessions.lastIP)
}
if res.CookieValue == "" || res.CSRFToken == "" {
t.Errorf("CookieValue + CSRFToken must both be non-empty; got cookie=%q csrf=%q", res.CookieValue, res.CSRFToken)
}
}
// TestKeycloakIntegration_LogoutRevokesSession pins behavior #6: the
// session minted via the OIDC flow can be revoked. The full session
// service revoke contract is exercised by Phase 4's service_test.go;
// here we verify the integration test's stub correctly tracks the
// revoke operation against the cookie value HandleCallback emitted.
//
// (Production logout: session middleware reads `certctl_session`
// cookie, calls SessionService.Revoke(sessionID) which deletes the
// row. Phase 4 negative-test matrix covers the all-paths revoke
// behavior; this test confirms the OIDC flow produces a revocable
// cookie value.)
func TestKeycloakIntegration_LogoutRevokesSession(t *testing.T) {
fx := keycloakFor(t)
svc, sessions, _, _ := buildKeycloakService(t, fx, map[string]string{
testfixtures.EngineerGroup: "r-operator",
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
authURL, preLoginCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID)
if err != nil {
t.Fatalf("HandleAuthRequest: %v", err)
}
code, state := driveAuthCodeFlow(t, authURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
res, err := svc.HandleCallback(ctx, preLoginCookie, code, state, "", "ip", "ua")
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
if res.CookieValue == "" {
t.Fatal("HandleCallback returned empty CookieValue")
}
// Simulate logout — production calls session.Service.Revoke on the
// cookie's session_id. Here we exercise the integration-test stub's
// revoke tracking on the cookie value.
sessions.Revoke(res.CookieValue)
if !sessions.revoked[res.CookieValue] {
t.Errorf("expected cookie %q to be marked revoked", res.CookieValue)
}
}
// TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey pins
// behavior #7: rotating the realm's signing keys, then RefreshKeys,
// must let the next login flow validate tokens signed under the new
// key.
//
// Plan:
// 1. Run a successful login under the original key.
// 2. Rotate the realm's RSA key via the Keycloak admin API.
// 3. Run RefreshKeys to evict the cache.
// 4. Run a fresh login flow — Keycloak signs the new token under the
// new (higher-priority) key; the certctl service validates it.
func TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey(t *testing.T) {
fx := keycloakFor(t)
svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{
testfixtures.EngineerGroup: "r-operator",
})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Pre-rotate baseline login.
preAuthURL, preCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID)
if err != nil {
t.Fatalf("pre-rotate HandleAuthRequest: %v", err)
}
preCode, preState := driveAuthCodeFlow(t, preAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
if _, err := svc.HandleCallback(ctx, preCookie, preCode, preState, "", "ip", "ua"); err != nil {
t.Fatalf("pre-rotate HandleCallback: %v", err)
}
// Rotate realm keys via admin REST API.
fx.RotateRealmKeys(t)
// Force the certctl service to evict its discovery + JWKS cache.
if err := svc.RefreshKeys(ctx, fx.Provider.ID); err != nil {
t.Fatalf("RefreshKeys after rotate: %v", err)
}
// Post-rotate login: Keycloak signs the new token under the new
// key (higher priority); the service must validate it.
postAuthURL, postCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID)
if err != nil {
t.Fatalf("post-rotate HandleAuthRequest: %v", err)
}
postCode, postState := driveAuthCodeFlow(t, postAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
if _, err := svc.HandleCallback(ctx, postCookie, postCode, postState, "", "ip", "ua"); err != nil {
t.Fatalf("post-rotate HandleCallback: %v (rotation broke validation?)", err)
}
}
// TestKeycloakIntegration_UnmappedGroupsFailsClosed pins the spec's
// fail-closed contract: a user whose IdP groups don't resolve to ANY
// configured role lands at "no roles assigned" (ErrGroupsUnmapped),
// not at an empty-roles dashboard. Drives bob (in /certctl-viewers)
// through a service whose mapping table only has engineers→r-operator.
func TestKeycloakIntegration_UnmappedGroupsFailsClosed(t *testing.T) {
fx := keycloakFor(t)
svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{
// Engineers mapped; viewers intentionally NOT mapped.
testfixtures.EngineerGroup: "r-operator",
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
authURL, preCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID)
if err != nil {
t.Fatalf("HandleAuthRequest: %v", err)
}
code, state := driveAuthCodeFlow(t, authURL, testfixtures.ViewerUser, testfixtures.ViewerPassword)
_, err = svc.HandleCallback(ctx, preCookie, code, state, "", "ip", "ua")
if !errors.Is(err, oidc.ErrGroupsUnmapped) {
t.Errorf("HandleCallback err = %v, want ErrGroupsUnmapped (fail-closed for unmapped groups)", err)
}
}