Finish KEY-WP-0008: registration handoff and client MFA isolation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Add signed registration/enrollment handoffs, per-request assurance policy with login-session isolation, and /logout. coulomb-social stays AAL1 unless acr_values or another client raises the bar.
This commit is contained in:
parent
fff9e39478
commit
b6af6c5268
22 changed files with 1636 additions and 42 deletions
222
src/internal/server/oidc/policy_isolation_test.go
Normal file
222
src/internal/server/oidc/policy_isolation_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue