mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
feat(auth): add WebAuthn MFA credentials (#1628)
* feat(auth): add WebAuthn MFA credentials * fix(auth): harden WebAuthn MFA review paths * fix(auth): polish WebAuthn error handling * fix(auth): handle duplicate WebAuthn credential races * fix(auth): permit WebAuthn credential params * fix(auth): trim WebAuthn registration controller cleanup * fix(auth): tighten WebAuthn MFA handling * fix(auth): pin WebAuthn relying party config
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import WebauthnController from "controllers/webauthn_controller";
|
||||
import {
|
||||
prepareCredentialRequestOptions,
|
||||
serializePublicKeyCredential,
|
||||
} from "utils/webauthn";
|
||||
|
||||
export default class extends WebauthnController {
|
||||
static targets = ["error"];
|
||||
static values = {
|
||||
optionsUrl: String,
|
||||
verifyUrl: String,
|
||||
unsupportedMessage: String,
|
||||
errorFallback: String,
|
||||
};
|
||||
|
||||
async authenticate(event) {
|
||||
event.preventDefault();
|
||||
this.clearError();
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
this.showError(this.unsupportedMessageValue);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await this.fetchOptions();
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: prepareCredentialRequestOptions(options),
|
||||
});
|
||||
|
||||
await this.verifyCredential(serializePublicKeyCredential(credential));
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchOptions() {
|
||||
const response = await fetch(this.optionsUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async verifyCredential(credential) {
|
||||
const response = await fetch(this.verifyUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ credential }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
const result = await response.json();
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
}
|
||||
39
app/javascript/controllers/webauthn_controller.js
Normal file
39
app/javascript/controllers/webauthn_controller.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
get headers() {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
|
||||
?.content,
|
||||
};
|
||||
}
|
||||
|
||||
async errorMessage(response) {
|
||||
try {
|
||||
const result = await response.clone().json();
|
||||
if (result.error) return result.error;
|
||||
} catch (_error) {
|
||||
return this.errorFallbackValue;
|
||||
}
|
||||
|
||||
return this.errorFallbackValue;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = message;
|
||||
this.errorTarget.hidden = false;
|
||||
this.errorTarget.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = "";
|
||||
this.errorTarget.hidden = true;
|
||||
this.errorTarget.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import WebauthnController from "controllers/webauthn_controller";
|
||||
import {
|
||||
prepareCredentialCreationOptions,
|
||||
serializePublicKeyCredential,
|
||||
} from "utils/webauthn";
|
||||
|
||||
export default class extends WebauthnController {
|
||||
static targets = ["error", "nickname"];
|
||||
static values = {
|
||||
optionsUrl: String,
|
||||
createUrl: String,
|
||||
unsupportedMessage: String,
|
||||
errorFallback: String,
|
||||
};
|
||||
|
||||
async register(event) {
|
||||
event.preventDefault();
|
||||
this.clearError();
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
this.showError(this.unsupportedMessageValue);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await this.fetchOptions();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: prepareCredentialCreationOptions(options),
|
||||
});
|
||||
|
||||
await this.createCredential(serializePublicKeyCredential(credential));
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchOptions() {
|
||||
const response = await fetch(this.optionsUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async createCredential(credential) {
|
||||
const response = await fetch(this.createUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
webauthn_credential: {
|
||||
nickname: this.hasNicknameTarget ? this.nicknameTarget.value : "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
const result = await response.json();
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
}
|
||||
85
app/javascript/utils/webauthn.js
Normal file
85
app/javascript/utils/webauthn.js
Normal file
@@ -0,0 +1,85 @@
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const binary = String.fromCharCode(...bytes);
|
||||
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function base64urlToBuffer(value) {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
"=",
|
||||
);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export function prepareCredentialCreationOptions(options) {
|
||||
options.challenge = base64urlToBuffer(options.challenge);
|
||||
options.user.id = base64urlToBuffer(options.user.id);
|
||||
options.excludeCredentials = (options.excludeCredentials || []).map(
|
||||
(credential) => ({
|
||||
...credential,
|
||||
id: base64urlToBuffer(credential.id),
|
||||
}),
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function prepareCredentialRequestOptions(options) {
|
||||
options.challenge = base64urlToBuffer(options.challenge);
|
||||
options.allowCredentials = (options.allowCredentials || []).map(
|
||||
(credential) => ({
|
||||
...credential,
|
||||
id: base64urlToBuffer(credential.id),
|
||||
}),
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function serializePublicKeyCredential(credential) {
|
||||
const serialized = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
};
|
||||
|
||||
if (credential.response.attestationObject) {
|
||||
serialized.response = {
|
||||
attestationObject: bufferToBase64url(
|
||||
credential.response.attestationObject,
|
||||
),
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports
|
||||
? credential.response.getTransports()
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
serialized.response = {
|
||||
authenticatorData: bufferToBase64url(
|
||||
credential.response.authenticatorData,
|
||||
),
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
signature: bufferToBase64url(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? bufferToBase64url(credential.response.userHandle)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
Reference in New Issue
Block a user