diff --git a/main.py b/main.py index bd40f53..79b106f 100644 --- a/main.py +++ b/main.py @@ -191,7 +191,7 @@ async def get_index(): WebAuthn Registration Demo - + + + +
+ +
+

👋 Welcome!

+ +
+ +

Your Passkeys

+
+

Loading credentials...

+
+ + + + +
+ + + +

📱 Add Device

+
+ + + + +
+
+ + + + + diff --git a/static/profile.js b/static/profile.js new file mode 100644 index 0000000..6c41638 --- /dev/null +++ b/static/profile.js @@ -0,0 +1,115 @@ +// Profile page specific functionality + +document.addEventListener('DOMContentLoaded', function() { + // Initialize the app + initializeApp(); + + // Setup dialog event handlers + setupDialogHandlers(); +}); + +// Setup dialog event handlers +function setupDialogHandlers() { + // Close dialog when clicking outside + const dialog = document.getElementById('deviceLinkDialog'); + if (dialog) { + dialog.addEventListener('click', function(e) { + if (e.target === this) { + closeDeviceLinkDialog(); + } + }); + } + + // Close dialog when pressing Escape key + document.addEventListener('keydown', function(e) { + const dialog = document.getElementById('deviceLinkDialog'); + if (e.key === 'Escape' && dialog && dialog.open) { + closeDeviceLinkDialog(); + } + }); +} + +// Open device link dialog +function openDeviceLinkDialog() { + const dialog = document.getElementById('deviceLinkDialog'); + const container = document.querySelector('.container'); + const body = document.body; + + if (dialog && container && body) { + // Add blur and disable effects + container.classList.add('dialog-open'); + body.classList.add('dialog-open'); + + dialog.showModal(); + generateDeviceLink(); + } +} + +// Close device link dialog +function closeDeviceLinkDialog() { + const dialog = document.getElementById('deviceLinkDialog'); + const container = document.querySelector('.container'); + const body = document.body; + + if (dialog && container && body) { + // Remove blur and disable effects + container.classList.remove('dialog-open'); + body.classList.remove('dialog-open'); + + dialog.close(); + } +} + +// Generate device link function +function generateDeviceLink() { + clearStatus('deviceAdditionStatus'); + showStatus('deviceAdditionStatus', 'Generating device link...', 'info'); + + fetch('/api/create-device-link', { + method: 'POST', + credentials: 'include' + }) + .then(response => response.json()) + .then(result => { + if (result.error) throw new Error(result.error); + + // Update UI with the link + const deviceLinkText = document.getElementById('deviceLinkText'); + const deviceToken = document.getElementById('deviceToken'); + + if (deviceLinkText) { + deviceLinkText.textContent = result.addition_link; + } + + if (deviceToken) { + deviceToken.textContent = result.token; + } + + // Store link globally for copy function + window.currentDeviceLink = result.addition_link; + + // Generate QR code + const qrCodeEl = document.getElementById('qrCode'); + if (qrCodeEl && typeof QRCode !== 'undefined') { + qrCodeEl.innerHTML = ''; + new QRCode(qrCodeEl, { + text: result.addition_link, + width: 200, + height: 200, + colorDark: '#000000', + colorLight: '#ffffff', + correctLevel: QRCode.CorrectLevel.M + }); + } + + showStatus('deviceAdditionStatus', 'Device link generated successfully!', 'success'); + }) + .catch(error => { + console.error('Error generating device link:', error); + showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error'); + }); +} + +// Make functions available globally for onclick handlers +window.openDeviceLinkDialog = openDeviceLinkDialog; +window.closeDeviceLinkDialog = closeDeviceLinkDialog; diff --git a/static/register.html b/static/register.html new file mode 100644 index 0000000..a4f4b26 --- /dev/null +++ b/static/register.html @@ -0,0 +1,29 @@ + + + + Register - Passkey Authentication + + + + + +
+ +
+

🔐 Create Account

+
+
+ + +
+ +
+
+ + + + + + diff --git a/static/register.js b/static/register.js new file mode 100644 index 0000000..153db24 --- /dev/null +++ b/static/register.js @@ -0,0 +1,35 @@ +// Register page specific functionality + +document.addEventListener('DOMContentLoaded', function() { + // Initialize the app + initializeApp(); + + // Registration form handler + const regForm = document.getElementById('registrationForm'); + if (regForm) { + const regSubmitBtn = regForm.querySelector('button[type="submit"]'); + + regForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + regSubmitBtn.disabled = true; + clearStatus('registerStatus'); + + const user_name = (new FormData(regForm)).get('username'); + + try { + showStatus('registerStatus', 'Starting registration...', 'info'); + await register(user_name); + showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success'); + + // Auto-login after successful registration + setTimeout(() => { + window.location.href = '/auth/profile'; + }, 1500); + } catch (err) { + showStatus('registerStatus', `Registration failed: ${err.message}`, 'error'); + } finally { + regSubmitBtn.disabled = false; + } + }); + } +}); diff --git a/static/reset.html b/static/reset.html index cc92f8c..20107de 100644 --- a/static/reset.html +++ b/static/reset.html @@ -3,7 +3,7 @@ Add Device - Passkey Authentication - + diff --git a/static/simplewebauthn-browser.min.js b/static/simplewebauthn-browser.min.js new file mode 100644 index 0000000..beafd42 --- /dev/null +++ b/static/simplewebauthn-browser.min.js @@ -0,0 +1,2 @@ +/* [@simplewebauthn/browser@13.1.2] */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;ee};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}})); diff --git a/static/util.js b/static/util.js new file mode 100644 index 0000000..bc6da95 --- /dev/null +++ b/static/util.js @@ -0,0 +1,103 @@ +// Shared utility functions for all views + +// Initialize the app based on current page +function initializeApp() { + checkExistingSession(); +} + +// Show status message +function showStatus(elementId, message, type = 'info') { + const statusEl = document.getElementById(elementId); + if (statusEl) { + statusEl.innerHTML = `
${message}
`; + } +} + +// Clear status message +function clearStatus(elementId) { + const statusEl = document.getElementById(elementId); + if (statusEl) { + statusEl.innerHTML = ''; + } +} + +// Check if user is already logged in on page load +async function checkExistingSession() { + const isLoggedIn = await validateStoredToken(); + const path = window.location.pathname; + + // Protected routes that require authentication + const protectedRoutes = ['/auth/profile']; + + if (isLoggedIn) { + // User is logged in + if (path === '/auth/login' || path === '/auth/register' || path === '/') { + // Redirect to profile if accessing login/register pages while logged in + window.location.href = '/auth/profile'; + } else if (path === '/auth/add-device') { + // Redirect old add-device route to profile + window.location.href = '/auth/profile'; + } else if (protectedRoutes.includes(path)) { + // Stay on current protected page and load user data + if (path === '/auth/profile') { + loadUserInfo().then(() => { + updateUserInfo(); + loadCredentials(); + }).catch(error => { + showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error'); + }); + } + } + } else { + // User is not logged in + if (protectedRoutes.includes(path) || path === '/auth/add-device') { + // Redirect to login if accessing protected pages without authentication + window.location.href = '/auth/login'; + } + } +} + +// Validate stored token +async function validateStoredToken() { + try { + const response = await fetch('/api/validate-token', { + method: 'GET', + credentials: 'include' + }); + + const result = await response.json(); + return result.status === 'success'; + } catch (error) { + return false; + } +} + +// Copy device link to clipboard +async function copyDeviceLink() { + try { + if (window.currentDeviceLink) { + await navigator.clipboard.writeText(window.currentDeviceLink); + + const copyButton = document.querySelector('.copy-button'); + if (copyButton) { + const originalText = copyButton.textContent; + copyButton.textContent = 'Copied!'; + copyButton.style.background = '#28a745'; + + setTimeout(() => { + copyButton.textContent = originalText; + copyButton.style.background = '#28a745'; + }, 2000); + } + } + } catch (error) { + console.error('Failed to copy link:', error); + const linkText = document.getElementById('deviceLinkText'); + if (linkText) { + const range = document.createRange(); + range.selectNode(linkText); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + } + } +}