this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 4 let username = $state('') 5 let password = $state('') 6 let rememberDevice = $state(false) 7 let submitting = $state(false) 8 let error = $state<string | null>(null) 9 let hasPasskeys = $state(false) 10 let hasTotp = $state(false) 11 let checkingSecurityStatus = $state(false) 12 let securityStatusChecked = $state(false) 13 let passkeySupported = $state(false) 14 let clientName = $state<string | null>(null) 15 16 $effect(() => { 17 passkeySupported = window.PublicKeyCredential !== undefined 18 }) 19 20 function getRequestUri(): string | null { 21 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 22 return params.get('request_uri') 23 } 24 25 function getErrorFromUrl(): string | null { 26 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 27 return params.get('error') 28 } 29 30 $effect(() => { 31 const urlError = getErrorFromUrl() 32 if (urlError) { 33 error = urlError 34 } 35 }) 36 37 $effect(() => { 38 fetchAuthRequestInfo() 39 }) 40 41 async function fetchAuthRequestInfo() { 42 const requestUri = getRequestUri() 43 if (!requestUri) return 44 45 try { 46 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 47 headers: { 'Accept': 'application/json' } 48 }) 49 if (response.ok) { 50 const data = await response.json() 51 if (data.login_hint && !username) { 52 username = data.login_hint 53 } 54 if (data.client_name) { 55 clientName = data.client_name 56 } 57 } 58 } catch { 59 // Ignore errors fetching auth info 60 } 61 } 62 63 let checkTimeout: ReturnType<typeof setTimeout> | null = null 64 65 $effect(() => { 66 if (checkTimeout) { 67 clearTimeout(checkTimeout) 68 } 69 hasPasskeys = false 70 hasTotp = false 71 securityStatusChecked = false 72 if (username.length >= 3) { 73 checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500) 74 } 75 }) 76 77 async function checkUserSecurityStatus() { 78 if (!username || checkingSecurityStatus) return 79 checkingSecurityStatus = true 80 try { 81 const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`) 82 if (response.ok) { 83 const data = await response.json() 84 hasPasskeys = passkeySupported && data.hasPasskeys === true 85 hasTotp = data.hasTotp === true 86 securityStatusChecked = true 87 } 88 } catch { 89 hasPasskeys = false 90 hasTotp = false 91 } finally { 92 checkingSecurityStatus = false 93 } 94 } 95 96 97 async function handlePasskeyLogin() { 98 const requestUri = getRequestUri() 99 if (!requestUri || !username) { 100 error = 'Missing required parameters' 101 return 102 } 103 104 submitting = true 105 error = null 106 107 try { 108 const startResponse = await fetch('/oauth/passkey/start', { 109 method: 'POST', 110 headers: { 111 'Content-Type': 'application/json', 112 'Accept': 'application/json' 113 }, 114 body: JSON.stringify({ 115 request_uri: requestUri, 116 identifier: username 117 }) 118 }) 119 120 if (!startResponse.ok) { 121 const data = await startResponse.json() 122 error = data.error_description || data.error || 'Failed to start passkey login' 123 submitting = false 124 return 125 } 126 127 const { options } = await startResponse.json() 128 129 const credential = await navigator.credentials.get({ 130 publicKey: prepareCredentialRequestOptions(options.publicKey) 131 }) as PublicKeyCredential | null 132 133 if (!credential) { 134 error = 'Passkey authentication was cancelled' 135 submitting = false 136 return 137 } 138 139 const assertionResponse = credential.response as AuthenticatorAssertionResponse 140 const credentialData = { 141 id: credential.id, 142 type: credential.type, 143 rawId: arrayBufferToBase64Url(credential.rawId), 144 response: { 145 clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), 146 authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), 147 signature: arrayBufferToBase64Url(assertionResponse.signature), 148 userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null 149 } 150 } 151 152 const finishResponse = await fetch('/oauth/passkey/finish', { 153 method: 'POST', 154 headers: { 155 'Content-Type': 'application/json', 156 'Accept': 'application/json' 157 }, 158 body: JSON.stringify({ 159 request_uri: requestUri, 160 credential: credentialData 161 }) 162 }) 163 164 const data = await finishResponse.json() 165 166 if (!finishResponse.ok) { 167 error = data.error_description || data.error || 'Passkey authentication failed' 168 submitting = false 169 return 170 } 171 172 if (data.needs_totp) { 173 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 174 return 175 } 176 177 if (data.needs_2fa) { 178 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 179 return 180 } 181 182 if (data.redirect_uri) { 183 window.location.href = data.redirect_uri 184 return 185 } 186 187 error = 'Unexpected response from server' 188 submitting = false 189 } catch (e) { 190 console.error('Passkey login error:', e) 191 if (e instanceof DOMException && e.name === 'NotAllowedError') { 192 error = 'Passkey authentication was cancelled' 193 } else { 194 error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}` 195 } 196 submitting = false 197 } 198 } 199 200 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 201 const bytes = new Uint8Array(buffer) 202 let binary = '' 203 for (let i = 0; i < bytes.byteLength; i++) { 204 binary += String.fromCharCode(bytes[i]) 205 } 206 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 207 } 208 209 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 210 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 211 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 212 const binary = atob(padded) 213 const bytes = new Uint8Array(binary.length) 214 for (let i = 0; i < binary.length; i++) { 215 bytes[i] = binary.charCodeAt(i) 216 } 217 return bytes.buffer 218 } 219 220 function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions { 221 return { 222 ...options, 223 challenge: base64UrlToArrayBuffer(options.challenge), 224 allowCredentials: options.allowCredentials?.map((cred: any) => ({ 225 ...cred, 226 id: base64UrlToArrayBuffer(cred.id) 227 })) || [] 228 } 229 } 230 231 async function handleSubmit(e: Event) { 232 e.preventDefault() 233 const requestUri = getRequestUri() 234 if (!requestUri) { 235 error = 'Missing request_uri parameter' 236 return 237 } 238 239 submitting = true 240 error = null 241 242 try { 243 const response = await fetch('/oauth/authorize', { 244 method: 'POST', 245 headers: { 246 'Content-Type': 'application/json', 247 'Accept': 'application/json' 248 }, 249 body: JSON.stringify({ 250 request_uri: requestUri, 251 username, 252 password, 253 remember_device: rememberDevice 254 }) 255 }) 256 257 const data = await response.json() 258 259 if (!response.ok) { 260 error = data.error_description || data.error || 'Login failed' 261 submitting = false 262 return 263 } 264 265 if (data.needs_totp) { 266 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 267 return 268 } 269 270 if (data.needs_2fa) { 271 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 272 return 273 } 274 275 if (data.redirect_uri) { 276 window.location.href = data.redirect_uri 277 return 278 } 279 280 error = 'Unexpected response from server' 281 submitting = false 282 } catch { 283 error = 'Failed to connect to server' 284 submitting = false 285 } 286 } 287 288 async function handleCancel() { 289 const requestUri = getRequestUri() 290 if (!requestUri) { 291 window.history.back() 292 return 293 } 294 295 submitting = true 296 try { 297 const response = await fetch('/oauth/authorize/deny', { 298 method: 'POST', 299 headers: { 300 'Content-Type': 'application/json', 301 'Accept': 'application/json' 302 }, 303 body: JSON.stringify({ request_uri: requestUri }) 304 }) 305 306 const data = await response.json() 307 if (data.redirect_uri) { 308 window.location.href = data.redirect_uri 309 } 310 } catch { 311 window.history.back() 312 } 313 } 314</script> 315 316<div class="oauth-login-container"> 317 <h1>Sign In</h1> 318 <p class="subtitle"> 319 {#if clientName} 320 Sign in to continue to <strong>{clientName}</strong> 321 {:else} 322 Sign in to continue to the application 323 {/if} 324 </p> 325 326 {#if error} 327 <div class="error">{error}</div> 328 {/if} 329 330 <form onsubmit={handleSubmit}> 331 <div class="field"> 332 <label for="username">Handle or Email</label> 333 <input 334 id="username" 335 type="text" 336 bind:value={username} 337 placeholder="you@example.com or handle" 338 disabled={submitting} 339 required 340 autocomplete="username" 341 /> 342 </div> 343 344 {#if passkeySupported && username.length >= 3} 345 <button 346 type="button" 347 class="passkey-btn" 348 class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 349 onclick={handlePasskeyLogin} 350 disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 351 title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 > 353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 355 <path d="M17 17v4l3-2-3-2z" /> 356 <path d="M12 11c-4 0-6 2-6 4v4h9" /> 357 </svg> 358 <span class="passkey-text"> 359 {#if submitting} 360 Authenticating... 361 {:else if checkingSecurityStatus || !securityStatusChecked} 362 Checking passkey... 363 {:else if hasPasskeys} 364 Sign in with passkey 365 {:else} 366 Passkey not set up 367 {/if} 368 </span> 369 </button> 370 371 <div class="auth-divider"> 372 <span>or use password</span> 373 </div> 374 {/if} 375 376 <div class="field"> 377 <label for="password">Password</label> 378 <input 379 id="password" 380 type="password" 381 bind:value={password} 382 disabled={submitting} 383 required 384 autocomplete="current-password" 385 /> 386 </div> 387 388 <label class="remember-device"> 389 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 390 <span>Remember this device</span> 391 </label> 392 393 <div class="actions"> 394 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 395 Cancel 396 </button> 397 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 398 {submitting ? 'Signing in...' : 'Sign In'} 399 </button> 400 </div> 401 </form> 402</div> 403 404<style> 405 .oauth-login-container { 406 max-width: 400px; 407 margin: 4rem auto; 408 padding: 2rem; 409 } 410 411 h1 { 412 margin: 0 0 0.5rem 0; 413 } 414 415 .subtitle { 416 color: var(--text-secondary); 417 margin: 0 0 2rem 0; 418 } 419 420 form { 421 display: flex; 422 flex-direction: column; 423 gap: 1rem; 424 } 425 426 .field { 427 display: flex; 428 flex-direction: column; 429 gap: 0.25rem; 430 } 431 432 label { 433 font-size: 0.875rem; 434 font-weight: 500; 435 } 436 437 input[type="text"], 438 input[type="password"] { 439 padding: 0.75rem; 440 border: 1px solid var(--border-color-light); 441 border-radius: 4px; 442 font-size: 1rem; 443 background: var(--bg-input); 444 color: var(--text-primary); 445 } 446 447 input:focus { 448 outline: none; 449 border-color: var(--accent); 450 } 451 452 .remember-device { 453 display: flex; 454 align-items: center; 455 gap: 0.5rem; 456 cursor: pointer; 457 color: var(--text-secondary); 458 font-size: 0.875rem; 459 } 460 461 .remember-device input { 462 width: 16px; 463 height: 16px; 464 } 465 466 .error { 467 padding: 0.75rem; 468 background: var(--error-bg); 469 border: 1px solid var(--error-border); 470 border-radius: 4px; 471 color: var(--error-text); 472 margin-bottom: 1rem; 473 } 474 475 .actions { 476 display: flex; 477 gap: 1rem; 478 margin-top: 0.5rem; 479 } 480 481 .actions button { 482 flex: 1; 483 padding: 0.75rem; 484 border: none; 485 border-radius: 4px; 486 font-size: 1rem; 487 cursor: pointer; 488 transition: background-color 0.15s; 489 } 490 491 .actions button:disabled { 492 opacity: 0.6; 493 cursor: not-allowed; 494 } 495 496 .cancel-btn { 497 background: var(--bg-secondary); 498 color: var(--text-primary); 499 border: 1px solid var(--border-color); 500 } 501 502 .cancel-btn:hover:not(:disabled) { 503 background: var(--error-bg); 504 border-color: var(--error-border); 505 color: var(--error-text); 506 } 507 508 .submit-btn { 509 background: var(--accent); 510 color: white; 511 } 512 513 .submit-btn:hover:not(:disabled) { 514 background: var(--accent-hover); 515 } 516 517 .auth-divider { 518 display: flex; 519 align-items: center; 520 gap: 1rem; 521 margin: 0.5rem 0; 522 } 523 524 .auth-divider::before, 525 .auth-divider::after { 526 content: ''; 527 flex: 1; 528 height: 1px; 529 background: var(--border-color-light); 530 } 531 532 .auth-divider span { 533 color: var(--text-secondary); 534 font-size: 0.875rem; 535 } 536 537 .passkey-btn { 538 display: flex; 539 align-items: center; 540 justify-content: center; 541 gap: 0.5rem; 542 width: 100%; 543 padding: 0.75rem; 544 background: var(--accent); 545 color: white; 546 border: 1px solid var(--accent); 547 border-radius: 4px; 548 font-size: 1rem; 549 cursor: pointer; 550 transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; 551 } 552 553 .passkey-btn:hover:not(:disabled) { 554 background: var(--accent-hover); 555 border-color: var(--accent-hover); 556 } 557 558 .passkey-btn:disabled { 559 opacity: 0.6; 560 cursor: not-allowed; 561 } 562 563 .passkey-btn.passkey-unavailable { 564 background: var(--bg-secondary); 565 color: var(--text-secondary); 566 border-color: var(--border-color); 567 } 568 569 .passkey-icon { 570 width: 20px; 571 height: 20px; 572 } 573 574 .passkey-text { 575 flex: 1; 576 text-align: left; 577 } 578</style>