Changes
This commit is contained in:
+113
@@ -74,6 +74,32 @@ export interface ProfileResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// WebAuthn types
|
||||
export interface PasskeyCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
transports: string[];
|
||||
device_type: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface WebAuthnStatusResponse {
|
||||
webauthn_enabled: boolean;
|
||||
credential_count: number;
|
||||
}
|
||||
|
||||
export interface WebAuthnCredentialsResponse {
|
||||
credentials: PasskeyCredential[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface WebAuthnLoginCompleteResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
code: number;
|
||||
type: string;
|
||||
@@ -306,6 +332,93 @@ export const api = {
|
||||
body: JSON.stringify({ password }),
|
||||
}, true, { clearTokenOn401: false }),
|
||||
},
|
||||
|
||||
webauthn: {
|
||||
// Get WebAuthn status
|
||||
status: () =>
|
||||
request<WebAuthnStatusResponse>('/auth/webauthn/status'),
|
||||
|
||||
// List all passkeys for current user
|
||||
listCredentials: () =>
|
||||
request<WebAuthnCredentialsResponse>('/auth/webauthn/credentials'),
|
||||
|
||||
// Begin passkey registration (returns raw WebAuthn options)
|
||||
beginRegistration: async (): Promise<Record<string, unknown>> => {
|
||||
const response = await fetch(`${config.api.baseUrl}/auth/webauthn/register/begin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${tokenManager.getToken()}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ApiError(
|
||||
error.message || 'Failed to begin registration',
|
||||
error.code || response.status,
|
||||
error.error?.type || 'WEBAUTHN_ERROR',
|
||||
error.error?.details || {}
|
||||
);
|
||||
}
|
||||
// Returns raw WebAuthn options (not wrapped in standard response)
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Complete passkey registration
|
||||
completeRegistration: (credential: Record<string, unknown>, name?: string) =>
|
||||
request<{ message: string; credential_id: string }>('/auth/webauthn/register/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...credential, name }),
|
||||
}),
|
||||
|
||||
// Begin passkey login (returns raw WebAuthn options)
|
||||
beginLogin: async (email: string): Promise<Record<string, unknown>> => {
|
||||
const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/begin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ApiError(
|
||||
error.message || 'No passkeys found for this account',
|
||||
error.code || response.status,
|
||||
error.error?.type || 'WEBAUTHN_ERROR',
|
||||
error.error?.details || {}
|
||||
);
|
||||
}
|
||||
// Returns raw WebAuthn options (not wrapped in standard response)
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Complete passkey login
|
||||
completeLogin: async (assertion: Record<string, unknown>): Promise<WebAuthnLoginCompleteResponse> => {
|
||||
const response = await request<WebAuthnLoginCompleteResponse>('/auth/webauthn/login/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(assertion),
|
||||
}, false);
|
||||
|
||||
// Store token after successful passkey login
|
||||
if (response.token && response.expires_at) {
|
||||
tokenManager.setToken(response.token, response.expires_at);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
// Rename a passkey
|
||||
renameCredential: (credentialId: string, name: string) =>
|
||||
request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Delete a passkey
|
||||
deleteCredential: (credentialId: string) =>
|
||||
request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// WebAuthn utility functions for passkey authentication
|
||||
|
||||
// Convert Base64URL to ArrayBuffer
|
||||
export function base64ToBuffer(base64: string): ArrayBuffer {
|
||||
const base64Url = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const binary = atob(base64Url + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to Base64URL
|
||||
export function bufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
// Check if WebAuthn is supported
|
||||
export function isWebAuthnSupported(): boolean {
|
||||
return !!(
|
||||
navigator.credentials &&
|
||||
typeof navigator.credentials.create === 'function' &&
|
||||
typeof navigator.credentials.get === 'function' &&
|
||||
window.PublicKeyCredential
|
||||
);
|
||||
}
|
||||
|
||||
// Check if platform authenticator is available (Touch ID, Face ID, Windows Hello)
|
||||
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||
if (!isWebAuthnSupported()) return false;
|
||||
try {
|
||||
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Types for WebAuthn API responses
|
||||
export interface WebAuthnRegistrationOptions {
|
||||
rp: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
challenge: string;
|
||||
pubKeyCredParams: Array<{
|
||||
type: 'public-key';
|
||||
alg: number;
|
||||
}>;
|
||||
timeout: number;
|
||||
excludeCredentials: Array<{
|
||||
id: string;
|
||||
type: 'public-key';
|
||||
transports?: string[];
|
||||
}>;
|
||||
authenticatorSelection: {
|
||||
residentKey: string;
|
||||
userVerification: string;
|
||||
authenticatorAttachment?: string;
|
||||
};
|
||||
attestation: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnLoginOptions {
|
||||
challenge: string;
|
||||
timeout: number;
|
||||
rpId: string;
|
||||
allowCredentials: Array<{
|
||||
id: string;
|
||||
type: 'public-key';
|
||||
transports?: string[];
|
||||
}>;
|
||||
userVerification: string;
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
transports: string[];
|
||||
device_type: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface WebAuthnStatusResponse {
|
||||
webauthn_enabled: boolean;
|
||||
credential_count: number;
|
||||
}
|
||||
|
||||
// Create registration credential from server options
|
||||
export async function createRegistrationCredential(
|
||||
options: WebAuthnRegistrationOptions
|
||||
): Promise<PublicKeyCredential> {
|
||||
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
||||
...options,
|
||||
challenge: base64ToBuffer(options.challenge),
|
||||
user: {
|
||||
id: base64ToBuffer(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
excludeCredentials: options.excludeCredentials.map((cred) => ({
|
||||
...cred,
|
||||
id: base64ToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[] | undefined,
|
||||
})),
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
authenticatorSelection: {
|
||||
...options.authenticatorSelection,
|
||||
residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement,
|
||||
userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement,
|
||||
authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment | undefined,
|
||||
},
|
||||
attestation: options.attestation as AttestationConveyancePreference,
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
|
||||
if (!credential || !(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Failed to create credential');
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
// Format registration credential for server
|
||||
export function formatRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64(response.clientDataJSON),
|
||||
},
|
||||
transports: response.getTransports?.() || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Create login assertion from server options
|
||||
export async function createLoginAssertion(
|
||||
options: WebAuthnLoginOptions
|
||||
): Promise<PublicKeyCredential> {
|
||||
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: base64ToBuffer(options.challenge),
|
||||
timeout: options.timeout,
|
||||
rpId: options.rpId,
|
||||
allowCredentials: options.allowCredentials.map((cred) => ({
|
||||
...cred,
|
||||
id: base64ToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[] | undefined,
|
||||
})),
|
||||
userVerification: options.userVerification as UserVerificationRequirement,
|
||||
};
|
||||
|
||||
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||
if (!assertion || !(assertion instanceof PublicKeyCredential)) {
|
||||
throw new Error('Failed to get assertion');
|
||||
}
|
||||
return assertion;
|
||||
}
|
||||
|
||||
// Format login assertion for server
|
||||
export function formatLoginAssertion(assertion: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = assertion.response as AuthenticatorAssertionResponse;
|
||||
return {
|
||||
id: assertion.id,
|
||||
rawId: bufferToBase64(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: bufferToBase64(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64(response.clientDataJSON),
|
||||
signature: bufferToBase64(response.signature),
|
||||
userHandle: response.userHandle ? bufferToBase64(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user