Files
media-downloader/web/frontend/public/passkeys.js
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

559 lines
20 KiB
JavaScript

/**
* 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;