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