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>