558
web/frontend/public/passkeys.js
Normal file
558
web/frontend/public/passkeys.js
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Passkeys.js - Frontend WebAuthn/Passkey Management
|
||||
*
|
||||
* This module handles passkey (WebAuthn) registration, authentication, and management.
|
||||
* Passkeys provide phishing-resistant, passwordless authentication using biometrics
|
||||
* or hardware security keys (Touch ID, Face ID, Windows Hello, YubiKey, etc.)
|
||||
*/
|
||||
|
||||
const Passkeys = {
|
||||
/**
|
||||
* Check if WebAuthn is supported in the browser
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return window.PublicKeyCredential !== undefined &&
|
||||
navigator.credentials !== undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show browser not supported error
|
||||
*/
|
||||
showNotSupportedError() {
|
||||
showNotification('error', 'Browser Not Supported',
|
||||
'Your browser does not support passkeys. Please use a modern browser like Chrome, Safari, Edge, or Firefox.');
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a new passkey
|
||||
*/
|
||||
async registerPasskey() {
|
||||
if (!this.isSupported()) {
|
||||
this.showNotSupportedError();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prompt for device name
|
||||
const deviceName = await this.promptDeviceName();
|
||||
if (!deviceName) {return;} // User cancelled
|
||||
|
||||
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showNotification('error', 'Authentication Required', 'Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get registration options from server
|
||||
showNotification('info', 'Starting Registration', 'Requesting registration options...');
|
||||
|
||||
const optionsResponse = await fetch('/api/auth/passkey/register/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!optionsResponse.ok) {
|
||||
const error = await optionsResponse.json();
|
||||
throw new Error(error.error || 'Failed to get registration options');
|
||||
}
|
||||
|
||||
const { options } = await optionsResponse.json();
|
||||
|
||||
// Convert options for WebAuthn API
|
||||
const publicKeyOptions = this.convertRegistrationOptions(options);
|
||||
|
||||
// Prompt user for biometric/security key
|
||||
showNotification('info', 'Authenticate', 'Please authenticate with your device (Touch ID, Face ID, Windows Hello, or security key)...');
|
||||
|
||||
let credential;
|
||||
try {
|
||||
credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
showNotification('warning', 'Cancelled', 'Registration was cancelled');
|
||||
} else if (err.name === 'InvalidStateError') {
|
||||
showNotification('error', 'Already Registered', 'This authenticator is already registered');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
showNotification('error', 'Registration Failed', 'No credential was created');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send credential to server for verification
|
||||
const verificationResponse = await fetch('/api/auth/passkey/register/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
response: this.encodeCredential(credential),
|
||||
deviceName: deviceName
|
||||
})
|
||||
});
|
||||
|
||||
if (!verificationResponse.ok) {
|
||||
const error = await verificationResponse.json();
|
||||
throw new Error(error.error || 'Failed to verify registration');
|
||||
}
|
||||
|
||||
const result = await verificationResponse.json();
|
||||
showNotification('success', 'Passkey Registered', `${deviceName} has been successfully registered!`);
|
||||
|
||||
// Reload passkey list
|
||||
if (typeof loadPasskeyStatus === 'function') {
|
||||
loadPasskeyStatus();
|
||||
}
|
||||
if (typeof listPasskeys === 'function') {
|
||||
listPasskeys();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Passkey registration error:', error);
|
||||
showNotification('error', 'Registration Error', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Prompt user for device name
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async promptDeviceName() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2><i class="fas fa-fingerprint"></i> Register Passkey</h2>
|
||||
<button class="modal-close" onclick="this.closest('.modal-overlay').remove(); event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 15px;">Give this passkey a name to help you identify it later:</p>
|
||||
<div class="form-group">
|
||||
<label for="passkey-device-name">Device Name</label>
|
||||
<input type="text" id="passkey-device-name"
|
||||
placeholder="e.g., iPhone 12, MacBook Pro, YubiKey"
|
||||
maxlength="100" required autofocus>
|
||||
<small style="color: var(--gray-500); margin-top: 5px; display: block;">
|
||||
Examples: "iPhone 15", "Windows PC", "YubiKey 5C"
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove();">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary" id="continue-registration-btn">
|
||||
<i class="fas fa-arrow-right"></i> Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const input = modal.querySelector('#passkey-device-name');
|
||||
const continueBtn = modal.querySelector('#continue-registration-btn');
|
||||
|
||||
// Auto-suggest device name
|
||||
input.value = this.suggestDeviceName();
|
||||
input.select();
|
||||
|
||||
const handleContinue = () => {
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
modal.remove();
|
||||
resolve(value);
|
||||
} else {
|
||||
input.style.borderColor = 'var(--danger)';
|
||||
showNotification('warning', 'Name Required', 'Please enter a device name');
|
||||
}
|
||||
};
|
||||
|
||||
continueBtn.addEventListener('click', handleContinue);
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleContinue();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle modal close
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Suggest a device name based on user agent
|
||||
* @returns {string}
|
||||
*/
|
||||
suggestDeviceName() {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
// Mobile devices
|
||||
if (/iPhone/.test(ua)) {return 'iPhone';}
|
||||
if (/iPad/.test(ua)) {return 'iPad';}
|
||||
if (/Android/.test(ua)) {
|
||||
if (/Mobile/.test(ua)) {return 'Android Phone';}
|
||||
return 'Android Tablet';
|
||||
}
|
||||
|
||||
// Desktop OS
|
||||
if (/Macintosh/.test(ua)) {return 'Mac';}
|
||||
if (/Windows/.test(ua)) {return 'Windows PC';}
|
||||
if (/Linux/.test(ua)) {return 'Linux PC';}
|
||||
if (/CrOS/.test(ua)) {return 'Chromebook';}
|
||||
|
||||
return 'My Device';
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert registration options from server to WebAuthn format
|
||||
* @param {object} options - Options from server
|
||||
* @returns {object} - WebAuthn PublicKeyCredentialCreationOptions
|
||||
*/
|
||||
convertRegistrationOptions(options) {
|
||||
return {
|
||||
challenge: this.base64urlToBuffer(options.challenge),
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: this.stringToBuffer(options.user.id), // Handle both base64url and plain text
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation,
|
||||
excludeCredentials: options.excludeCredentials?.map(cred => ({
|
||||
id: this.base64urlToBuffer(cred.id),
|
||||
type: cred.type,
|
||||
transports: cred.transports
|
||||
})) || [],
|
||||
authenticatorSelection: options.authenticatorSelection
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert authentication options from server to WebAuthn format
|
||||
* @param {object} options - Options from server
|
||||
* @returns {object} - WebAuthn PublicKeyCredentialRequestOptions
|
||||
*/
|
||||
convertAuthenticationOptions(options) {
|
||||
return {
|
||||
challenge: this.base64urlToBuffer(options.challenge),
|
||||
rpId: options.rpId,
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
allowCredentials: options.allowCredentials?.map(cred => ({
|
||||
id: this.base64urlToBuffer(cred.id),
|
||||
type: cred.type,
|
||||
transports: cred.transports
|
||||
})) || []
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Encode credential for sending to server
|
||||
* @param {PublicKeyCredential} credential
|
||||
* @returns {object}
|
||||
*/
|
||||
encodeCredential(credential) {
|
||||
const response = credential.response;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: this.bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
|
||||
attestationObject: this.bufferToBase64url(response.attestationObject),
|
||||
transports: response.getTransports ? response.getTransports() : []
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Encode authentication credential for sending to server
|
||||
* @param {PublicKeyCredential} credential
|
||||
* @returns {object}
|
||||
*/
|
||||
encodeAuthCredential(credential) {
|
||||
const response = credential.response;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: this.bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
|
||||
authenticatorData: this.bufferToBase64url(response.authenticatorData),
|
||||
signature: this.bufferToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert string to ArrayBuffer (handles both plain text and base64url)
|
||||
* @param {string} str - String to convert
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
stringToBuffer(str) {
|
||||
// Check if it looks like base64url (only contains base64url-safe characters)
|
||||
if (/^[A-Za-z0-9_-]+$/.test(str) && str.length > 10) {
|
||||
// Likely base64url encoded, try to decode
|
||||
try {
|
||||
return this.base64urlToBuffer(str);
|
||||
} catch (e) {
|
||||
// If decoding fails, treat as plain text
|
||||
console.warn('Failed to decode as base64url, treating as plain text:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text - convert to UTF-8 bytes
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(str).buffer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert base64url string to ArrayBuffer
|
||||
* @param {string} base64url
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
base64urlToBuffer(base64url) {
|
||||
try {
|
||||
// Ensure input is a string and trim whitespace
|
||||
if (typeof base64url !== 'string') {
|
||||
throw new Error(`Expected string, got ${typeof base64url}`);
|
||||
}
|
||||
|
||||
base64url = base64url.trim();
|
||||
|
||||
// Convert base64url to base64
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Add padding
|
||||
const padLen = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64 + '='.repeat(padLen);
|
||||
|
||||
// Decode base64 to binary string
|
||||
const binary = atob(padded);
|
||||
|
||||
// Convert to ArrayBuffer
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
} catch (error) {
|
||||
console.error('base64urlToBuffer error:', error);
|
||||
console.error('Input value:', base64url);
|
||||
console.error('Input type:', typeof base64url);
|
||||
throw new Error(`Failed to decode base64url: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64url string
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a passkey
|
||||
* @param {string} credentialId - Credential ID to delete
|
||||
* @param {string} deviceName - Device name for confirmation
|
||||
*/
|
||||
async deletePasskey(credentialId, deviceName) {
|
||||
if (!confirm(`Are you sure you want to delete the passkey "${deviceName}"?\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showNotification('error', 'Authentication Required', 'Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete passkey');
|
||||
}
|
||||
|
||||
showNotification('success', 'Passkey Deleted', `${deviceName} has been removed`);
|
||||
|
||||
// Reload passkey list
|
||||
if (typeof loadPasskeyStatus === 'function') {
|
||||
loadPasskeyStatus();
|
||||
}
|
||||
if (typeof listPasskeys === 'function') {
|
||||
listPasskeys();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delete passkey error:', error);
|
||||
showNotification('error', 'Delete Error', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a passkey
|
||||
* @param {string} credentialId - Credential ID to rename
|
||||
* @param {string} currentName - Current device name
|
||||
*/
|
||||
async renamePasskey(credentialId, currentName) {
|
||||
const newName = prompt('Enter a new name for this passkey:', currentName);
|
||||
if (!newName || newName === currentName) {return;}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showNotification('error', 'Authentication Required', 'Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}/rename`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ deviceName: newName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to rename passkey');
|
||||
}
|
||||
|
||||
showNotification('success', 'Passkey Renamed', `Renamed to "${newName}"`);
|
||||
|
||||
// Reload passkey list
|
||||
if (typeof listPasskeys === 'function') {
|
||||
listPasskeys();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Rename passkey error:', error);
|
||||
showNotification('error', 'Rename Error', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Authenticate with passkey
|
||||
* @param {string} username - Username (optional for resident keys)
|
||||
* @returns {Promise<object>} - Authentication result with token
|
||||
*/
|
||||
async authenticate(username = null) {
|
||||
if (!this.isSupported()) {
|
||||
this.showNotSupportedError();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authentication options from server
|
||||
const optionsResponse = await fetch('/api/auth/passkey/authenticate/options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!optionsResponse.ok) {
|
||||
const error = await optionsResponse.json();
|
||||
throw new Error(error.error || 'Failed to get authentication options');
|
||||
}
|
||||
|
||||
const { options } = await optionsResponse.json();
|
||||
|
||||
// Convert options for WebAuthn API
|
||||
const publicKeyOptions = this.convertAuthenticationOptions(options);
|
||||
|
||||
// Prompt user for biometric/security key
|
||||
showNotification('info', 'Authenticate', 'Please authenticate with your passkey...');
|
||||
|
||||
let credential;
|
||||
try {
|
||||
credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
showNotification('warning', 'Cancelled', 'Authentication was cancelled');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
showNotification('error', 'Authentication Failed', 'No credential was provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Send credential to server for verification
|
||||
const verificationResponse = await fetch('/api/auth/passkey/authenticate/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
response: this.encodeAuthCredential(credential)
|
||||
})
|
||||
});
|
||||
|
||||
if (!verificationResponse.ok) {
|
||||
const error = await verificationResponse.json();
|
||||
throw new Error(error.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
const result = await verificationResponse.json();
|
||||
showNotification('success', 'Authenticated', 'Successfully logged in with passkey!');
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Passkey authentication error:', error);
|
||||
showNotification('error', 'Authentication Error', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make globally available
|
||||
window.Passkeys = Passkeys;
|
||||
Reference in New Issue
Block a user