- Added `customerNumber` parameter to `getAuthenticationChallenge` and `authenticateWithPasskey` functions. - Introduced challenge token usage in `verifyAuthentication` to align with OpenAPI spec. - Improved error handling in `LoginForm.vue` with detailed error checks and logging. - Ensured support for session tokens in `SubuserLoginForm.vue`.
198 lines
6.5 KiB
JavaScript
198 lines
6.5 KiB
JavaScript
/**
|
|
* Passkey Authentication Service
|
|
*
|
|
* Handles WebAuthn passkey authentication for users and subusers.
|
|
* Uses the WebAuthn API for passwordless authentication.
|
|
*/
|
|
|
|
import axios from 'axios';
|
|
import { API_URL } from '@/config.js';
|
|
|
|
/**
|
|
* Check if WebAuthn is supported in the current browser
|
|
* @returns {boolean}
|
|
*/
|
|
export const isPasskeySupported = () => {
|
|
return window.PublicKeyCredential !== undefined;
|
|
};
|
|
|
|
/**
|
|
* Convert an ArrayBuffer to Base64URL string
|
|
* @param {ArrayBuffer} buffer
|
|
* @returns {string}
|
|
*/
|
|
const arrayBufferToBase64URL = (buffer) => {
|
|
const bytes = new Uint8Array(buffer);
|
|
let str = '';
|
|
for (const byte of bytes) {
|
|
str += String.fromCharCode(byte);
|
|
}
|
|
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
};
|
|
|
|
/**
|
|
* Convert a Base64URL string to ArrayBuffer
|
|
* @param {string} base64url
|
|
* @returns {ArrayBuffer}
|
|
*/
|
|
const base64URLToArrayBuffer = (base64url) => {
|
|
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
const paddedBase64 = base64 + '='.repeat((4 - base64.length % 4) % 4);
|
|
const binary = atob(paddedBase64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
};
|
|
|
|
/**
|
|
* Request authentication challenge from server
|
|
* @param {string} userType - 'user' or 'subuser'
|
|
* @param {number|null} customerNumber - Optional customer number for user login
|
|
* @returns {Promise<Object>} Challenge options from server including challenge_token and publicKey
|
|
*/
|
|
const getAuthenticationChallenge = async (userType = 'user', customerNumber = null) => {
|
|
const endpoint = userType === 'subuser'
|
|
? '/auth/passkey/challenge/subuser'
|
|
: '/auth/passkey/challenge';
|
|
|
|
const requestBody = {};
|
|
if (customerNumber) {
|
|
requestBody.customer_number = customerNumber;
|
|
}
|
|
|
|
const response = await axios.post(API_URL + endpoint, requestBody);
|
|
return response.data.data || response.data;
|
|
};
|
|
|
|
/**
|
|
* Send authentication assertion to server for verification
|
|
* @param {Object} assertion - The WebAuthn assertion matching openapi spec
|
|
* @param {string} challengeToken - The challenge token from challenge endpoint
|
|
* @param {string} userType - 'user' or 'subuser'
|
|
* @returns {Promise<Object>} Authentication response with token or session
|
|
*/
|
|
const verifyAuthentication = async (assertion, challengeToken, userType = 'user') => {
|
|
const endpoint = userType === 'subuser'
|
|
? '/auth/passkey/verify/subuser'
|
|
: '/auth/passkey/verify';
|
|
|
|
const requestBody = {
|
|
challenge_token: challengeToken,
|
|
id: assertion.id,
|
|
response: {
|
|
clientDataJSON: assertion.clientDataJSON,
|
|
authenticatorData: assertion.authenticatorData,
|
|
signature: assertion.signature,
|
|
},
|
|
};
|
|
|
|
// Include userHandle if present
|
|
if (assertion.userHandle) {
|
|
requestBody.response.userHandle = assertion.userHandle;
|
|
}
|
|
|
|
const response = await axios.post(API_URL + endpoint, requestBody);
|
|
return response.data.data || response.data;
|
|
};
|
|
|
|
/**
|
|
* Authenticate using a passkey
|
|
* @param {string} userType - 'user' or 'subuser'
|
|
* @param {number|null} customerNumber - Optional customer number for user login
|
|
* @returns {Promise<{token: string}|{session: string}>} Authentication result with token or session
|
|
* @throws {Error} If authentication fails
|
|
*/
|
|
export const authenticateWithPasskey = async (userType = 'user', customerNumber = null) => {
|
|
if (!isPasskeySupported()) {
|
|
throw new Error('Passkeys are not supported on this device');
|
|
}
|
|
|
|
try {
|
|
// Get challenge from server
|
|
const challengeResponse = await getAuthenticationChallenge(userType, customerNumber);
|
|
const challengeToken = challengeResponse.challenge_token;
|
|
const publicKey = challengeResponse.publicKey;
|
|
|
|
// Prepare credential request options from server response
|
|
const publicKeyCredentialRequestOptions = {
|
|
challenge: base64URLToArrayBuffer(publicKey.challenge),
|
|
timeout: publicKey.timeout || 60000,
|
|
userVerification: publicKey.userVerification || 'preferred',
|
|
rpId: publicKey.rpId || window.location.hostname,
|
|
};
|
|
|
|
// Add allowCredentials if provided by server
|
|
if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) {
|
|
publicKeyCredentialRequestOptions.allowCredentials = publicKey.allowCredentials.map(cred => ({
|
|
id: base64URLToArrayBuffer(cred.id),
|
|
type: cred.type || 'public-key',
|
|
transports: cred.transports || ['internal', 'hybrid'],
|
|
}));
|
|
}
|
|
|
|
// Request credential from authenticator
|
|
const credential = await navigator.credentials.get({
|
|
publicKey: publicKeyCredentialRequestOptions,
|
|
});
|
|
|
|
if (!credential) {
|
|
throw new Error('No credential returned from authenticator');
|
|
}
|
|
|
|
// Prepare assertion for server verification (matching openapi spec)
|
|
const assertion = {
|
|
id: arrayBufferToBase64URL(credential.rawId),
|
|
clientDataJSON: arrayBufferToBase64URL(credential.response.clientDataJSON),
|
|
authenticatorData: arrayBufferToBase64URL(credential.response.authenticatorData),
|
|
signature: arrayBufferToBase64URL(credential.response.signature),
|
|
userHandle: credential.response.userHandle
|
|
? arrayBufferToBase64URL(credential.response.userHandle)
|
|
: null,
|
|
};
|
|
|
|
// Send assertion to server for verification with challenge token
|
|
const result = await verifyAuthentication(assertion, challengeToken, userType);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Handle specific WebAuthn errors
|
|
if (error.name === 'NotAllowedError') {
|
|
throw new Error('Authentication was cancelled or not allowed');
|
|
} else if (error.name === 'SecurityError') {
|
|
throw new Error('Security error during authentication');
|
|
} else if (error.name === 'NotSupportedError') {
|
|
throw new Error('Passkey not supported');
|
|
}
|
|
|
|
// Re-throw other errors
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if the user has any registered passkeys
|
|
* This is a convenience method to conditionally show passkey login options
|
|
* Note: This requires the user to be authenticated first
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export const hasRegisteredPasskeys = async () => {
|
|
try {
|
|
const response = await axios.get(API_URL + '/account/security/passkeys', {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
|
},
|
|
});
|
|
return response.data.data && response.data.data.length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export default {
|
|
isPasskeySupported,
|
|
authenticateWithPasskey,
|
|
hasRegisteredPasskeys,
|
|
};
|