key-cape/src/internal/server/oidc/policy_isolation_test.go

223 lines
8.3 KiB
Go
Raw Normal View History

package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
)
func TestPolicy_CoulombSocialPasswordOnlyWhenNoStrongerRule(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("social", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "social",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatal("ordinary coulomb-social login must not render MFA")
}
loc, _ := url.Parse(rec.Header().Get("Location"))
if loc.Query().Get("code") == "" {
t.Fatal("expected authorization code")
}
sess, ok := h.Sessions.Get(loc.Query().Get("code"))
if !ok || sess.MFAVerified {
t.Fatalf("AAL1 login must record MFAVerified=false: %+v", sess)
}
}
func TestPolicy_ProfileActionStepUpForcesMFA(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: false, enrolled: true},
)
h.PendingStates().Store("step", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "step",
ACRValues: []string{"aal2"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=step", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatalf("expected MFA challenge, status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPolicy_OpenBaoKeepsMandatoryMFA(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=bao", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatalf("OpenBao must keep MFA, status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPolicy_LowAssuranceClientDoesNotSuppressHighAssurance(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("social", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "social",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(time.Minute),
})
first := httptest.NewRecorder()
h.ServeHTTPCallback(first, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
if first.Code != http.StatusFound {
t.Fatalf("AAL1 status=%d body=%s", first.Code, first.Body.String())
}
cookie := first.Result().Cookies()
if len(cookie) == 0 {
t.Fatal("expected login session cookie after AAL1")
}
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=y&state=bao", nil)
req.AddCookie(cookie[0])
second := httptest.NewRecorder()
h.ServeHTTPCallback(second, req)
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
t.Fatalf("AAL1 session must not satisfy OpenBao: status=%d body=%s", second.Code, second.Body.String())
}
}
func TestPolicy_NoFactorEnrollmentHandoffDoesNotBypass(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: false, enrolled: false},
)
h.PendingStates().Store("enroll", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "enroll",
ACRValues: []string{"aal2"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=enroll", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if loc.Host != "users.example.com" || loc.Path != "/enroll" {
t.Fatalf("expected enrollment handoff, got %s", loc)
}
if loc.Query().Get("code") != "" {
t.Fatal("enrollment handoff must not mint a code")
}
}
func TestPolicy_ExactRedirectStillEnforced(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://evil.example/callback"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"s"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestLogout_ClearsSessionSoHighAssuranceRequiresMFAAgain(t *testing.T) {
logins := oidc.NewLoginSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: isolationClients(),
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
MFA: &mockMFAProvider{required: true, enrolled: true},
Sessions: oidc.NewSessionStore(),
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Emitter: &captureEmitter{},
}
aal2 := logins.Create("alice", domain.AssuranceAAL2)
logout := &oidc.LogoutHandler{ClientConfig: isolationClients(), Logins: logins}
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
url.QueryEscape("https://coulomb.social/auth/callback/"), nil)
req.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
rec := httptest.NewRecorder()
logout.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := logins.Get(aal2.ID); ok {
t.Fatal("logout must delete the login session")
}
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
after := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=z&state=bao", nil)
after.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
second := httptest.NewRecorder()
h.ServeHTTPCallback(second, after)
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
t.Fatalf("after logout OpenBao must require MFA, status=%d body=%s", second.Code, second.Body.String())
}
}
func TestLogout_RejectsUnregisteredPostLogoutRedirect(t *testing.T) {
logout := &oidc.LogoutHandler{
ClientConfig: isolationClients(),
Logins: oidc.NewLoginSessionStore(),
}
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
url.QueryEscape("https://evil.example/out"), nil)
rec := httptest.NewRecorder()
logout.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}