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 securityStatusChecked && passkeySupported} 345 <button 346 type="button" 347 class="passkey-btn" 348 class:passkey-unavailable={!hasPasskeys} 349 onclick={handlePasskeyLogin} 350 disabled={submitting || !hasPasskeys || !username} 351 title={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 hasPasskeys} 362 Sign in with passkey 363 {:else} 364 Passkey not set up 365 {/if} 366 </span> 367 </button> 368 369 <div class="auth-divider"> 370 <span>or use password</span> 371 </div> 372 {/if} 373 374 <div class="field"> 375 <label for="password">Password</label> 376 <input 377 id="password" 378 type="password" 379 bind:value={password} 380 disabled={submitting} 381 required 382 autocomplete="current-password" 383 /> 384 </div> 385 386 <label class="remember-device"> 387 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 388 <span>Remember this device</span> 389 </label> 390 391 <div class="actions"> 392 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 393 Cancel 394 </button> 395 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 396 {submitting ? 'Signing in...' : 'Sign In'} 397 </button> 398 </div> 399 </form> 400</div> 401 402<style> 403 .oauth-login-container { 404 max-width: 400px; 405 margin: 4rem auto; 406 padding: 2rem; 407 } 408 409 h1 { 410 margin: 0 0 0.5rem 0; 411 } 412 413 .subtitle { 414 color: var(--text-secondary); 415 margin: 0 0 2rem 0; 416 } 417 418 form { 419 display: flex; 420 flex-direction: column; 421 gap: 1rem; 422 } 423 424 .field { 425 display: flex; 426 flex-direction: column; 427 gap: 0.25rem; 428 } 429 430 label { 431 font-size: 0.875rem; 432 font-weight: 500; 433 } 434 435 input[type="text"], 436 input[type="password"] { 437 padding: 0.75rem; 438 border: 1px solid var(--border-color-light); 439 border-radius: 4px; 440 font-size: 1rem; 441 background: var(--bg-input); 442 color: var(--text-primary); 443 } 444 445 input:focus { 446 outline: none; 447 border-color: var(--accent); 448 } 449 450 .remember-device { 451 display: flex; 452 align-items: center; 453 gap: 0.5rem; 454 cursor: pointer; 455 color: var(--text-secondary); 456 font-size: 0.875rem; 457 } 458 459 .remember-device input { 460 width: 16px; 461 height: 16px; 462 } 463 464 .error { 465 padding: 0.75rem; 466 background: var(--error-bg); 467 border: 1px solid var(--error-border); 468 border-radius: 4px; 469 color: var(--error-text); 470 margin-bottom: 1rem; 471 } 472 473 .actions { 474 display: flex; 475 gap: 1rem; 476 margin-top: 0.5rem; 477 } 478 479 .actions button { 480 flex: 1; 481 padding: 0.75rem; 482 border: none; 483 border-radius: 4px; 484 font-size: 1rem; 485 cursor: pointer; 486 transition: background-color 0.15s; 487 } 488 489 .actions button:disabled { 490 opacity: 0.6; 491 cursor: not-allowed; 492 } 493 494 .cancel-btn { 495 background: var(--bg-secondary); 496 color: var(--text-primary); 497 border: 1px solid var(--border-color); 498 } 499 500 .cancel-btn:hover:not(:disabled) { 501 background: var(--error-bg); 502 border-color: var(--error-border); 503 color: var(--error-text); 504 } 505 506 .submit-btn { 507 background: var(--accent); 508 color: white; 509 } 510 511 .submit-btn:hover:not(:disabled) { 512 background: var(--accent-hover); 513 } 514 515 .auth-divider { 516 display: flex; 517 align-items: center; 518 gap: 1rem; 519 margin: 0.5rem 0; 520 } 521 522 .auth-divider::before, 523 .auth-divider::after { 524 content: ''; 525 flex: 1; 526 height: 1px; 527 background: var(--border-color-light); 528 } 529 530 .auth-divider span { 531 color: var(--text-secondary); 532 font-size: 0.875rem; 533 } 534 535 .passkey-btn { 536 display: flex; 537 align-items: center; 538 justify-content: center; 539 gap: 0.5rem; 540 width: 100%; 541 padding: 0.75rem; 542 background: var(--accent); 543 color: white; 544 border: 1px solid var(--accent); 545 border-radius: 4px; 546 font-size: 1rem; 547 cursor: pointer; 548 transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; 549 } 550 551 .passkey-btn:hover:not(:disabled) { 552 background: var(--accent-hover); 553 border-color: var(--accent-hover); 554 } 555 556 .passkey-btn:disabled { 557 opacity: 0.6; 558 cursor: not-allowed; 559 } 560 561 .passkey-btn.passkey-unavailable { 562 background: var(--bg-secondary); 563 color: var(--text-secondary); 564 border-color: var(--border-color); 565 } 566 567 .passkey-icon { 568 width: 20px; 569 height: 20px; 570 } 571 572 .passkey-text { 573 flex: 1; 574 text-align: left; 575 } 576</style>