Support opt-in MFA per browser client with authoritative enrollment checks
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 00:27:28 +02:00
parent e1e292919a
commit ac8ed65203
14 changed files with 298 additions and 12 deletions

View file

@ -375,7 +375,11 @@ func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState
providerRequired := false
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
var err error
providerRequired, err = h.MFA.CheckMFARequired(ctx, username)
if client != nil && client.MFAOptional {
providerRequired, err = h.MFA.HasEnrolledFactor(ctx, username)
} else {
providerRequired, err = h.MFA.CheckMFARequired(ctx, username)
}
if err != nil {
return domain.AssuranceDecision{}, err
}

View file

@ -0,0 +1,50 @@
package oidc_test
import (
"errors"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOptionalMFAFollowsEnrollment(t *testing.T) {
for _, tc := range []struct {
name string
enrolled bool
lookupErr error
acr []string
want int
challenge bool
}{
{name: "unenrolled despite global requirement", want: http.StatusFound},
{name: "enrolled", enrolled: true, want: http.StatusOK, challenge: true},
{name: "lookup unavailable", lookupErr: errors.New("provider unavailable"), want: http.StatusInternalServerError},
{name: "explicit step up", acr: []string{"aal2"}, lookupErr: errors.New("must not query enrollment"), want: http.StatusOK, challenge: true},
} {
t.Run(tc.name, func(t *testing.T) {
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: map[string]*domain.Client{"test-client": {ClientID: "test-client", MFAOptional: true}},
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
MFA: &mockMFAProvider{required: true, requiredErr: errors.New("global policy must not be queried"), enrolled: tc.enrolled, enrolledErr: tc.lookupErr},
Sessions: sessions, Emitter: &captureEmitter{},
}
h.PendingStates().Store("optional", &oidc.PendingState{ClientID: "test-client", RedirectURI: "https://app.example/callback", State: "optional", ACRValues: tc.acr, ExpiresAt: time.Now().Add(time.Minute)})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=optional", nil))
if rec.Code != tc.want {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "KeyCape MFA") != tc.challenge {
t.Fatal("unexpected MFA challenge state")
}
if tc.want != http.StatusFound && strings.Contains(rec.Header().Get("Location"), "code=") {
t.Fatal("issued authorization code before MFA")
}
})
}
}