From 184586861b5d8601e34abc4faadcfcabe9e342f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:41:47 +0000 Subject: [PATCH 1/6] Initial plan From a20963266e5b9e6a6a1e773af2f369403387045b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:56:01 +0000 Subject: [PATCH 2/6] feat: add backend for password change, TOTP 2FA, and passkey management - Add totpSecret/totpEnabled columns to User schema - Create edge-compatible TOTP utility (Web Crypto HMAC-SHA1) - Add backend routes: password/change, totp/setup, totp/enable, totp/disable - Add passkeys routes: list, register-options, register-verify, delete - Add credentials/preflight endpoint for TOTP-aware login - Modify credentials authorize to verify TOTP codes - Update client API with all new functions - Wire routes in router.ts Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/f26604b8-2585-4e32-8a0f-dabeed136fd3 --- .../src/lib/auth-api.ts | 15 + .../worker/lib/totp.ts | 153 ++++ .../worker/routes/account-security.ts | 821 ++++++++++++++++++ .../worker/routes/router.ts | 58 ++ packages/auth/src/backend-handler.ts | 88 +- packages/auth/src/client-api.ts | 352 ++++++++ packages/auth/src/index.ts | 15 + packages/auth/src/providers.ts | 1 + packages/ottaorm/src/models/User.schema.ts | 3 + packages/ottaorm/src/models/User.ts | 31 +- 10 files changed, 1533 insertions(+), 4 deletions(-) create mode 100644 apps/ottabase-template-app-tanstack/worker/lib/totp.ts create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/account-security.ts diff --git a/apps/ottabase-template-app-tanstack/src/lib/auth-api.ts b/apps/ottabase-template-app-tanstack/src/lib/auth-api.ts index 6d61294bb..3ee11c331 100644 --- a/apps/ottabase-template-app-tanstack/src/lib/auth-api.ts +++ b/apps/ottabase-template-app-tanstack/src/lib/auth-api.ts @@ -8,24 +8,39 @@ // ============================================================ export { + changePassword, + deletePasskey, + disableTotp, + enableTotp, getCsrfToken, + getPasskeyAuthOptions, + getPasskeyRegisterOptions, getSession, isAuthenticated, + listPasskeys, + preflightCredentials, registerWithCredentials, requestEmailVerification, requestPasswordReset, resetPassword, sendMagicLink, + setupTotp, signInWithCredentials, signInWithProvider, signOut, verifyEmail, + verifyPasskeyAuth, + verifyPasskeyRegistration, type AuthClientOptions, type AuthResponse, type AuthSession, + type ChangePasswordResponse, type EmailVerificationResponse, + type PasskeyInfo, type PasswordResetResponse, + type PreflightResponse, type RegisterCredentials, type RegisterResponse, type SignInCredentials, + type TotpSetupResponse, } from '@ottabase/auth/client'; diff --git a/apps/ottabase-template-app-tanstack/worker/lib/totp.ts b/apps/ottabase-template-app-tanstack/worker/lib/totp.ts new file mode 100644 index 000000000..72d611f91 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/lib/totp.ts @@ -0,0 +1,153 @@ +// ============================================================ +// TOTP (Time-based One-Time Password) - Edge-compatible +// ============================================================ +// +// Pure implementation using Web Crypto API (works on Cloudflare Workers). +// Implements RFC 6238 (TOTP) and RFC 4226 (HOTP) with HMAC-SHA1. +// +// ============================================================ + +const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +/** + * Encode a Uint8Array to a Base32 string (RFC 4648) + */ +export function base32Encode(buffer: Uint8Array): string { + let bits = 0; + let value = 0; + let output = ''; + + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_CHARS[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_CHARS[(value << (5 - bits)) & 31]; + } + + return output; +} + +/** + * Decode a Base32 string to a Uint8Array + */ +export function base32Decode(input: string): Uint8Array { + const cleaned = input.replace(/[\s=]/g, '').toUpperCase(); + const bytes: number[] = []; + let bits = 0; + let value = 0; + + for (const char of cleaned) { + const idx = BASE32_CHARS.indexOf(char); + if (idx === -1) { + throw new Error(`Invalid base32 character: ${char}`); + } + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + + return new Uint8Array(bytes); +} + +/** + * Generate a cryptographically random TOTP secret (20 bytes = 160 bits) + */ +export function generateTotpSecret(): string { + const buffer = crypto.getRandomValues(new Uint8Array(20)); + return base32Encode(buffer); +} + +/** + * Generate an otpauth:// URI for authenticator apps + */ +export function generateTotpUri(secret: string, email: string, issuer: string): string { + const encodedIssuer = encodeURIComponent(issuer); + const encodedEmail = encodeURIComponent(email); + return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`; +} + +/** + * Generate a TOTP code for the given secret and time step + */ +async function generateHotp(secret: Uint8Array, counter: bigint): Promise { + // Convert counter to 8-byte big-endian buffer + const counterBuffer = new ArrayBuffer(8); + const view = new DataView(counterBuffer); + view.setBigUint64(0, counter, false); + + // Import key for HMAC-SHA1 + const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + + // Generate HMAC + const hmac = await crypto.subtle.sign('HMAC', key, counterBuffer); + const hmacBytes = new Uint8Array(hmac); + + // Dynamic truncation (RFC 4226 Section 5.3) + const offset = hmacBytes[hmacBytes.length - 1] & 0x0f; + const code = + ((hmacBytes[offset] & 0x7f) << 24) | + ((hmacBytes[offset + 1] & 0xff) << 16) | + ((hmacBytes[offset + 2] & 0xff) << 8) | + (hmacBytes[offset + 3] & 0xff); + + // Return 6-digit code with leading zeros + return String(code % 1000000).padStart(6, '0'); +} + +/** + * Verify a TOTP code against a secret + * + * @param secret - Base32-encoded secret + * @param code - 6-digit TOTP code to verify + * @param window - Number of time steps to check before/after current (default: 1) + * @returns true if the code is valid + */ +export async function verifyTotp(secret: string, code: string, window = 1): Promise { + if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) { + return false; + } + + const secretBytes = base32Decode(secret); + const timeStep = BigInt(Math.floor(Date.now() / 30000)); + + // Check current time step and ±window + for (let i = -window; i <= window; i++) { + const step = timeStep + BigInt(i); + const expected = await generateHotp(secretBytes, step); + if (timingSafeEqual(code, expected)) { + return true; + } + } + + return false; +} + +/** + * Generate the current TOTP code (useful for testing) + */ +export async function generateTotp(secret: string): Promise { + const secretBytes = base32Decode(secret); + const timeStep = BigInt(Math.floor(Date.now() / 30000)); + return generateHotp(secretBytes, timeStep); +} + +/** + * Constant-time string comparison to prevent timing attacks + */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} diff --git a/apps/ottabase-template-app-tanstack/worker/routes/account-security.ts b/apps/ottabase-template-app-tanstack/worker/routes/account-security.ts new file mode 100644 index 000000000..fffc9a109 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/account-security.ts @@ -0,0 +1,821 @@ +// ============================================================ +// Account Security Routes +// ============================================================ +// +// Endpoints for password change, TOTP 2FA, and passkey management. +// +// ============================================================ + +import { getSession, hashPassword, verifyPassword } from '@ottabase/auth/backend'; +import { createD1Driver } from '@ottabase/db/drizzle-d1'; +import { registerConnection } from '@ottabase/ottaorm'; +import { Authenticator, User } from '@ottabase/ottaorm/models'; +import { errorResponse } from '@ottabase/utils/http-errors'; +import { jsonResponse } from '@ottabase/utils/http-response'; +import type { CloudflareEnv } from '../../cloudflare-env'; +import { getAuthOptions } from '../lib/auth-utils'; +import { enforceRateLimit } from '../lib/rate-limiting'; +import { getClientIpAddress, isStrongPassword, readJson } from '../lib/utils'; +import { generateTotpSecret, generateTotpUri, verifyTotp } from '../lib/totp'; + +interface SecurityRouteContext { + request: Request; + env: CloudflareEnv; + url: URL; + withAuthCors: (response: Response) => Response; +} + +// ── Helpers ─────────────────────────────────────────────────── + +async function requireSession(request: Request, env: CloudflareEnv) { + const session = await getSession(request, env as any, getAuthOptions(env)); + const userId = session?.user?.id; + if (!userId) return null; + return { userId, session }; +} + +function requireD1(env: CloudflareEnv) { + if (!env.OBCF_D1) { + return errorResponse('D1 database binding not configured', 500, { code: 'CONFIG_ERROR' }); + } + registerConnection('default', createD1Driver(env.OBCF_D1)); + return null; +} + +// ── Password Change ────────────────────────────────────────── + +export async function handlePasswordChange(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:password-change:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const body = await readJson<{ currentPassword?: string; newPassword?: string }>(request); + const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : ''; + const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''; + + const fieldErrors: Record = {}; + if (!currentPassword) { + fieldErrors.currentPassword = ['Current password is required']; + } + if (!newPassword) { + fieldErrors.newPassword = ['New password is required']; + } else if (!isStrongPassword(newPassword)) { + fieldErrors.newPassword = [ + 'Password must be at least 8 characters and include uppercase, lowercase, number, and symbol', + ]; + } + if (Object.keys(fieldErrors).length > 0) { + return withAuthCors(errorResponse('Validation failed', 400, { code: 'VALIDATION_ERROR', fieldErrors })); + } + + // Load user with password hash (hidden field, use raw query) + const row = await env.OBCF_D1!.prepare(`SELECT password_hash FROM users WHERE id = ? LIMIT 1`) + .bind(auth.userId) + .first<{ password_hash: string | null }>(); + + if (!row?.password_hash) { + return withAuthCors( + errorResponse('No password set for this account. Use OAuth or magic link to sign in.', 400, { + code: 'NO_PASSWORD', + }), + ); + } + + const valid = await verifyPassword(currentPassword, row.password_hash); + if (!valid) { + return withAuthCors(errorResponse('Current password is incorrect', 400, { code: 'INVALID_PASSWORD' })); + } + + const newHash = await hashPassword(newPassword); + const user = await User.find(auth.userId); + if (!user) { + return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + } + + user.set('passwordHash', newHash); + await user.save(); + + return withAuthCors(jsonResponse({ success: true })); +} + +// ── TOTP Setup ─────────────────────────────────────────────── + +export async function handleTotpSetup(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const user = await User.find(auth.userId); + if (!user) return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + + if (user.get('totpEnabled')) { + return withAuthCors(errorResponse('Two-factor authentication is already enabled', 400, { code: 'TOTP_ALREADY_ENABLED' })); + } + + const secret = generateTotpSecret(); + const email = user.get('email') as string; + const issuer = 'Ottabase'; + const uri = generateTotpUri(secret, email, issuer); + + return withAuthCors(jsonResponse({ secret, uri })); +} + +// ── TOTP Enable ────────────────────────────────────────────── + +export async function handleTotpEnable(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:totp-enable:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const body = await readJson<{ secret?: string; code?: string }>(request); + const secret = typeof body.secret === 'string' ? body.secret.trim() : ''; + const code = typeof body.code === 'string' ? body.code.trim() : ''; + + if (!secret || !code) { + return withAuthCors(errorResponse('Secret and verification code are required', 400, { code: 'VALIDATION_ERROR' })); + } + + // Verify the code against the provided secret + const valid = await verifyTotp(secret, code); + if (!valid) { + return withAuthCors(errorResponse('Invalid verification code. Please try again.', 400, { code: 'INVALID_TOTP' })); + } + + const user = await User.find(auth.userId); + if (!user) return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + + if (user.get('totpEnabled')) { + return withAuthCors(errorResponse('Two-factor authentication is already enabled', 400, { code: 'TOTP_ALREADY_ENABLED' })); + } + + user.set('totpSecret', secret); + user.set('totpEnabled', 1); + await user.save(); + + return withAuthCors(jsonResponse({ success: true })); +} + +// ── TOTP Disable ───────────────────────────────────────────── + +export async function handleTotpDisable(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:totp-disable:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const body = await readJson<{ code?: string }>(request); + const code = typeof body.code === 'string' ? body.code.trim() : ''; + + if (!code) { + return withAuthCors(errorResponse('Verification code is required', 400, { code: 'VALIDATION_ERROR' })); + } + + const user = await User.find(auth.userId); + if (!user) return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + + if (!user.get('totpEnabled')) { + return withAuthCors(errorResponse('Two-factor authentication is not enabled', 400, { code: 'TOTP_NOT_ENABLED' })); + } + + // Verify the code against the stored secret (need raw query since totpSecret is hidden) + const row = await env.OBCF_D1!.prepare(`SELECT totp_secret FROM users WHERE id = ? LIMIT 1`) + .bind(auth.userId) + .first<{ totp_secret: string | null }>(); + + if (!row?.totp_secret) { + return withAuthCors(errorResponse('TOTP secret not found', 500, { code: 'INTERNAL_ERROR' })); + } + + const valid = await verifyTotp(row.totp_secret, code); + if (!valid) { + return withAuthCors(errorResponse('Invalid verification code', 400, { code: 'INVALID_TOTP' })); + } + + user.set('totpSecret', null); + user.set('totpEnabled', 0); + await user.save(); + + return withAuthCors(jsonResponse({ success: true })); +} + +// ── Credentials Preflight (for TOTP-aware login) ───────────── + +export async function handleCredentialsPreflight(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:preflight:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + if (!env.OBCF_D1) { + return withAuthCors(errorResponse('D1 database binding not configured', 500, { code: 'CONFIG_ERROR' })); + } + + const body = await readJson<{ email?: string; password?: string }>(request); + const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; + const password = typeof body.password === 'string' ? body.password : ''; + + if (!email || !password) { + return withAuthCors(jsonResponse({ valid: false })); + } + + const result = await env.OBCF_D1.prepare( + `SELECT id, password_hash, totp_enabled FROM users WHERE email = ? LIMIT 1`, + ) + .bind(email) + .first<{ id: string; password_hash: string | null; totp_enabled: number }>(); + + if (!result?.password_hash) { + return withAuthCors(jsonResponse({ valid: false })); + } + + const valid = await verifyPassword(password, result.password_hash); + if (!valid) { + return withAuthCors(jsonResponse({ valid: false })); + } + + return withAuthCors(jsonResponse({ valid: true, totpRequired: !!result.totp_enabled })); +} + +// ── Passkeys: List ─────────────────────────────────────────── + +export async function handlePasskeysList(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const authenticators = await Authenticator.findByUserId(auth.userId); + const passkeys = authenticators.map((a) => ({ + id: a.get('id'), + credentialId: a.get('credentialId'), + credentialDeviceType: a.get('credentialDeviceType'), + credentialBackedUp: a.get('credentialBackedUp'), + transports: a.get('transports'), + createdAt: a.get('createdAt'), + })); + + return withAuthCors(jsonResponse({ passkeys })); +} + +// ── Passkeys: Registration Options ────────────────────────── + +export async function handlePasskeysRegisterOptions(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const user = await User.find(auth.userId); + if (!user) return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + + const existingAuthenticators = await Authenticator.findByUserId(auth.userId); + + // Build WebAuthn registration options (SimpleWebAuthn-compatible format) + const rpName = 'Ottabase'; + const rpID = new URL(request.url).hostname; + const userEmail = user.get('email') as string; + const userName = (user.get('name') as string) || userEmail; + + // Generate a random challenge + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const challengeB64 = bufferToBase64Url(challenge); + + // Store challenge in KV for verification (expires in 5 minutes) + if (env.OBCF_KV) { + await env.OBCF_KV.put(`webauthn:challenge:${auth.userId}`, challengeB64, { expirationTtl: 300 }); + } + + const excludeCredentials = existingAuthenticators.map((a) => ({ + id: a.get('credentialId') as string, + type: 'public-key' as const, + transports: ((a.get('transports') as string) || '').split(',').filter(Boolean), + })); + + const options = { + challenge: challengeB64, + rp: { name: rpName, id: rpID }, + user: { + id: bufferToBase64Url(new TextEncoder().encode(auth.userId)), + name: userEmail, + displayName: userName, + }, + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, // ES256 + { alg: -257, type: 'public-key' }, // RS256 + ], + timeout: 60000, + attestation: 'none', + excludeCredentials, + authenticatorSelection: { + authenticatorAttachment: 'platform' as const, + residentKey: 'preferred' as const, + requireResidentKey: false, + userVerification: 'preferred' as const, + }, + }; + + return withAuthCors(jsonResponse({ options })); +} + +// ── Passkeys: Verify Registration ─────────────────────────── + +export async function handlePasskeysRegisterVerify(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + if (!env.OBCF_KV) { + return withAuthCors(errorResponse('KV not configured', 500, { code: 'CONFIG_ERROR' })); + } + + // Retrieve the expected challenge + const expectedChallenge = await env.OBCF_KV.get(`webauthn:challenge:${auth.userId}`); + if (!expectedChallenge) { + return withAuthCors(errorResponse('Challenge expired or not found', 400, { code: 'CHALLENGE_EXPIRED' })); + } + + // Clean up the challenge + await env.OBCF_KV.delete(`webauthn:challenge:${auth.userId}`); + + const body = await readJson<{ + id: string; + rawId: string; + response: { + clientDataJSON: string; + attestationObject: string; + }; + type: string; + authenticatorAttachment?: string; + }>(request); + + if (!body.id || !body.rawId || !body.response?.clientDataJSON || !body.response?.attestationObject) { + return withAuthCors(errorResponse('Invalid registration response', 400, { code: 'VALIDATION_ERROR' })); + } + + // Decode clientDataJSON to verify challenge and origin + const clientDataRaw = base64UrlToBuffer(body.response.clientDataJSON); + const clientData = JSON.parse(new TextDecoder().decode(clientDataRaw)); + + if (clientData.type !== 'webauthn.create') { + return withAuthCors(errorResponse('Invalid client data type', 400, { code: 'INVALID_CLIENT_DATA' })); + } + + if (clientData.challenge !== expectedChallenge) { + return withAuthCors(errorResponse('Challenge mismatch', 400, { code: 'CHALLENGE_MISMATCH' })); + } + + const expectedOrigin = new URL(request.url).origin; + // In dev, the frontend may be on a different port + const validOrigins = [expectedOrigin]; + const authUrl = (env as any).AUTH_URL || (env as any).NEXTAUTH_URL; + if (authUrl) validOrigins.push(new URL(authUrl).origin); + // Also allow the frontend origin (port 3003 in dev) + const reqOrigin = new URL(request.url); + if (reqOrigin.port === '3004') { + validOrigins.push(reqOrigin.origin.replace(':3004', ':3003')); + } + + if (!validOrigins.includes(clientData.origin)) { + return withAuthCors(errorResponse('Origin mismatch', 400, { code: 'ORIGIN_MISMATCH' })); + } + + // Parse attestation object to extract credential public key + // For "none" attestation, we trust the credential directly + const attestationBuffer = base64UrlToBuffer(body.response.attestationObject); + const attestation = decodeCborSimple(attestationBuffer); + + if (!attestation || !attestation.authData) { + return withAuthCors(errorResponse('Invalid attestation', 400, { code: 'INVALID_ATTESTATION' })); + } + + // Parse authenticator data + const authData = new Uint8Array(attestation.authData); + // rpIdHash (32) + flags (1) + signCount (4) + const flags = authData[32]; + const hasAttestedCred = (flags & 0x40) !== 0; + if (!hasAttestedCred) { + return withAuthCors(errorResponse('No attested credential data', 400, { code: 'NO_CREDENTIAL_DATA' })); + } + + const signCount = new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0, false); + + // Parse attested credential data + // AAGUID (16) + credIdLength (2) + credId (credIdLength) + credentialPublicKey (remaining) + let offset = 37; // 32 + 1 + 4 + // skip AAGUID + offset += 16; + const credIdLength = (authData[offset] << 8) | authData[offset + 1]; + offset += 2; + const credentialIdBytes = authData.slice(offset, offset + credIdLength); + offset += credIdLength; + const credentialPublicKeyBytes = authData.slice(offset); + + const credentialId = bufferToBase64Url(credentialIdBytes); + const credentialPublicKey = bufferToBase64Url(credentialPublicKeyBytes); + + // Determine device type + const backupEligible = (flags & 0x08) !== 0; + const backedUp = (flags & 0x10) !== 0; + const deviceType = backupEligible ? 'multiDevice' : 'singleDevice'; + + // Determine transports from authenticatorAttachment + const transports: string[] = []; + if (body.authenticatorAttachment === 'platform') { + transports.push('internal'); + } else if (body.authenticatorAttachment === 'cross-platform') { + transports.push('usb', 'ble', 'nfc'); + } + + // Store the credential + await Authenticator.create({ + credentialId, + userId: auth.userId, + providerAccountId: auth.userId, + credentialPublicKey, + counter: signCount, + credentialDeviceType: deviceType, + credentialBackedUp: backedUp ? 1 : 0, + transports: transports.join(','), + }); + + return withAuthCors(jsonResponse({ success: true, credentialId })); +} + +// ── Passkeys: Delete ───────────────────────────────────────── + +export async function handlePasskeyDelete(ctx: SecurityRouteContext, passkeyId: string): Promise { + const { request, env, withAuthCors } = ctx; + + const d1Error = requireD1(env); + if (d1Error) return withAuthCors(d1Error); + + const auth = await requireSession(request, env); + if (!auth) return withAuthCors(errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' })); + + const authenticator = await Authenticator.find(passkeyId); + if (!authenticator || authenticator.get('userId') !== auth.userId) { + return withAuthCors(errorResponse('Passkey not found', 404, { code: 'NOT_FOUND' })); + } + + await authenticator.delete(); + + return withAuthCors(jsonResponse({ success: true })); +} + +// ── Passkeys: Authentication Options (for login) ───────────── + +export async function handlePasskeysAuthOptions(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:passkey-auth:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + if (!env.OBCF_D1) { + return withAuthCors(errorResponse('D1 not configured', 500, { code: 'CONFIG_ERROR' })); + } + + const rpID = new URL(request.url).hostname; + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const challengeB64 = bufferToBase64Url(challenge); + + // Store challenge keyed by IP (no user context yet) + if (env.OBCF_KV) { + await env.OBCF_KV.put(`webauthn:auth-challenge:${ip}`, challengeB64, { expirationTtl: 300 }); + } + + const options = { + challenge: challengeB64, + rpId: rpID, + timeout: 60000, + userVerification: 'preferred', + allowCredentials: [], // Empty = discoverable credential (passkey) + }; + + return withAuthCors(jsonResponse({ options })); +} + +// ── Passkeys: Verify Authentication (for login) ────────────── + +export async function handlePasskeysAuthVerify(ctx: SecurityRouteContext): Promise { + const { request, env, withAuthCors } = ctx; + + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `auth:passkey-verify:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + if (!env.OBCF_D1 || !env.OBCF_KV) { + return withAuthCors(errorResponse('D1/KV not configured', 500, { code: 'CONFIG_ERROR' })); + } + + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const expectedChallenge = await env.OBCF_KV.get(`webauthn:auth-challenge:${ip}`); + if (!expectedChallenge) { + return withAuthCors(errorResponse('Challenge expired', 400, { code: 'CHALLENGE_EXPIRED' })); + } + await env.OBCF_KV.delete(`webauthn:auth-challenge:${ip}`); + + const body = await readJson<{ + id: string; + rawId: string; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle?: string; + }; + type: string; + }>(request); + + if (!body.id || !body.response?.clientDataJSON || !body.response?.authenticatorData || !body.response?.signature) { + return withAuthCors(errorResponse('Invalid authentication response', 400, { code: 'VALIDATION_ERROR' })); + } + + // Decode and verify clientDataJSON + const clientDataRaw = base64UrlToBuffer(body.response.clientDataJSON); + const clientData = JSON.parse(new TextDecoder().decode(clientDataRaw)); + + if (clientData.type !== 'webauthn.get') { + return withAuthCors(errorResponse('Invalid client data type', 400, { code: 'INVALID_CLIENT_DATA' })); + } + + if (clientData.challenge !== expectedChallenge) { + return withAuthCors(errorResponse('Challenge mismatch', 400, { code: 'CHALLENGE_MISMATCH' })); + } + + // Find the authenticator by credential ID + const credentialId = body.id; + const authenticator = await Authenticator.findByCredentialId(credentialId); + if (!authenticator) { + return withAuthCors(errorResponse('Passkey not recognized', 400, { code: 'UNKNOWN_CREDENTIAL' })); + } + + // Verify the signature + const authDataBuffer = base64UrlToBuffer(body.response.authenticatorData); + const signatureBuffer = base64UrlToBuffer(body.response.signature); + + // Compute hash of clientDataJSON + const clientDataHash = new Uint8Array(await crypto.subtle.digest('SHA-256', clientDataRaw)); + + // Concatenate authenticatorData + clientDataHash to form the signed data + const signedData = new Uint8Array(authDataBuffer.length + clientDataHash.length); + signedData.set(new Uint8Array(authDataBuffer), 0); + signedData.set(clientDataHash, authDataBuffer.length); + + // Import the stored public key and verify + const pubKeyB64 = authenticator.get('credentialPublicKey') as string; + const pubKeyBuffer = base64UrlToBuffer(pubKeyB64); + + let verified = false; + try { + // Parse the COSE public key to extract algorithm and key data + const coseKey = decodeCborSimple(pubKeyBuffer); + if (coseKey) { + const cryptoKey = await importCosePublicKey(coseKey); + if (cryptoKey) { + const algo = cryptoKey.algorithm; + verified = await crypto.subtle.verify( + algo.name === 'ECDSA' ? { name: 'ECDSA', hash: 'SHA-256' } : algo, + cryptoKey, + signatureBuffer, + signedData, + ); + } + } + } catch (error) { + console.warn('WebAuthn signature verification failed:', error); + return withAuthCors(errorResponse('Signature verification failed', 400, { code: 'VERIFICATION_FAILED' })); + } + + if (!verified) { + return withAuthCors(errorResponse('Invalid passkey signature', 400, { code: 'INVALID_SIGNATURE' })); + } + + // Update counter + const newCount = new DataView(authDataBuffer.buffer, authDataBuffer.byteOffset + 33, 4).getUint32(0, false); + await authenticator.updateCounter(newCount); + + // Look up the user + const userId = authenticator.get('userId') as string; + const user = await User.find(userId); + if (!user) { + return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + } + + // Return user info so the frontend can create a session via Auth.js + return withAuthCors( + jsonResponse({ + success: true, + user: { + id: user.get('id'), + email: user.get('email'), + name: user.get('name'), + image: user.get('image'), + }, + }), + ); +} + +// ── Buffer Utilities ───────────────────────────────────────── + +function bufferToBase64Url(buffer: Uint8Array): string { + let binary = ''; + for (const byte of buffer) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function base64UrlToBuffer(base64url: string): Uint8Array { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// ── Minimal CBOR Decoder ───────────────────────────────────── +// Supports only the subset needed for WebAuthn attestation/COSE keys + +function decodeCborSimple(data: Uint8Array): any { + let offset = 0; + + function readByte(): number { + return data[offset++]; + } + + function readBytes(n: number): Uint8Array { + const slice = data.slice(offset, offset + n); + offset += n; + return slice; + } + + function readUint16(): number { + const val = (data[offset] << 8) | data[offset + 1]; + offset += 2; + return val; + } + + function readUint32(): number { + const val = (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; + offset += 4; + return val >>> 0; + } + + function decodeLength(additional: number): number { + if (additional < 24) return additional; + if (additional === 24) return readByte(); + if (additional === 25) return readUint16(); + if (additional === 26) return readUint32(); + throw new Error('CBOR: unsupported length'); + } + + function decode(): any { + if (offset >= data.length) return undefined; + + const initial = readByte(); + const major = initial >> 5; + const additional = initial & 0x1f; + + switch (major) { + case 0: // unsigned integer + return decodeLength(additional); + case 1: // negative integer + return -1 - decodeLength(additional); + case 2: { // byte string + const len = decodeLength(additional); + return readBytes(len); + } + case 3: { // text string + const len = decodeLength(additional); + return new TextDecoder().decode(readBytes(len)); + } + case 4: { // array + const len = decodeLength(additional); + const arr: any[] = []; + for (let i = 0; i < len; i++) { + arr.push(decode()); + } + return arr; + } + case 5: { // map + const len = decodeLength(additional); + const obj: Record = {}; + for (let i = 0; i < len; i++) { + const key = decode(); + const value = decode(); + obj[key] = value; + } + return obj; + } + case 7: { // simple/float + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + return undefined; + } + default: + throw new Error(`CBOR: unsupported major type ${major}`); + } + } + + try { + return decode(); + } catch { + return null; + } +} + +// ── COSE Key Import ────────────────────────────────────────── + +async function importCosePublicKey(coseKey: Record): Promise { + const kty = coseKey[1]; // Key type + const alg = coseKey[3]; // Algorithm + + if (kty === 2) { + // EC2 key (ECDSA) + const crv = coseKey[-1]; + const x = coseKey[-2]; + const y = coseKey[-3]; + + if (!x || !y) return null; + + const namedCurve = crv === 1 ? 'P-256' : crv === 2 ? 'P-384' : crv === 3 ? 'P-521' : null; + if (!namedCurve) return null; + + // Build uncompressed point format: 0x04 || x || y + const xBytes = x instanceof Uint8Array ? x : new Uint8Array(0); + const yBytes = y instanceof Uint8Array ? y : new Uint8Array(0); + const rawKey = new Uint8Array(1 + xBytes.length + yBytes.length); + rawKey[0] = 0x04; + rawKey.set(xBytes, 1); + rawKey.set(yBytes, 1 + xBytes.length); + + return crypto.subtle.importKey('raw', rawKey, { name: 'ECDSA', namedCurve }, false, ['verify']); + } + + if (kty === 3) { + // RSA key + const n = coseKey[-1]; + const e = coseKey[-2]; + + if (!n || !e) return null; + + const nBytes = n instanceof Uint8Array ? n : new Uint8Array(0); + const eBytes = e instanceof Uint8Array ? e : new Uint8Array(0); + + // Build JWK + const jwk = { + kty: 'RSA', + n: bufferToBase64Url(nBytes), + e: bufferToBase64Url(eBytes), + alg: alg === -257 ? 'RS256' : 'RS256', + }; + + return crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']); + } + + return null; +} diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index 622e2e1a4..3e9c65d97 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -46,6 +46,19 @@ import { handleVerifyEmail, handleVerifyEmailResend, } from './auth'; +import { + handleCredentialsPreflight, + handlePasskeyDelete, + handlePasskeysAuthOptions, + handlePasskeysAuthVerify, + handlePasskeysList, + handlePasskeysRegisterOptions, + handlePasskeysRegisterVerify, + handlePasswordChange, + handleTotpDisable, + handleTotpEnable, + handleTotpSetup, +} from './account-security'; import { handleBlogPostBySlug, handleBlogPostUnlock, @@ -180,6 +193,10 @@ async function handleGetRoutes(context: ApiRouteContext): Promise= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return new Uint8Array(bytes); +} + +async function generateHotpCode(secret: Uint8Array, counter: bigint): Promise { + const counterBuf = new ArrayBuffer(8); + new DataView(counterBuf).setBigUint64(0, counter, false); + const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuf)); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return String(code % 1000000).padStart(6, '0'); +} + +function totpTimingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i); + return result === 0; +} + +async function verifyTotpCode(secret: string, code: string, window = 1): Promise { + if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) return false; + const secretBytes = base32DecodeTOTP(secret); + const timeStep = BigInt(Math.floor(Date.now() / 30000)); + for (let i = -window; i <= window; i++) { + const expected = await generateHotpCode(secretBytes, timeStep + BigInt(i)); + if (totpTimingSafeEqual(code, expected)) return true; + } + return false; +} +// ── End TOTP ───────────────────────────────────────────────── + /** * Environment interface for auth handler * Your CloudflareEnv should extend this @@ -74,12 +128,13 @@ export interface CredentialsAuthorizeOptions { * In production, override with your own validation logic */ async function defaultCredentialsAuthorize( - credentials: { email: string; password: string }, + credentials: { email: string; password: string; totp?: string }, env: AuthEnv, options?: CredentialsAuthorizeOptions, ): Promise { const email = typeof credentials.email === 'string' ? credentials.email.trim().toLowerCase() : ''; const password = typeof credentials.password === 'string' ? credentials.password : ''; + const totpCode = typeof credentials.totp === 'string' ? credentials.totp.trim() : ''; const minLength = options?.minPasswordLength ?? 6; if (!email || !password) { @@ -98,7 +153,7 @@ async function defaultCredentialsAuthorize( let result: any | null = null; try { result = await env.OBCF_D1.prepare( - `SELECT id, name, email, image, email_verified, password_hash + `SELECT id, name, email, image, email_verified, password_hash, totp_enabled, totp_secret FROM users WHERE email = ?`, ) @@ -109,7 +164,22 @@ async function defaultCredentialsAuthorize( if (message.includes('no such column: email_verified') || message.includes('no such column: password_hash')) { throw new Error('Missing auth columns on users table. Run /api/ottaorm/init to apply migrations.'); } - throw error; + // Gracefully handle missing TOTP columns (pre-migration) + if (message.includes('no such column: totp_')) { + try { + result = await env.OBCF_D1.prepare( + `SELECT id, name, email, image, email_verified, password_hash + FROM users + WHERE email = ?`, + ) + .bind(email) + .first(); + } catch { + throw error; + } + } else { + throw error; + } } if (!result || !result.password_hash) { @@ -124,6 +194,18 @@ async function defaultCredentialsAuthorize( return null; } + // TOTP verification: if enabled, require valid code + if (result.totp_enabled && result.totp_secret) { + if (!totpCode) { + // No TOTP code provided but required — reject + return null; + } + const totpValid = await verifyTotpCode(result.totp_secret, totpCode); + if (!totpValid) { + return null; + } + } + const emailVerifiedMs = result.email_verified ? Number(result.email_verified) : null; if (options?.requireVerifiedEmail && !emailVerifiedMs) { return null; diff --git a/packages/auth/src/client-api.ts b/packages/auth/src/client-api.ts index acf33a427..429de53b0 100644 --- a/packages/auth/src/client-api.ts +++ b/packages/auth/src/client-api.ts @@ -639,3 +639,355 @@ export async function resetPassword( }; } } + +// ── Password Change (while logged in) ──────────────────────── + +/** + * Change password response + */ +export interface ChangePasswordResponse { + success: boolean; + error?: string; +} + +/** + * Change password while logged in + */ +export async function changePassword( + data: { currentPassword: string; newPassword: string }, + options?: { clientOptions?: AuthClientOptions }, +): Promise { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/password/change`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Password change failed' }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Password change failed' }; + } +} + +// ── TOTP 2FA ───────────────────────────────────────────────── + +/** + * TOTP setup response + */ +export interface TotpSetupResponse { + success: boolean; + secret?: string; + uri?: string; + error?: string; +} + +/** + * Request TOTP setup (generate secret + URI) + */ +export async function setupTotp( + options?: { clientOptions?: AuthClientOptions }, +): Promise { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/totp/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'TOTP setup failed' }; + } + + return { success: true, secret: payload.secret, uri: payload.uri }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'TOTP setup failed' }; + } +} + +/** + * Enable TOTP with verification code + */ +export async function enableTotp( + data: { secret: string; code: string }, + options?: { clientOptions?: AuthClientOptions }, +): Promise { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/totp/enable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Failed to enable 2FA' }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to enable 2FA' }; + } +} + +/** + * Disable TOTP with verification code + */ +export async function disableTotp( + code: string, + options?: { clientOptions?: AuthClientOptions }, +): Promise { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/totp/disable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ code }), + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Failed to disable 2FA' }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to disable 2FA' }; + } +} + +// ── Credentials Preflight (TOTP-aware login) ───────────────── + +/** + * Preflight credentials check response + */ +export interface PreflightResponse { + valid: boolean; + totpRequired?: boolean; +} + +/** + * Preflight check for credentials login. + * Validates email+password and indicates if TOTP is required. + */ +export async function preflightCredentials( + data: { email: string; password: string }, + options?: { clientOptions?: AuthClientOptions }, +): Promise { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/credentials/preflight`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + + const payload = await response.json().catch(() => ({ valid: false })); + return { valid: !!payload.valid, totpRequired: !!payload.totpRequired }; + } catch { + return { valid: false }; + } +} + +// ── Passkeys ───────────────────────────────────────────────── + +/** + * Passkey info returned from the API + */ +export interface PasskeyInfo { + id: string; + credentialId: string; + credentialDeviceType: string; + credentialBackedUp: boolean; + transports: string; + createdAt: number; +} + +/** + * List registered passkeys for the current user + */ +export async function listPasskeys( + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ passkeys: PasskeyInfo[]; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + const payload = await response.json().catch(() => ({ passkeys: [] })); + + if (!response.ok) { + return { passkeys: [], error: payload.error || 'Failed to load passkeys' }; + } + + return { passkeys: payload.passkeys || [] }; + } catch (error) { + return { passkeys: [], error: error instanceof Error ? error.message : 'Failed to load passkeys' }; + } +} + +/** + * Get WebAuthn registration options for adding a new passkey + */ +export async function getPasskeyRegisterOptions( + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ options?: any; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys/register-options`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + return { error: payload.error || 'Failed to get registration options' }; + } + + return { options: payload.options }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Failed to get registration options' }; + } +} + +/** + * Verify and store a WebAuthn registration response + */ +export async function verifyPasskeyRegistration( + credential: any, + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ success: boolean; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys/register-verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(credential), + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Registration failed' }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Registration failed' }; + } +} + +/** + * Delete a passkey + */ +export async function deletePasskey( + passkeyId: string, + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ success: boolean; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys/${encodeURIComponent(passkeyId)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Failed to delete passkey' }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to delete passkey' }; + } +} + +/** + * Get WebAuthn authentication options (for passkey login) + */ +export async function getPasskeyAuthOptions( + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ options?: any; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys/auth-options`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + return { error: payload.error || 'Failed to get authentication options' }; + } + + return { options: payload.options }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Failed to get authentication options' }; + } +} + +/** + * Verify a WebAuthn authentication response (passkey login) + */ +export async function verifyPasskeyAuth( + credential: any, + options?: { clientOptions?: AuthClientOptions }, +): Promise<{ success: boolean; user?: any; error?: string }> { + const baseUrl = options?.clientOptions?.baseUrl ?? defaultOptions.baseUrl; + + try { + const response = await fetch(`${baseUrl}/passkeys/auth-verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(credential), + }); + + const payload = await response.json().catch(() => ({ error: 'Request failed' })); + + if (!response.ok) { + return { success: false, error: payload.error || 'Authentication failed' }; + } + + return { success: true, user: payload.user }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Authentication failed' }; + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index ae8281e6a..8b931a3aa 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -90,26 +90,41 @@ export { // CLIENT API (Frontend) // ============================================================ export { + changePassword, + deletePasskey, + disableTotp, + enableTotp, getCsrfToken, + getPasskeyAuthOptions, + getPasskeyRegisterOptions, getSession as getSessionClient, isAuthenticated as isAuthenticatedClient, + listPasskeys, + preflightCredentials, registerWithCredentials, requestEmailVerification, requestPasswordReset, resetPassword, sendMagicLink, + setupTotp, signInWithCredentials, signInWithProvider, signOut, verifyEmail, + verifyPasskeyAuth, + verifyPasskeyRegistration, type AuthClientOptions, type AuthResponse, type AuthSession, + type ChangePasswordResponse, type EmailVerificationResponse, + type PasskeyInfo, type PasswordResetResponse, + type PreflightResponse, type RegisterCredentials, type RegisterResponse, type SignInCredentials, + type TotpSetupResponse, } from './client-api'; // ============================================================ diff --git a/packages/auth/src/providers.ts b/packages/auth/src/providers.ts index 3a07d57f6..20625ccea 100644 --- a/packages/auth/src/providers.ts +++ b/packages/auth/src/providers.ts @@ -389,6 +389,7 @@ export function createCredentialsProvider(authorize: (credentials: Record