Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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