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()) } }