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>