mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 13:18:52 +00:00
2cd2a5c52f
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
586 lines
21 KiB
Go
586 lines
21 KiB
Go
//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 &, /, = back to literals — the
|
||
// only entities Keycloak's escaper produces in form action URLs.
|
||
func htmlUnescape(s string) string {
|
||
r := strings.NewReplacer("&", "&", "/", "/", "=", "=", """, `"`)
|
||
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)
|
||
}
|
||
}
|