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