vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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/static/auth.js +21−4
| @@ -15,15 +15,20 @@ | |||
| 15 | 15 | function resp(c){const r=c.response;const out={id:c.id,rawId:enc(c.rawId),type:c.type,clientExtensionResults:c.getClientExtensionResults()};if(r.attestationObject){out.response={clientDataJSON:enc(r.clientDataJSON),attestationObject:enc(r.attestationObject)};if(r.getTransports)out.response.transports=r.getTransports()}else{out.response={clientDataJSON:enc(r.clientDataJSON),authenticatorData:enc(r.authenticatorData),signature:enc(r.signature)};if(r.userHandle)out.response.userHandle=enc(r.userHandle)}if(c.authenticatorAttachment)out.authenticatorAttachment=c.authenticatorAttachment;return out} | |
| 16 | 16 | async function post(url,body,withCSRF){const h={'Content-Type':'application/json'};if(withCSRF)h['X-Vole-CSRF']=csrf;const r=await fetch(url,{method:'POST',headers:h,body:JSON.stringify(body),credentials:'same-origin'});let d={};try{d=await r.json()}catch(e){}if(!r.ok)throw new Error(d.error||r.statusText);return d} | |
| 17 | 17 | function fail(form,err){const e=form.querySelector('[data-auth-error]')||document.querySelector('[data-auth-error]');if(e)e.textContent=err.message||String(err);else alert(err.message||String(err))} | |
| 18 | async function signInWithPasskey(form,username,mediation){ | ||
| 19 | const b=await post('/auth/login/begin',username?{username:username}:{}); | ||
| 20 | const opts={publicKey:prepGet(b)}; | ||
| 21 | if(mediation)opts.mediation=mediation; | ||
| 22 | const c=await navigator.credentials.get(opts); | ||
| 23 | await post('/auth/login/finish',resp(c)); | ||
| 24 | location.href=form.dataset.next||'/'; | ||
| 25 | } | ||
| 18 | 26 | document.querySelectorAll('form[data-auth]').forEach(function(form){ | |
| 19 | 27 | form.addEventListener('submit',async function(ev){ | |
| 20 | 28 | ev.preventDefault();const btn=form.querySelector('button[type=submit]');if(btn)btn.disabled=true; | |
| 21 | 29 | try{ | |
| 22 | 30 | if(form.dataset.auth==='login'){ | |
| 23 | const b=await post('/auth/login/begin',{username:form.username.value.trim()}); | ||
| 24 | const c=await navigator.credentials.get({publicKey:prepGet(b)}); | ||
| 25 | await post('/auth/login/finish',resp(c)); | ||
| 26 | location.href=form.dataset.next||'/'; | ||
| 31 | await signInWithPasskey(form,form.username.value.trim()); | ||
| 27 | 32 | }else if(form.dataset.auth==='invite'){ | |
| 28 | 33 | // The token is in the fragment, which the browser never sends to the | |
| 29 | 34 | // server in a request line or a Referer. It is read here and posted. | |
| @@ -47,4 +52,16 @@ | |||
| 47 | 52 | }catch(err){fail(form,err);if(btn)btn.disabled=false} | |
| 48 | 53 | }) | |
| 49 | 54 | }) | |
| 55 | |||
| 56 | // Conditional UI puts a saved passkey in the account field's autofill menu. | ||
| 57 | // The request starts only where the browser advertises support; the form below | ||
| 58 | // remains the fallback for browsers without it and for a non-discoverable | ||
| 59 | // passkey. A cancelled or empty autofill is not an error to show on page load. | ||
| 60 | const loginForm=document.querySelector('form[data-auth="login"]'); | ||
| 61 | if(loginForm&&window.PublicKeyCredential&&typeof PublicKeyCredential.isConditionalMediationAvailable==='function'){ | ||
| 62 | PublicKeyCredential.isConditionalMediationAvailable().then(function(ok){ | ||
| 63 | if(!ok)return; | ||
| 64 | signInWithPasskey(loginForm,'','conditional').catch(function(){}); | ||
| 65 | }).catch(function(){}); | ||
| 66 | } | ||
| 50 | 67 | })(); | |