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
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:
parent
e1e292919a
commit
ac8ed65203
14 changed files with 298 additions and 12 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
50
src/internal/server/oidc/optional_mfa_test.go
Normal file
50
src/internal/server/oidc/optional_mfa_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue