mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 13:29:39 +02:00
feat(auth): add WebAuthn/Passkey support across all apps
Implements passwordless authentication via passkeys using @simplewebauthn: Backend (mana-core-auth): - New passkeys table in auth schema (credentialId, publicKey, counter, etc.) - PasskeyService with registration/authentication flows and challenge storage - 7 new API endpoints (register, authenticate, list, delete, rename) - createSessionAndTokens helper for non-password auth flows - Security event types for passkey operations Client (shared-auth): - signInWithPasskey() and registerPasskey() with dynamic @simplewebauthn/browser imports - isPasskeyAvailable() browser capability check - Passkey management methods (list, delete, rename) UI (shared-auth-ui): - Passkey button on LoginPage with key icon, shown when browser supports WebAuthn - Divider between passkey and email/password form App integration: - All 19 web app auth stores have isPasskeyAvailable() and signInWithPasskey() - All 19 web app login pages pass passkeyAvailable and onSignInWithPasskey props - rpID=mana.how in production enables cross-app passkey usage (SSO-compatible) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1095202ad9
commit
3091da914e
52 changed files with 1849 additions and 4 deletions
|
|
@ -58,6 +58,11 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
|||
credits: '/api/v1/credits/balance',
|
||||
// Better Auth native endpoints for SSO
|
||||
getSession: '/api/auth/get-session',
|
||||
passkeyRegisterOptions: '/api/v1/auth/passkeys/register/options',
|
||||
passkeyRegisterVerify: '/api/v1/auth/passkeys/register/verify',
|
||||
passkeyAuthOptions: '/api/v1/auth/passkeys/authenticate/options',
|
||||
passkeyAuthVerify: '/api/v1/auth/passkeys/authenticate/verify',
|
||||
passkeyList: '/api/v1/auth/passkeys',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -358,6 +363,210 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
return { appToken, refreshToken, userData };
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if WebAuthn/Passkeys are supported in this browser
|
||||
*/
|
||||
isPasskeyAvailable(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!window.PublicKeyCredential;
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a new passkey for the current user
|
||||
*/
|
||||
async registerPasskey(friendlyName?: string): Promise<AuthResult> {
|
||||
try {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
// Step 1: Get registration options from server
|
||||
const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterOptions}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to get registration options' };
|
||||
}
|
||||
|
||||
const { options, challengeId } = await optionsRes.json();
|
||||
|
||||
// Step 2: Create credential via browser WebAuthn API
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
// Step 3: Send credential to server for verification
|
||||
const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterVerify}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
},
|
||||
body: JSON.stringify({ challengeId, credential, friendlyName }),
|
||||
});
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Passkey registration failed' };
|
||||
}
|
||||
|
||||
trackAuth('passkey_registered');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// User cancelled or WebAuthn error
|
||||
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||
return { success: false, error: 'Passkey registration was cancelled' };
|
||||
}
|
||||
console.error('Passkey registration error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passkey registration failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with a passkey
|
||||
*/
|
||||
async signInWithPasskey(): Promise<AuthResult> {
|
||||
try {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
const storage = getStorageAdapter();
|
||||
|
||||
// Step 1: Get authentication options from server
|
||||
const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyAuthOptions}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to get authentication options' };
|
||||
}
|
||||
|
||||
const { options, challengeId } = await optionsRes.json();
|
||||
|
||||
// Step 2: Authenticate via browser WebAuthn API
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
// Step 3: Send credential to server for verification
|
||||
const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ challengeId, credential }),
|
||||
});
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Passkey authentication failed' };
|
||||
}
|
||||
|
||||
const data = await verifyRes.json();
|
||||
const appToken = data.accessToken;
|
||||
const refreshToken = data.refreshToken;
|
||||
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
storage.setItem(storageKeys.USER_EMAIL, data.user?.email || ''),
|
||||
]);
|
||||
|
||||
trackAuth('login', { method: 'passkey' });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||
return { success: false, error: 'Passkey authentication was cancelled' };
|
||||
}
|
||||
console.error('Passkey authentication error:', error);
|
||||
trackAuth('login_failed', { method: 'passkey' });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passkey authentication failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List user's registered passkeys
|
||||
*/
|
||||
async listPasskeys(): Promise<any[]> {
|
||||
try {
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return [];
|
||||
|
||||
const res = await fetch(`${baseUrl}${endpoints.passkeyList}`, {
|
||||
headers: { Authorization: `Bearer ${appToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a passkey
|
||||
*/
|
||||
async deletePasskey(passkeyId: string): Promise<AuthResult> {
|
||||
try {
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${appToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to delete passkey' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete passkey',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a passkey
|
||||
*/
|
||||
async renamePasskey(passkeyId: string, friendlyName: string): Promise<AuthResult> {
|
||||
try {
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
},
|
||||
body: JSON.stringify({ friendlyName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to rename passkey' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to rename passkey',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current app token
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ export interface AuthEndpoints {
|
|||
credits: string;
|
||||
/** Better Auth native endpoint for SSO session check */
|
||||
getSession: string;
|
||||
passkeyRegisterOptions: string;
|
||||
passkeyRegisterVerify: string;
|
||||
passkeyAuthOptions: string;
|
||||
passkeyAuthVerify: string;
|
||||
passkeyList: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue