vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Offer passkeys through conditional UI and require discoverable credentials
ad79b7abded893d0771eaf80de555a1236f17474 · Keenan Nemetz · 3d ago · parent 25343521
Offer passkeys through conditional UI and require discoverable credentials Start a usernameless ceremony when the login page advertises conditional mediation, so the browser can offer a saved passkey in the account field. The assertion resolves the account from the credential id and user handle; username-first sign-in stays for browsers without autofill and for passkeys created before discoverable credentials were required. Register each new passkey with residentKey required, and record the autofill flow and fallback in the account documentation. Refs #213 (01M6SAK9STFGJ7PECY7ET8277N).
server/web/auth_handlers.go +58−14
| @@ -100,6 +100,12 @@ func (w *Web) LoginBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 100 | 100 | return | |
| 101 | 101 | } | |
| 102 | 102 | name := strings.TrimSpace(body.Username) | |
| 103 | if name == "" { | ||
| 104 | // Conditional UI and a browser without a username field start here. The | ||
| 105 | // account is resolved from the credential when the ceremony finishes. | ||
| 106 | w.beginDiscoverableLogin(rw) | ||
| 107 | return | ||
| 108 | } | ||
| 103 | 109 | // A name that could not be an account is answered with the same decoy as an | |
| 104 | 110 | // unknown account, but it must not reach the per-account limiter or the | |
| 105 | 111 | // ceremony store at whatever length the client chose. | |
| @@ -157,6 +163,22 @@ func (w *Web) beginLoginCeremony(rw http.ResponseWriter, user *waUser, name stri | |||
| 157 | 163 | } | |
| 158 | 164 | } | |
| 159 | 165 | ||
| 166 | // beginDiscoverableLogin starts the usernameless flow: the assertion carries no | ||
| 167 | // allowCredentials, and the authenticator returns the user handle with the | ||
| 168 | // assertion. The account is only known when LoginFinish resolves the credential. | ||
| 169 | func (w *Web) beginDiscoverableLogin(rw http.ResponseWriter) { | ||
| 170 | assertion, data, err := w.Auth.WA.BeginDiscoverableLogin() | ||
| 171 | if err != nil { | ||
| 172 | writeJSONError(rw, http.StatusBadRequest, "cannot begin sign-in") | ||
| 173 | return | ||
| 174 | } | ||
| 175 | if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data}, assertion); err != nil { | ||
| 176 | w.Logger("auth: cannot store a sign-in ceremony: %v", err) | ||
| 177 | writeJSONError(rw, http.StatusServiceUnavailable, | ||
| 178 | "too many sign-in attempts in progress; try again in a few minutes") | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 160 | 182 | func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) { | |
| 161 | 183 | w.noStore(rw) | |
| 162 | 184 | if !w.enabled() { | |
| @@ -174,18 +196,40 @@ func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) { | |||
| 174 | 196 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") | |
| 175 | 197 | return | |
| 176 | 198 | } | |
| 177 | acct, err := w.Ident.Find(c.Name) | ||
| 178 | if err != nil || !acct.Active() { | ||
| 179 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") | ||
| 180 | return | ||
| 181 | } | ||
| 182 | user, err := w.waUser(acct) | ||
| 183 | if err != nil { | ||
| 184 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") | ||
| 185 | return | ||
| 199 | var ( | ||
| 200 | acct *identity.Account | ||
| 201 | cred *webauthn.Credential | ||
| 202 | err error | ||
| 203 | ) | ||
| 204 | if c.Name == "" { | ||
| 205 | cred, err = w.Auth.WA.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { | ||
| 206 | _, a, err := w.Ident.FindCredential(rawID) | ||
| 207 | if err != nil { | ||
| 208 | return nil, err | ||
| 209 | } | ||
| 210 | // The credential id already names the account. The user handle comes | ||
| 211 | // from the authenticator and must agree with it; a mismatch is not a | ||
| 212 | // sign-in for either account. | ||
| 213 | if string(userHandle) != a.Name { | ||
| 214 | return nil, identity.ErrCredentialNotFound | ||
| 215 | } | ||
| 216 | acct = a | ||
| 217 | return w.waUser(a) | ||
| 218 | }, c.Data, r) | ||
| 219 | } else { | ||
| 220 | acct, err = w.Ident.Find(c.Name) | ||
| 221 | if err != nil || !acct.Active() { | ||
| 222 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") | ||
| 223 | return | ||
| 224 | } | ||
| 225 | user, uerr := w.waUser(acct) | ||
| 226 | if uerr != nil { | ||
| 227 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") | ||
| 228 | return | ||
| 229 | } | ||
| 230 | cred, err = w.Auth.WA.FinishLogin(user, c.Data, r) | ||
| 186 | 231 | } | |
| 187 | cred, err := w.Auth.WA.FinishLogin(user, c.Data, r) | ||
| 188 | if err != nil { | ||
| 232 | if err != nil || acct == nil || cred == nil { | ||
| 189 | 233 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") | |
| 190 | 234 | return | |
| 191 | 235 | } | |
| @@ -242,7 +286,7 @@ func (w *Web) InviteBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 242 | 286 | } | |
| 243 | 287 | creation, data, err := w.Auth.WA.BeginRegistration(user, | |
| 244 | 288 | webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), | |
| 245 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)) | ||
| 289 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) | ||
| 246 | 290 | if err != nil { | |
| 247 | 291 | writeJSONError(rw, http.StatusBadRequest, "cannot begin enrollment: "+err.Error()) | |
| 248 | 292 | return | |
| @@ -363,7 +407,7 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 363 | 407 | // succeeds. An abandoned registration therefore reserves no account. | |
| 364 | 408 | user := &waUser{account: &identity.Account{Name: name, Status: "active"}} | |
| 365 | 409 | creation, data, err := w.Auth.WA.BeginRegistration(user, | |
| 366 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)) | ||
| 410 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) | ||
| 367 | 411 | if err != nil { | |
| 368 | 412 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) | |
| 369 | 413 | return | |
| @@ -520,7 +564,7 @@ func (w *Web) AddPasskeyBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 520 | 564 | } | |
| 521 | 565 | creation, data, err := w.Auth.WA.BeginRegistration(user, | |
| 522 | 566 | webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), | |
| 523 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)) | ||
| 567 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) | ||
| 524 | 568 | if err != nil { | |
| 525 | 569 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) | |
| 526 | 570 | return | |