Skip to content

Commit 29c008d

Browse files
committed
Add passkey autofill
1 parent 231cc6f commit 29c008d

File tree

5 files changed

+126
-92
lines changed

5 files changed

+126
-92
lines changed

src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
<h1>Welcome!</h1>
1212

13+
<PageTitle>Passkey sample</PageTitle>
14+
1315
<p>
1416
This app demonstrates how to use passkeys for authentication with ASP.NET Core Identity.
1517
</p>
@@ -27,12 +29,13 @@
2729

2830
<form id="auth-form" method="post" @formname="auth" @onsubmit="OnSubmitAsync">
2931
<AntiforgeryToken />
30-
<input type="text" id="input-username" name="Username" placeholder="username" autocomplete="username webauthn"/>
31-
<hr />
32-
<input type="hidden" id="input-credential" name="CredentialJson" />
33-
<input type="hidden" id="input-action" name="Action" />
34-
<button type="submit" id="input-register">Register</button>
35-
<button type="submit" id="input-authenticate">Authenticate</button>
32+
<div>
33+
<input type="text" id="input-username" name="username" placeholder="Username" autocomplete="username webauthn" />
34+
</div>
35+
<div>
36+
<button type="submit" name="action" value="register" id="input-register">Register</button>
37+
<button type="submit" name="action" value="authenticate" id="input-authenticate">Authenticate</button>
38+
</div>
3639
</form>
3740

3841
<p id="status-message">@statusMessage</p>
@@ -43,13 +46,13 @@
4346
[CascadingParameter]
4447
private HttpContext HttpContext { get; set; } = default!;
4548

46-
[SupplyParameterFromForm]
49+
[SupplyParameterFromForm(Name = "username")]
4750
private string? Username { get; set; }
4851

49-
[SupplyParameterFromForm]
52+
[SupplyParameterFromForm(Name = "credential")]
5053
private string? CredentialJson { get; set; }
5154

52-
[SupplyParameterFromForm]
55+
[SupplyParameterFromForm(Name = "action")]
5356
private string? Action { get; set; }
5457

5558
private Task OnSubmitAsync()

src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dotnetRunMessages": true,
77
"launchBrowser": true,
88
"applicationUrl": "http://localhost:5021",
9+
"hotReloadEnabled": false,
910
"environmentVariables": {
1011
"ASPNETCORE_ENVIRONMENT": "Development"
1112
}
@@ -15,6 +16,7 @@
1516
"dotnetRunMessages": true,
1617
"launchBrowser": true,
1718
"applicationUrl": "https://localhost:7021;http://localhost:5021",
19+
"hotReloadEnabled": false,
1820
"environmentVariables": {
1921
"ASPNETCORE_ENVIRONMENT": "Development"
2022
}

src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js

Lines changed: 82 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -22,83 +22,100 @@
2222
}
2323

2424
// Define home page JS functionality.
25-
addRouteScript('/', () => {
25+
addRouteScript('/', async () => {
26+
let abortController;
2627
const form = document.getElementById('auth-form');
27-
const usernameInput = document.getElementById('input-username');
28-
const credentialInput = document.getElementById('input-credential');
29-
const actionInput = document.getElementById('input-action');
30-
const registerInput = document.getElementById('input-register');
31-
const authenticateInput = document.getElementById('input-authenticate');
3228
const statusMessage = document.getElementById('status-message');
3329

34-
async function submitCredential(action, credentialCallback) {
35-
statusMessage.textContent = 'Submitting...';
30+
async function fetchNewCredential(username) {
31+
if (!username) {
32+
throw new Error('Please enter a username.');
33+
}
34+
35+
const optionsResponse = await fetch('/attestation/options', {
36+
method: 'POST',
37+
body: JSON.stringify({
38+
username,
39+
authenticatorSelection: {
40+
residentKey: 'preferred',
41+
}
42+
// TODO: Allow configuration of other options.
43+
}),
44+
headers: {
45+
'Content-Type': 'application/json',
46+
},
47+
credentials: 'include',
48+
});
49+
const optionsJson = await optionsResponse.json();
50+
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
51+
abortController?.abort();
52+
abortController = new AbortController();
53+
return await navigator.credentials.create({
54+
publicKey: options,
55+
signal: abortController.signal,
56+
});
57+
}
58+
59+
async function fetchExistingCredential(username, useConditionalMediation) {
60+
// The username is optional for authentication, so we don't validate it here.
61+
const optionsResponse = await fetch('/assertion/options', {
62+
method: 'POST',
63+
body: JSON.stringify({
64+
username,
65+
// TODO: Allow configuration of other options.
66+
}),
67+
headers: {
68+
'Content-Type': 'application/json',
69+
},
70+
credentials: 'include',
71+
});
72+
const optionsJson = await optionsResponse.json();
73+
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
74+
abortController?.abort();
75+
abortController = new AbortController();
76+
return await navigator.credentials.get({
77+
publicKey: options,
78+
mediation: useConditionalMediation ? 'conditional' : undefined,
79+
signal: abortController.signal,
80+
});
81+
}
82+
83+
async function fetchAndSubmitCredential(action, useConditionalMediation = false) {
3684
try {
37-
var credential = await credentialCallback();
85+
const username = new FormData(form).get('username');
86+
let credential;
87+
if (action === 'register') {
88+
credential = await fetchNewCredential(username);
89+
} else if (action === 'authenticate') {
90+
credential = await fetchExistingCredential(username, useConditionalMediation);
91+
} else {
92+
throw new Error('Unknown action: ' + action);
93+
}
3894
var credentialJson = JSON.stringify(credential);
39-
credentialInput.value = credentialJson;
40-
actionInput.value = action;
95+
form.addEventListener('formdata', (e) => {
96+
e.formData.append('action', action);
97+
e.formData.append('credential', credentialJson);
98+
}, { once: true });
4199
form.submit();
42100
} catch (error) {
43-
statusMessage.textContent = 'Error: ' + error.message;
44-
throw error;
101+
// Ignore abort errors, they are expected when the user cancels the operation.
102+
if (error.name !== 'AbortError') {
103+
statusMessage.textContent = 'Error: ' + error.message;
104+
throw error;
105+
}
45106
}
46107
}
47108

48-
registerInput.addEventListener('click', async (e) => {
49-
e.preventDefault();
50-
51-
await submitCredential('register', async () => {
52-
const username = usernameInput.value;
53-
if (!username) {
54-
throw new Error('Please enter a username.');
55-
}
56-
57-
const optionsResponse = await fetch('/attestation/options', {
58-
method: 'POST',
59-
body: JSON.stringify({
60-
username,
61-
authenticatorSelection: {
62-
residentKey: 'preferred',
63-
}
64-
// TODO: Allow configuration of other options.
65-
}),
66-
headers: {
67-
'Content-Type': 'application/json',
68-
},
69-
credentials: 'include',
70-
});
71-
const optionsJson = await optionsResponse.json();
72-
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
73-
const credential = await navigator.credentials.create({ publicKey: options });
74-
return credential;
75-
});
109+
form.addEventListener('submit', (e) => {
110+
if (e.submitter?.name == 'action') {
111+
e.preventDefault();
112+
fetchAndSubmitCredential(e.submitter.value);
113+
}
76114
});
77115

78-
authenticateInput.addEventListener('click', async (e) => {
79-
e.preventDefault();
80-
81-
await submitCredential('authenticate', async () => {
82-
// The username is optional for authentication, so we don't validate it here.
83-
const username = usernameInput.value;
84-
85-
const optionsResponse = await fetch('/assertion/options', {
86-
method: 'POST',
87-
body: JSON.stringify({
88-
username,
89-
// TODO: Allow configuration of other options.
90-
}),
91-
headers: {
92-
'Content-Type': 'application/json',
93-
},
94-
credentials: 'include',
95-
});
96-
const optionsJson = await optionsResponse.json();
97-
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
98-
const credential = await navigator.credentials.get({ publicKey: options });
99-
return credential;
100-
});
101-
});
116+
if (await PublicKeyCredential.isConditionalMediationAvailable()) {
117+
await fetchAndSubmitCredential('authenticate', /* useConditionalMediation */ true);
118+
}
102119
});
103120

104121
enableRouteScripts();

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<hr />
2525
<ValidationSummary class="text-danger" role="alert" />
2626
<div class="form-floating mb-3">
27-
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="[email protected]" />
27+
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username webauthn" aria-required="true" placeholder="[email protected]" />
2828
<label for="Input.Email" class="form-label">Email</label>
2929
<ValidationMessage For="() => Input.Email" class="text-danger" />
3030
</div>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
async function fetchWithErrorHandling(url, options = {}) {
1+
async function fetchWithErrorHandling(url, options = {}) {
22
const response = await fetch(url, {
33
credentials: 'include',
44
...options
@@ -11,30 +11,33 @@ async function fetchWithErrorHandling(url, options = {}) {
1111
return response;
1212
}
1313

14-
async function createCredential() {
14+
async function createCredential(signal) {
1515
const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', {
1616
method: 'POST',
17+
signal,
1718
});
1819
const optionsJson = await optionsResponse.json();
1920
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
20-
return await navigator.credentials.create({ publicKey: options });
21+
return await navigator.credentials.create({ publicKey: options, signal });
2122
}
2223

23-
async function requestCredential(email) {
24+
async function requestCredential(email, mediation, signal) {
2425
const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, {
2526
method: 'POST',
27+
signal,
2628
});
2729
const optionsJson = await optionsResponse.json();
2830
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
29-
return await navigator.credentials.get({ publicKey: options });
31+
return await navigator.credentials.get({ publicKey: options, mediation, signal });
3032
}
3133

34+
let passkeySubmitAbortController;
35+
3236
customElements.define('passkey-submit', class extends HTMLElement {
3337
static formAssociated = true;
3438

3539
connectedCallback() {
3640
this.internals = this.attachInternals();
37-
this.isObtainingCredentials = false;
3841
this.attrs = {
3942
operation: this.getAttribute('operation'),
4043
name: this.getAttribute('name'),
@@ -44,37 +47,46 @@ customElements.define('passkey-submit', class extends HTMLElement {
4447
this.internals.form.addEventListener('submit', (event) => {
4548
if (event.submitter?.name === '__passkeySubmit') {
4649
event.preventDefault();
47-
this.obtainCredentialAndReSubmit();
50+
this.obtainCredentialAndSubmit();
4851
}
4952
});
50-
}
5153

52-
async obtainCredentialAndReSubmit() {
53-
if (this.isObtainingCredentials) {
54-
return;
55-
}
54+
this.tryAutofillPasskey();
55+
}
5656

57-
this.isObtainingCredentials = true;
57+
async obtainCredentialAndSubmit(useConditionalMediation = false) {
58+
passkeySubmitAbortController?.abort();
59+
passkeySubmitAbortController = new AbortController();
60+
const signal = passkeySubmitAbortController.signal;
5861
const formData = new FormData();
5962
try {
6063
let credential;
6164
if (this.attrs.operation === 'Create') {
62-
credential = await createCredential();
65+
credential = await createCredential(signal);
6366
} else if (this.attrs.operation === 'Request') {
6467
const email = new FormData(this.internals.form).get(this.attrs.emailName);
65-
credential = await requestCredential(email);
68+
const mediation = useConditionalMediation ? 'conditional' : undefined;
69+
credential = await requestCredential(email, mediation, signal);
6670
} else {
6771
throw new Error(`Unknown passkey operation '${operation}'.`);
6872
}
6973
const credentialJson = JSON.stringify(credential);
7074
formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
7175
} catch (error) {
76+
if (error.name === 'AbortError') {
77+
// Canceled by user action, do not submit the form
78+
return;
79+
}
7280
formData.append(`${this.attrs.name}.Error`, error.message);
7381
console.error(error);
74-
} finally {
75-
this.isObtainingCredentials = false;
7682
}
7783
this.internals.setFormValue(formData);
7884
this.internals.form.submit();
7985
}
86+
87+
async tryAutofillPasskey() {
88+
if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) {
89+
await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true);
90+
}
91+
}
8092
});

0 commit comments

Comments
 (0)