mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 18:06:41 +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
|
|
@ -84,6 +84,8 @@
|
|||
version?: string;
|
||||
/** Build timestamp (ISO string) to display next to version */
|
||||
buildTime?: string;
|
||||
onSignInWithPasskey?: () => Promise<AuthResult>;
|
||||
passkeyAvailable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -106,6 +108,8 @@
|
|||
initialPassword = '',
|
||||
version = '',
|
||||
buildTime = '',
|
||||
onSignInWithPasskey,
|
||||
passkeyAvailable = false,
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived({ ...defaultTranslations, ...translations });
|
||||
|
|
@ -254,6 +258,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handlePasskeySignIn() {
|
||||
if (!onSignInWithPasskey) return;
|
||||
loading = true;
|
||||
clearError();
|
||||
|
||||
const result = await onSignInWithPasskey();
|
||||
loading = false;
|
||||
|
||||
if (result.success) {
|
||||
showSuccess = true;
|
||||
successAnnouncement = t.signInSuccess;
|
||||
setTimeout(() => goto(successRedirect), 600);
|
||||
} else if (result.error === 'Passkey authentication was cancelled') {
|
||||
// User cancelled - don't show error
|
||||
} else {
|
||||
setError(result.error || t.signInFailed, 'general');
|
||||
}
|
||||
}
|
||||
|
||||
function skipToForm() {
|
||||
if (emailInput) emailInput.focus();
|
||||
}
|
||||
|
|
@ -349,6 +372,34 @@
|
|||
<p class="form-subtitle">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{#if passkeyAvailable && onSignInWithPasskey}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handlePasskeySignIn}
|
||||
disabled={loading || showSuccess}
|
||||
class="passkey-button"
|
||||
style:border-color={primaryColor}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Passkey</span>
|
||||
</button>
|
||||
<div class="divider">
|
||||
<span>{t.orDivider}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if verificationEmailSent}
|
||||
<div class="verified-banner" role="status" aria-live="polite">
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
|
|
@ -927,6 +978,61 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.passkey-button {
|
||||
width: 100%;
|
||||
height: 3.5rem;
|
||||
border: 2px solid;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: transparent;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.light .passkey-button {
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.passkey-button:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.passkey-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.light .divider span {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"base64-js": "^1.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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