Skip to content

Commit 8cd4799

Browse files
duncanmccleanclaudejasonvarga
authored
[6.x] Frontend Passkeys (#14453)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 41269e9 commit 8cd4799

File tree

16 files changed

+1506
-79
lines changed

16 files changed

+1506
-79
lines changed
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { startAuthentication, startRegistration, browserSupportsWebAuthn, WebAuthnAbortService } from '@simplewebauthn/browser';
2+
3+
export default class Passkeys {
4+
constructor() {
5+
this.supported = browserSupportsWebAuthn();
6+
this._waiting = false;
7+
this._error = null;
8+
this._defaults = {};
9+
}
10+
11+
configure(defaults) {
12+
this._defaults = defaults;
13+
return this;
14+
}
15+
16+
get waiting() {
17+
return this._waiting;
18+
}
19+
20+
get error() {
21+
return this._error;
22+
}
23+
24+
/**
25+
* Authenticate with a passkey.
26+
*
27+
* @param {Object} options
28+
* @param {string} options.optionsUrl - URL to fetch assertion options
29+
* @param {string} options.verifyUrl - URL to verify the assertion
30+
* @param {Function} [options.onSuccess] - Callback on success with response data
31+
* @param {Function} [options.onError] - Callback on error with error object
32+
* @param {boolean} [options.useBrowserAutofill=false] - Use browser autofill UI
33+
* @param {string} [options.csrfToken] - Override CSRF token
34+
*/
35+
async authenticate(options = {}) {
36+
const {
37+
optionsUrl,
38+
verifyUrl,
39+
onSuccess,
40+
onError,
41+
useBrowserAutofill = false,
42+
csrfToken,
43+
} = { ...this._defaults, ...options };
44+
45+
if (!useBrowserAutofill) {
46+
this._waiting = true;
47+
}
48+
this._error = null;
49+
50+
try {
51+
const authOptionsResponse = await fetch(optionsUrl, {
52+
credentials: 'same-origin',
53+
});
54+
55+
if (!authOptionsResponse.ok) {
56+
throw new Error('Failed to fetch authentication options');
57+
}
58+
59+
const optionsJSON = await authOptionsResponse.json();
60+
61+
let authResponse;
62+
try {
63+
authResponse = await startAuthentication({ optionsJSON, useBrowserAutofill });
64+
} catch (e) {
65+
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
66+
return;
67+
}
68+
console.error(e);
69+
this._error = 'Authentication failed.';
70+
if (onError) {
71+
onError({ message: this._error, originalError: e });
72+
}
73+
return;
74+
}
75+
76+
const verifyResponse = await fetch(verifyUrl, {
77+
method: 'POST',
78+
headers: {
79+
'Content-Type': 'application/json',
80+
'Accept': 'application/json',
81+
'X-CSRF-TOKEN': csrfToken || this._getCsrfToken(),
82+
},
83+
credentials: 'same-origin',
84+
body: JSON.stringify(authResponse),
85+
});
86+
87+
const data = await verifyResponse.json();
88+
89+
if (!verifyResponse.ok) {
90+
this._error = data.message || 'Verification failed.';
91+
if (onError) {
92+
onError({ message: this._error, status: verifyResponse.status });
93+
}
94+
return;
95+
}
96+
97+
if (onSuccess) {
98+
onSuccess(data);
99+
}
100+
} catch (e) {
101+
this._handleError(e, onError);
102+
} finally {
103+
if (!useBrowserAutofill) {
104+
this._waiting = false;
105+
}
106+
}
107+
}
108+
109+
/**
110+
* Register a new passkey.
111+
*
112+
* @param {Object} options
113+
* @param {string} options.optionsUrl - URL to fetch attestation options
114+
* @param {string} options.verifyUrl - URL to verify and store the passkey
115+
* @param {string} [options.name='Passkey'] - Name for the passkey
116+
* @param {Function} [options.onSuccess] - Callback on success with response data
117+
* @param {Function} [options.onError] - Callback on error with error object
118+
* @param {string} [options.csrfToken] - Override CSRF token
119+
*/
120+
async register(options = {}) {
121+
const {
122+
optionsUrl,
123+
verifyUrl,
124+
name = 'Passkey',
125+
onSuccess,
126+
onError,
127+
csrfToken,
128+
} = { ...this._defaults, ...options };
129+
130+
this._waiting = true;
131+
this._error = null;
132+
133+
try {
134+
const createOptionsResponse = await fetch(optionsUrl, {
135+
credentials: 'same-origin',
136+
});
137+
138+
if (!createOptionsResponse.ok) {
139+
throw new Error('Failed to fetch registration options');
140+
}
141+
142+
const optionsJSON = await createOptionsResponse.json();
143+
144+
let registrationResponse;
145+
try {
146+
registrationResponse = await startRegistration({ optionsJSON });
147+
} catch (e) {
148+
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
149+
return;
150+
}
151+
console.error(e);
152+
this._error = 'Registration failed.';
153+
if (onError) {
154+
onError({ message: this._error, originalError: e });
155+
}
156+
return;
157+
}
158+
159+
const verifyResponse = await fetch(verifyUrl, {
160+
method: 'POST',
161+
headers: {
162+
'Content-Type': 'application/json',
163+
'Accept': 'application/json',
164+
'X-CSRF-TOKEN': csrfToken || this._getCsrfToken(),
165+
},
166+
credentials: 'same-origin',
167+
body: JSON.stringify({
168+
...registrationResponse,
169+
name,
170+
}),
171+
});
172+
173+
const data = await verifyResponse.json();
174+
175+
if (!verifyResponse.ok) {
176+
this._error = data.message || 'Verification failed.';
177+
if (onError) {
178+
onError({ message: this._error, status: verifyResponse.status });
179+
}
180+
return;
181+
}
182+
183+
if (onSuccess) {
184+
onSuccess(data);
185+
}
186+
} catch (e) {
187+
this._handleError(e, onError);
188+
} finally {
189+
this._waiting = false;
190+
}
191+
}
192+
193+
/**
194+
* Cancel any ongoing WebAuthn ceremony.
195+
*/
196+
cancel() {
197+
WebAuthnAbortService.cancelCeremony();
198+
}
199+
200+
/**
201+
* Initialize browser autofill for passkey authentication.
202+
* Call this on page load to enable passkey suggestions in form fields.
203+
*
204+
* @param {Object} options
205+
* @param {string} options.optionsUrl - URL to fetch assertion options
206+
* @param {string} options.verifyUrl - URL to verify the assertion
207+
* @param {Function} [options.onSuccess] - Callback on success with response data
208+
* @param {Function} [options.onError] - Callback on error with error object
209+
* @param {string} [options.csrfToken] - Override CSRF token
210+
*/
211+
initAutofill(options = {}) {
212+
if (!this.supported) {
213+
return;
214+
}
215+
216+
this.authenticate({
217+
...options,
218+
useBrowserAutofill: true,
219+
});
220+
}
221+
222+
/**
223+
* Get the CSRF token from the page.
224+
* @private
225+
*/
226+
_getCsrfToken() {
227+
const metaTag = document.querySelector('meta[name="csrf-token"]');
228+
if (metaTag) {
229+
return metaTag.getAttribute('content');
230+
}
231+
232+
const input = document.querySelector('input[name="_token"]');
233+
if (input) {
234+
return input.value;
235+
}
236+
237+
return '';
238+
}
239+
240+
/**
241+
* Handle errors consistently.
242+
* @private
243+
*/
244+
_handleError(e, onError) {
245+
this._error = e.message || 'Something went wrong';
246+
247+
if (onError) {
248+
onError({ message: this._error, originalError: e });
249+
}
250+
}
251+
}

resources/js/frontend/helpers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import FieldConditions from './components/FieldConditions.js';
2+
import Passkeys from './components/Passkeys.js';
23

34
class Statamic {
45
constructor() {
56
this.$conditions = new FieldConditions();
7+
this.$passkeys = new Passkeys();
68
}
79
}
810

0 commit comments

Comments
 (0)