this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 4 let loading = $state(false) 5 let error = $state<string | null>(null) 6 let autoStarted = $state(false) 7 8 function getRequestUri(): string | null { 9 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 return params.get('request_uri') 11 } 12 13 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 14 const bytes = new Uint8Array(buffer) 15 let binary = '' 16 for (let i = 0; i < bytes.byteLength; i++) { 17 binary += String.fromCharCode(bytes[i]) 18 } 19 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 20 } 21 22 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 23 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 24 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 25 const binary = atob(padded) 26 const bytes = new Uint8Array(binary.length) 27 for (let i = 0; i < binary.length; i++) { 28 bytes[i] = binary.charCodeAt(i) 29 } 30 return bytes.buffer 31 } 32 33 function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions { 34 return { 35 ...options.publicKey, 36 challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 37 allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ 38 ...cred, 39 id: base64UrlToArrayBuffer(cred.id) 40 })) || [] 41 } 42 } 43 44 async function startPasskeyAuth() { 45 const requestUri = getRequestUri() 46 if (!requestUri) { 47 error = 'Missing request_uri parameter' 48 return 49 } 50 51 if (!window.PublicKeyCredential) { 52 error = 'Passkeys are not supported in this browser' 53 return 54 } 55 56 loading = true 57 error = null 58 59 try { 60 const startResponse = await fetch(`/oauth/authorize/passkey?request_uri=${encodeURIComponent(requestUri)}`, { 61 method: 'GET', 62 headers: { 63 'Accept': 'application/json' 64 } 65 }) 66 67 if (!startResponse.ok) { 68 const data = await startResponse.json() 69 error = data.error_description || data.error || 'Failed to start passkey authentication' 70 loading = false 71 return 72 } 73 74 const { options } = await startResponse.json() 75 const publicKeyOptions = prepareAuthOptions(options) 76 77 const credential = await navigator.credentials.get({ 78 publicKey: publicKeyOptions 79 }) 80 81 if (!credential) { 82 error = 'Passkey authentication was cancelled' 83 loading = false 84 return 85 } 86 87 const pkCredential = credential as PublicKeyCredential 88 const response = pkCredential.response as AuthenticatorAssertionResponse 89 const credentialResponse = { 90 id: pkCredential.id, 91 type: pkCredential.type, 92 rawId: arrayBufferToBase64Url(pkCredential.rawId), 93 response: { 94 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 95 authenticatorData: arrayBufferToBase64Url(response.authenticatorData), 96 signature: arrayBufferToBase64Url(response.signature), 97 userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null, 98 }, 99 } 100 101 const finishResponse = await fetch('/oauth/authorize/passkey', { 102 method: 'POST', 103 headers: { 104 'Content-Type': 'application/json', 105 'Accept': 'application/json' 106 }, 107 body: JSON.stringify({ 108 request_uri: requestUri, 109 credential: credentialResponse 110 }) 111 }) 112 113 const finishData = await finishResponse.json() 114 115 if (!finishResponse.ok) { 116 error = finishData.error_description || finishData.error || 'Passkey verification failed' 117 loading = false 118 return 119 } 120 121 if (finishData.redirect_uri) { 122 window.location.href = finishData.redirect_uri 123 return 124 } 125 126 error = 'Unexpected response from server' 127 loading = false 128 } catch (e) { 129 if (e instanceof DOMException && e.name === 'NotAllowedError') { 130 error = 'Passkey authentication was cancelled' 131 } else { 132 error = 'Failed to authenticate with passkey' 133 } 134 loading = false 135 } 136 } 137 138 function handleCancel() { 139 const requestUri = getRequestUri() 140 if (requestUri) { 141 navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`) 142 } else { 143 window.history.back() 144 } 145 } 146 147 $effect(() => { 148 if (!autoStarted) { 149 autoStarted = true 150 startPasskeyAuth() 151 } 152 }) 153</script> 154 155<div class="oauth-passkey-container"> 156 <h1>Sign In with Passkey</h1> 157 <p class="subtitle"> 158 Your account uses a passkey for authentication. Use your fingerprint, face, or security key to sign in. 159 </p> 160 161 {#if error} 162 <div class="error">{error}</div> 163 {/if} 164 165 <div class="passkey-status"> 166 {#if loading} 167 <div class="loading-indicator"> 168 <div class="spinner"></div> 169 <p>Waiting for passkey...</p> 170 </div> 171 {:else} 172 <button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}> 173 Use Passkey 174 </button> 175 {/if} 176 </div> 177 178 <div class="actions"> 179 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}> 180 Cancel 181 </button> 182 </div> 183 184 <p class="help-text"> 185 If you've lost access to your passkey, you can recover your account using email. 186 </p> 187</div> 188 189<style> 190 .oauth-passkey-container { 191 max-width: 400px; 192 margin: 4rem auto; 193 padding: 2rem; 194 text-align: center; 195 } 196 197 h1 { 198 margin: 0 0 0.5rem 0; 199 } 200 201 .subtitle { 202 color: var(--text-secondary); 203 margin: 0 0 2rem 0; 204 } 205 206 .error { 207 padding: 0.75rem; 208 background: var(--error-bg); 209 border: 1px solid var(--error-border); 210 border-radius: 4px; 211 color: var(--error-text); 212 margin-bottom: 1.5rem; 213 text-align: left; 214 } 215 216 .passkey-status { 217 padding: 2rem; 218 background: var(--bg-secondary); 219 border-radius: 8px; 220 margin-bottom: 1.5rem; 221 } 222 223 .loading-indicator { 224 display: flex; 225 flex-direction: column; 226 align-items: center; 227 gap: 1rem; 228 } 229 230 .spinner { 231 width: 40px; 232 height: 40px; 233 border: 3px solid var(--border-color); 234 border-top-color: var(--accent); 235 border-radius: 50%; 236 animation: spin 1s linear infinite; 237 } 238 239 @keyframes spin { 240 to { 241 transform: rotate(360deg); 242 } 243 } 244 245 .loading-indicator p { 246 margin: 0; 247 color: var(--text-secondary); 248 } 249 250 .passkey-btn { 251 width: 100%; 252 padding: 1rem; 253 background: var(--accent); 254 color: white; 255 border: none; 256 border-radius: 4px; 257 font-size: 1rem; 258 cursor: pointer; 259 transition: background-color 0.15s; 260 } 261 262 .passkey-btn:hover:not(:disabled) { 263 background: var(--accent-hover); 264 } 265 266 .passkey-btn:disabled { 267 opacity: 0.6; 268 cursor: not-allowed; 269 } 270 271 .actions { 272 display: flex; 273 justify-content: center; 274 margin-bottom: 1.5rem; 275 } 276 277 .cancel-btn { 278 padding: 0.75rem 2rem; 279 background: var(--bg-secondary); 280 color: var(--text-primary); 281 border: 1px solid var(--border-color); 282 border-radius: 4px; 283 font-size: 1rem; 284 cursor: pointer; 285 transition: background-color 0.15s; 286 } 287 288 .cancel-btn:hover:not(:disabled) { 289 background: var(--error-bg); 290 border-color: var(--error-border); 291 color: var(--error-text); 292 } 293 294 .cancel-btn:disabled { 295 opacity: 0.6; 296 cursor: not-allowed; 297 } 298 299 .help-text { 300 font-size: 0.875rem; 301 color: var(--text-muted); 302 margin: 0; 303 } 304</style>