KEY-WP-0008: honor per-client mfaRequired and acr_values step-up
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 30s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 30s
Allow coulomb-social ordinary login at AAL1 via mfaRequired: false while keeping provider requireForAll for clients without an override. Preserve explicit acr_values=aal2 for step-up.
This commit is contained in:
parent
8e976bc60f
commit
3bef507cb8
7 changed files with 168 additions and 1 deletions
|
|
@ -27,6 +27,7 @@ type PendingState struct {
|
|||
Scopes []string
|
||||
ExpiresAt time.Time
|
||||
AuthenticatedUser string
|
||||
ACRValues []string
|
||||
}
|
||||
|
||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||
|
|
@ -107,6 +108,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
nonce := q.Get("nonce")
|
||||
codeChallenge := q.Get("code_challenge")
|
||||
codeChallengeMethod := q.Get("code_challenge_method")
|
||||
acrValues := strings.Fields(q.Get("acr_values"))
|
||||
|
||||
// Emit auth_start telemetry immediately.
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
|
|
@ -195,6 +197,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
State: state,
|
||||
Nonce: nonce,
|
||||
Scopes: strings.Fields(scope),
|
||||
ACRValues: acrValues,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
|
||||
|
|
@ -280,7 +283,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
// Check MFA requirement.
|
||||
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
||||
mfaRequired, _, err := h.mfaRequirement(ps, result.Username)
|
||||
if err != nil {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
|
|
@ -312,6 +315,20 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
h.completeAuthorization(w, r, ps, result.Username, mfaRequired)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) mfaRequirement(ps *PendingState, username string) (bool, bool, error) {
|
||||
for _, acr := range ps.ACRValues {
|
||||
switch strings.ToLower(acr) {
|
||||
case "aal2", "mfa", "urn:netkingdom:aal2":
|
||||
return true, false, nil
|
||||
}
|
||||
}
|
||||
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.MFARequired != nil {
|
||||
return *client.MFARequired, false, nil
|
||||
}
|
||||
required, err := h.MFA.CheckMFARequired(context.Background(), username)
|
||||
return required, true, err
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
|
|
|
|||
|
|
@ -656,6 +656,51 @@ func TestAuthorizeCallback_MFANotRequired_SessionRecordsMFAVerifiedFalse(t *test
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCallback_ClientPolicyCanDisableEnrolledMFA(t *testing.T) {
|
||||
disabled := false
|
||||
mfa := &mockMFAProvider{required: true}
|
||||
h := &oidc.AuthorizeHandler{
|
||||
ClientConfig: map[string]*domain.Client{"test-client": {
|
||||
ClientID: "test-client", DisplayName: "Test", MFARequired: &disabled,
|
||||
}},
|
||||
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||
MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: &captureEmitter{},
|
||||
}
|
||||
h.PendingStates().Store("state-client-aal1", &oidc.PendingState{
|
||||
ClientID: "test-client", RedirectURI: "https://app.example/callback",
|
||||
State: "state-client-aal1", ExpiresAt: time.Now().Add(time.Minute),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=state-client-aal1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCallback_ACRStepUpOverridesClientAAL1(t *testing.T) {
|
||||
disabled := false
|
||||
mfa := &mockMFAProvider{required: false}
|
||||
h := &oidc.AuthorizeHandler{
|
||||
ClientConfig: map[string]*domain.Client{"test-client": {
|
||||
ClientID: "test-client", DisplayName: "Test", MFARequired: &disabled,
|
||||
}},
|
||||
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||
MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: &captureEmitter{},
|
||||
}
|
||||
h.PendingStates().Store("state-step-up", &oidc.PendingState{
|
||||
ClientID: "test-client", RedirectURI: "https://app.example/callback",
|
||||
State: "state-step-up", ACRValues: []string{"aal2"},
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=state-step-up", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(rec, req)
|
||||
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 TestAuthorizeCallback_MFASubmission_InvalidToken_AuthFailure(t *testing.T) {
|
||||
auth := &mockAuthProvider{}
|
||||
mfa := &mockMFAProvider{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue