223 lines
8.3 KiB
Go
223 lines
8.3 KiB
Go
|
|
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())
|
||
|
|
}
|
||
|
|
}
|