Files
pleno-vue/src/services/PasskeyAuthService.js
T
Jeppe Bundgaard 75cde6a355 Enhance Passkey Authentication Logic and Error Handling
- 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`.
2026-02-24 00:34:07 +01:00

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,
};