no-password
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
isWebAuthnAvailable,
|
||||
preparePublicKeyCreationOptions,
|
||||
serializeRegistrationCredential,
|
||||
} from '../utils/webauthn';
|
||||
|
||||
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||
const [passkeys, setPasskeys] = useState([]);
|
||||
const [passkeysSupported, setPasskeysSupported] = useState(null);
|
||||
const [passkeysLoading, setPasskeysLoading] = useState(false);
|
||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||
const [revokingPasskeyId, setRevokingPasskeyId] = useState(null);
|
||||
|
||||
const refreshPasskeys = useCallback(async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setPasskeysLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/profile/passkeys');
|
||||
setPasskeys(Array.isArray(data) ? data : []);
|
||||
setPasskeysSupported(true);
|
||||
} catch (error) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 400 || status === 404) {
|
||||
setPasskeysSupported(false);
|
||||
setPasskeys([]);
|
||||
} else {
|
||||
notifyApiError(error, 'Failed to load passkeys.');
|
||||
}
|
||||
} finally {
|
||||
setPasskeysLoading(false);
|
||||
}
|
||||
}, [api, notifyApiError, token]);
|
||||
|
||||
const registerPasskey = useCallback(
|
||||
async ({ nickname } = {}) => {
|
||||
if (!isWebAuthnAvailable()) {
|
||||
setPasskeysSupported(false);
|
||||
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||
return { ok: false, reason: 'unsupported' };
|
||||
}
|
||||
if (registeringPasskey) {
|
||||
return { ok: false, reason: 'busy' };
|
||||
}
|
||||
|
||||
setRegisteringPasskey(true);
|
||||
try {
|
||||
const { data } = await api.post('/auth/passkeys/register/start', {});
|
||||
const challengeId = data?.challengeId || data?.challenge_id;
|
||||
const publicKeyOptions =
|
||||
data?.publicKey
|
||||
|| data?.public_key
|
||||
|| data?.challenge?.publicKey
|
||||
|| data?.publicKeyCredentialCreationOptions;
|
||||
|
||||
if (!challengeId || !publicKeyOptions) {
|
||||
throw new Error('Invalid passkey challenge response.');
|
||||
}
|
||||
|
||||
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
|
||||
if (!credential) {
|
||||
return { ok: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
const serialized = serializeRegistrationCredential(credential);
|
||||
const payload = {
|
||||
challengeId,
|
||||
credential: serialized,
|
||||
};
|
||||
const trimmedNickname = nickname?.trim();
|
||||
if (trimmedNickname) {
|
||||
payload.nickname = trimmedNickname;
|
||||
}
|
||||
|
||||
await api.post('/auth/passkeys/register/finish', payload);
|
||||
await refreshPasskeys();
|
||||
setPasskeysSupported(true);
|
||||
setStatusMessage('Passkey registered.', 'success');
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setStatusMessage('Passkey registration cancelled.', 'info');
|
||||
return { ok: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
const status = error?.response?.status;
|
||||
if (status === 400 || status === 404) {
|
||||
setPasskeysSupported(false);
|
||||
}
|
||||
|
||||
const message = error?.response?.data?.error || 'Failed to register passkey.';
|
||||
notifyApiError(error, message);
|
||||
return { ok: false, reason: 'error', message };
|
||||
} finally {
|
||||
setRegisteringPasskey(false);
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
|
||||
);
|
||||
|
||||
const revokePasskey = useCallback(
|
||||
async (passkeyId, reason) => {
|
||||
if (!passkeyId) {
|
||||
return { ok: false, reason: 'missing-id' };
|
||||
}
|
||||
setRevokingPasskeyId(passkeyId);
|
||||
try {
|
||||
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
|
||||
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
|
||||
await refreshPasskeys();
|
||||
setStatusMessage('Passkey revoked.', 'success');
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Failed to revoke passkey.';
|
||||
notifyApiError(error, message);
|
||||
return { ok: false, reason: 'error', message };
|
||||
} finally {
|
||||
setRevokingPasskeyId(null);
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, refreshPasskeys, setStatusMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePasskeys;
|
||||
@@ -0,0 +1,164 @@
|
||||
const base64urlToBase64 = (value = '') => {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
if (padding === 0) {
|
||||
return normalized;
|
||||
}
|
||||
const padLength = 4 - padding;
|
||||
return normalized + '='.repeat(padLength);
|
||||
};
|
||||
|
||||
const base64ToBase64url = (value = '') =>
|
||||
value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
|
||||
const decodeBase64 = (value) => {
|
||||
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
|
||||
return window.atob(value);
|
||||
}
|
||||
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||
if (bufferCtor) {
|
||||
return bufferCtor.from(value, 'base64').toString('binary');
|
||||
}
|
||||
throw new Error('No base64 decoder available.');
|
||||
};
|
||||
|
||||
const encodeBase64 = (binary) => {
|
||||
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
|
||||
return window.btoa(binary);
|
||||
}
|
||||
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||
if (bufferCtor) {
|
||||
return bufferCtor.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
throw new Error('No base64 encoder available.');
|
||||
};
|
||||
|
||||
export const base64urlToUint8Array = (value) => {
|
||||
const base64 = base64urlToBase64(value || '');
|
||||
const binary = decodeBase64(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const arrayBufferToBase64url = (buffer) => {
|
||||
const bytes = new Uint8Array(buffer || []);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = encodeBase64(binary);
|
||||
return base64ToBase64url(base64);
|
||||
};
|
||||
|
||||
export const isWebAuthnAvailable = () =>
|
||||
typeof window !== 'undefined'
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& navigator.credentials
|
||||
&& typeof navigator.credentials.create === 'function'
|
||||
&& typeof navigator.credentials.get === 'function';
|
||||
|
||||
export const preparePublicKeyCreationOptions = (challengeResponse) => {
|
||||
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||
throw new Error('Missing publicKey challenge options.');
|
||||
}
|
||||
|
||||
const publicKey = { ...challengeResponse.publicKey };
|
||||
|
||||
if (publicKey.challenge) {
|
||||
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||
}
|
||||
|
||||
if (publicKey.user?.id) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: base64urlToUint8Array(publicKey.user.id),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(publicKey.excludeCredentials)) {
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({
|
||||
...descriptor,
|
||||
id: base64urlToUint8Array(descriptor.id),
|
||||
}));
|
||||
}
|
||||
|
||||
if (publicKey.authenticatorSelection?.residentKey === 'discouraged' && !publicKey.authenticatorSelection.requireResidentKey) {
|
||||
delete publicKey.authenticatorSelection.requireResidentKey;
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
export const preparePublicKeyRequestOptions = (challengeResponse) => {
|
||||
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||
throw new Error('Missing publicKey request options.');
|
||||
}
|
||||
|
||||
const publicKey = { ...challengeResponse.publicKey };
|
||||
|
||||
if (publicKey.challenge) {
|
||||
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||
}
|
||||
|
||||
if (Array.isArray(publicKey.allowCredentials)) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({
|
||||
...descriptor,
|
||||
id: base64urlToUint8Array(descriptor.id),
|
||||
}));
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
export const serializeRegistrationCredential = (credential) => {
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transports =
|
||||
typeof credential?.response?.getTransports === 'function'
|
||||
? credential.response.getTransports()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferToBase64url(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||
attestationObject: arrayBufferToBase64url(credential.response.attestationObject),
|
||||
transports: transports && transports.length ? Array.from(transports) : undefined,
|
||||
},
|
||||
clientExtensionResults:
|
||||
typeof credential.getClientExtensionResults === 'function'
|
||||
? credential.getClientExtensionResults()
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export const serializeAuthenticationCredential = (credential) => {
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferToBase64url(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
|
||||
signature: arrayBufferToBase64url(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? arrayBufferToBase64url(credential.response.userHandle)
|
||||
: undefined,
|
||||
},
|
||||
clientExtensionResults:
|
||||
typeof credential.getClientExtensionResults === 'function'
|
||||
? credential.getClientExtensionResults()
|
||||
: {},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user