summaryrefslogtreecommitdiff
path: root/web/templates/login.html
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
commit0a1001eb0bd2d1f7c0624ae1ef8ae7ccdb3447d4 (patch)
treecaf04d4f505bb12751579e2f0f1730ead7a9e2e2 /web/templates/login.html
parent1eab4d59454fa5999675d51b99e77ac6580aba95 (diff)
Add passkey (WebAuthn) authentication support
Enable passwordless login via passkeys as an alternative to password auth. Users register passkeys from Settings; the login page offers both options. WebAuthn is optional — only active when WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN env vars are set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/templates/login.html')
-rw-r--r--web/templates/login.html90
1 files changed, 90 insertions, 0 deletions
diff --git a/web/templates/login.html b/web/templates/login.html
index bda6364..7d40a6b 100644
--- a/web/templates/login.html
+++ b/web/templates/login.html
@@ -58,6 +58,96 @@
Sign In
</button>
</form>
+
+ {{if .WebAuthnEnabled}}
+ <div id="passkey-section" class="mt-6 pt-6 border-t border-white/20" style="display: none;">
+ <button
+ id="passkey-login-btn"
+ type="button"
+ class="w-full bg-white/10 hover:bg-white/20 text-white font-medium py-3 px-4 rounded-lg transition-colors border border-white/20">
+ Sign in with Passkey
+ </button>
+ <p id="passkey-error" class="mt-2 text-red-300 text-sm" style="display: none;"></p>
+ </div>
+ <script>
+ (function() {
+ if (!window.PublicKeyCredential) return;
+ document.getElementById('passkey-section').style.display = 'block';
+
+ document.getElementById('passkey-login-btn').addEventListener('click', async function() {
+ const btn = this;
+ const errEl = document.getElementById('passkey-error');
+ errEl.style.display = 'none';
+ btn.disabled = true;
+ btn.textContent = 'Waiting for passkey...';
+
+ try {
+ const csrfToken = document.querySelector('input[name="csrf_token"]').value;
+ const beginResp = await fetch('/passkeys/login/begin', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': csrfToken}
+ });
+ if (!beginResp.ok) throw new Error('Failed to start login');
+ const options = await beginResp.json();
+
+ options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
+ if (options.publicKey.allowCredentials) {
+ options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
+ ...c, id: base64urlToBuffer(c.id)
+ }));
+ }
+
+ const assertion = await navigator.credentials.get(options);
+
+ const body = JSON.stringify({
+ id: assertion.id,
+ rawId: bufferToBase64url(assertion.rawId),
+ type: assertion.type,
+ response: {
+ authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
+ clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
+ signature: bufferToBase64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : ''
+ }
+ });
+
+ const finishResp = await fetch('/passkeys/login/finish', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
+ body: body
+ });
+ const result = await finishResp.json();
+ if (!finishResp.ok) throw new Error(result.error || 'Login failed');
+
+ window.location.href = result.redirect || '/';
+ } catch (e) {
+ if (e.name === 'NotAllowedError') {
+ errEl.textContent = 'Passkey request was cancelled.';
+ } else {
+ errEl.textContent = e.message || 'Passkey login failed.';
+ }
+ errEl.style.display = 'block';
+ btn.disabled = false;
+ btn.textContent = 'Sign in with Passkey';
+ }
+ });
+
+ function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+ })();
+ </script>
+ {{end}}
</div>
</div>
</body>