From db7cd73a6eebdbf9c13b94474cf500411d0078bd Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 17 Mar 2026 14:04:51 -0700 Subject: [PATCH] Fix WebAuthn login: username pre-fill and policy check - webauthn.js: read #username value before calling mciasWebAuthnLogin so non-discoverable keys work when a username is typed (previously always passed empty string, forcing discoverable/resident-key flow only) - handleWebAuthnLoginFinish: evaluate auth:login policy after credential verification, mirroring the gate in handleLogin; returns 403 on deny so policy rules apply equally to both password and passkey authentication paths Security: policy is checked post-verification so 403 vs 401 distinguishes a policy restriction from a bad credential without leaking account existence. No service context is sent (WebAuthn login carries no service_name/tags), so per-service deny rules don't fire on passkey login; account-level deny rules do. Co-Authored-By: Claude Sonnet 4.6 --- internal/server/handlers_webauthn.go | 27 ++++++++++++++++++++++++++- web/static/webauthn.js | 4 +++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/server/handlers_webauthn.go b/internal/server/handlers_webauthn.go index 68697b4..00a2b26 100644 --- a/internal/server/handlers_webauthn.go +++ b/internal/server/handlers_webauthn.go @@ -28,6 +28,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/kyle/mcias/internal/token" mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn" ) @@ -618,13 +619,37 @@ func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Reques // Login succeeded: clear lockout counter. _ = s.db.ClearLoginFailures(acct.ID) - // Issue JWT. + // Load roles for policy check and expiry decision. roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } + // Policy check: evaluate auth:login rules. + // WebAuthn login has no service context (no service_name or tags in the + // request body), so per-service deny rules won't fire. Account-level deny + // rules (e.g. deny a specific role from all auth:login actions) apply. + // This mirrors the policy gate in handleLogin so both auth paths are consistent. + // + // Security: policy is checked after credential verification so that a + // policy-denied login returns 403 (not 401), distinguishing a policy + // restriction from a bad credential without leaking account existence. + if s.polEng != nil { + input := policy.PolicyInput{ + Subject: acct.UUID, + AccountType: string(acct.AccountType), + Roles: roles, + Action: policy.ActionLogin, + Resource: policy.Resource{}, + } + if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny { + s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"policy_denied"}`) + middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied") + return + } + } + expiry := s.cfg.DefaultExpiry() for _, role := range roles { if role == "admin" { diff --git a/web/static/webauthn.js b/web/static/webauthn.js index 0796a15..3305fc2 100644 --- a/web/static/webauthn.js +++ b/web/static/webauthn.js @@ -206,10 +206,12 @@ if (loginBtn) { loginBtn.addEventListener('click', function () { hideError('webauthn-login-error'); + var usernameInput = document.getElementById('username'); + var username = usernameInput ? usernameInput.value.trim() : ''; loginBtn.disabled = true; loginBtn.textContent = 'Waiting for authenticator...'; - window.mciasWebAuthnLogin('', function () { + window.mciasWebAuthnLogin(username, function () { window.location.href = '/dashboard'; }, function (err) { loginBtn.disabled = false;