diff --git a/packages/auth-client/src/__tests__/smartauth.test.ts b/packages/auth-client/src/__tests__/smartauth.test.ts new file mode 100644 index 00000000..68117c4d --- /dev/null +++ b/packages/auth-client/src/__tests__/smartauth.test.ts @@ -0,0 +1,518 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createAuthClient } from '../client.js'; +import type { TokenStorage } from '../types.js'; + +function createMockStorage(): TokenStorage & { store: Map } { + const store = new Map(); + return { + store, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }; +} + +function mockFetchResponse(data: unknown, status = 200) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + }); +} + +describe('@bytelyst/auth-client — SmartAuth', () => { + let storage: ReturnType; + + beforeEach(() => { + storage = createMockStorage(); + vi.restoreAllMocks(); + }); + + // ── Phase 1C: Google Sign-In ────────────────────── + + describe('loginWithGoogle', () => { + it('calls POST /auth/oauth/google and stores tokens', async () => { + const mockData = { + accessToken: 'google-at', + refreshToken: 'google-rt', + user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.loginWithGoogle('google-id-token-123'); + + expect('user' in result).toBe(true); + if ('user' in result) { + expect(result.user.email).toBe('g@gmail.com'); + } + expect(client.getAccessToken()).toBe('google-at'); + expect(client.getRefreshToken()).toBe('google-rt'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/google'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.idToken).toBe('google-id-token-123'); + }); + + it('returns MFA challenge when mfaRequired is true', async () => { + const mfaData = { + mfaRequired: true, + mfaChallenge: 'challenge-token-abc', + methods: ['totp'], + }; + globalThis.fetch = mockFetchResponse(mfaData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.loginWithGoogle('google-id-token-456'); + + expect('mfaRequired' in result).toBe(true); + if ('mfaRequired' in result) { + expect(result.mfaRequired).toBe(true); + expect(result.mfaChallenge).toBe('challenge-token-abc'); + expect(result.methods).toEqual(['totp']); + } + // Tokens should NOT be stored when MFA is required + expect(client.isAuthenticated()).toBe(false); + }); + }); + + describe('loginWithMicrosoft', () => { + it('calls POST /auth/oauth/microsoft', async () => { + const mockData = { + accessToken: 'ms-at', + refreshToken: 'ms-rt', + user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + await client.loginWithMicrosoft('ms-id-token'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft'); + expect(client.getAccessToken()).toBe('ms-at'); + }); + }); + + describe('loginWithApple', () => { + it('calls POST /auth/oauth/apple', async () => { + const mockData = { + accessToken: 'apple-at', + refreshToken: 'apple-rt', + user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + await client.loginWithApple('apple-id-token'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/apple'); + expect(client.getAccessToken()).toBe('apple-at'); + }); + }); + + // ── Phase 1C: Provider management ───────────────── + + describe('getProviders', () => { + it('calls GET /auth/providers', async () => { + const providers = [ + { + provider: 'google', + email: 'g@gmail.com', + linkedAt: '2025-01-01T00:00:00Z', + lastUsedAt: null, + }, + ]; + globalThis.fetch = mockFetchResponse(providers); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.getProviders(); + + expect(result).toHaveLength(1); + expect(result[0].provider).toBe('google'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers'); + expect(opts.method).toBe('GET'); + expect(opts.headers['Authorization']).toBe('Bearer tok'); + }); + }); + + describe('linkProvider', () => { + it('calls POST /auth/providers/link with provider and idToken', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.linkProvider('google', 'link-token'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers/link'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.provider).toBe('google'); + expect(body.idToken).toBe('link-token'); + }); + }); + + describe('unlinkProvider', () => { + it('calls DELETE /auth/providers/:provider', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.unlinkProvider('google'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers/google'); + expect(opts.method).toBe('DELETE'); + }); + }); + + // ── Phase 2D: MFA ───────────────────────────────── + + describe('verifyMfa', () => { + it('sends challenge token and TOTP code, stores tokens', async () => { + const mockData = { + accessToken: 'mfa-at', + refreshToken: 'mfa-rt', + user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.verifyMfa('challenge-xyz', '123456', 'totp'); + + expect(result.user.email).toBe('a@b.com'); + expect(client.getAccessToken()).toBe('mfa-at'); + + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.challengeToken).toBe('challenge-xyz'); + expect(body.code).toBe('123456'); + expect(body.method).toBe('totp'); + }); + }); + + describe('setupTotp', () => { + it('calls POST /auth/mfa/totp/setup', async () => { + const setup = { + otpauthUri: 'otpauth://totp/Test?secret=ABC', + qrDataUrl: 'data:image/png;base64,...', + recoveryCodes: ['code1', 'code2'], + }; + globalThis.fetch = mockFetchResponse(setup); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.setupTotp(); + + expect(result.otpauthUri).toContain('otpauth://'); + expect(result.recoveryCodes).toHaveLength(2); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/mfa/totp/setup'); + }); + }); + + describe('getMfaStatus', () => { + it('returns MFA status', async () => { + const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 }; + globalThis.fetch = mockFetchResponse(status); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.getMfaStatus(); + expect(result.mfaEnabled).toBe(true); + expect(result.methods).toContain('totp'); + }); + }); + + // ── Phase 3: Passkeys ───────────────────────────── + + describe('listPasskeys', () => { + it('calls GET /auth/passkeys', async () => { + const passkeys = [ + { + id: 'pk1', + friendlyName: 'MacBook', + deviceType: 'platform', + backedUp: true, + lastUsedAt: null, + createdAt: '2025-01-01T00:00:00Z', + }, + ]; + globalThis.fetch = mockFetchResponse(passkeys); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.listPasskeys(); + expect(result).toHaveLength(1); + expect(result[0].friendlyName).toBe('MacBook'); + }); + }); + + describe('verifyPasskeyAuth', () => { + it('stores tokens after passkey authentication', async () => { + const mockData = { + accessToken: 'pk-at', + refreshToken: 'pk-rt', + user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.verifyPasskeyAuth({ id: 'cred-1', response: {} }); + + expect(result.user.email).toBe('a@b.com'); + expect(client.getAccessToken()).toBe('pk-at'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify'); + }); + }); + + // ── Phase 3: Devices ────────────────────────────── + + describe('listDevices', () => { + it('calls GET /auth/devices', async () => { + const devices = [ + { + id: 'd1', + name: 'Chrome on Mac', + platform: 'web', + trustLevel: 'trusted', + trustExpiresAt: '2025-04-01T00:00:00Z', + lastLoginAt: '2025-01-01T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + }, + ]; + globalThis.fetch = mockFetchResponse(devices); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.listDevices(); + expect(result).toHaveLength(1); + expect(result[0].trustLevel).toBe('trusted'); + }); + }); + + describe('revokeDevice', () => { + it('calls DELETE /auth/devices/:id', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.revokeDevice('device-xyz'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/device-xyz'); + expect(opts.method).toBe('DELETE'); + }); + }); + + // ── Phase 5B: Admin security ────────────────────── + + describe('getSecurityOverview', () => { + it('calls GET /auth/security/overview', async () => { + const overview = { + totalUsers: 100, + mfaAdoptionPercent: 42.5, + providerDistribution: { google: 60, microsoft: 30, password: 10 }, + activeSessions: 250, + suspiciousEvents24h: 3, + }; + globalThis.fetch = mockFetchResponse(overview); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('admin-tok', 'admin-ref'); + + const result = await client.getSecurityOverview(); + expect(result.totalUsers).toBe(100); + expect(result.mfaAdoptionPercent).toBe(42.5); + }); + }); + + describe('unlockUser', () => { + it('calls POST /auth/users/:id/unlock', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('admin-tok', 'admin-ref'); + + await client.unlockUser('user-locked-123'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock'); + expect(opts.method).toBe('POST'); + }); + }); + + describe('cancelDeletion', () => { + it('calls POST /auth/account/cancel-deletion', async () => { + globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' }); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.cancelDeletion(); + expect(result.message).toBe('Deletion cancelled'); + }); + }); + + // ── login() MFA flow ────────────────────────────── + + describe('login with MFA challenge', () => { + it('returns MfaLoginResult and does not store tokens', async () => { + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'login-challenge', + methods: ['totp', 'recovery'], + }; + globalThis.fetch = mockFetchResponse(mfaResponse); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.login('user@test.com', 'password'); + + expect('mfaRequired' in result).toBe(true); + if ('mfaRequired' in result) { + expect(result.mfaChallenge).toBe('login-challenge'); + expect(result.methods).toEqual(['totp', 'recovery']); + } + expect(client.isAuthenticated()).toBe(false); + }); + }); + + // ── createAuthClient includes all SmartAuth methods ── + + describe('client exposes all SmartAuth methods', () => { + it('has all phase 1C, 2D, 3, 5B methods', () => { + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + // Phase 1C + expect(client.loginWithGoogle).toBeTypeOf('function'); + expect(client.loginWithMicrosoft).toBeTypeOf('function'); + expect(client.loginWithApple).toBeTypeOf('function'); + expect(client.getProviders).toBeTypeOf('function'); + expect(client.linkProvider).toBeTypeOf('function'); + expect(client.unlinkProvider).toBeTypeOf('function'); + // Phase 2D + expect(client.verifyMfa).toBeTypeOf('function'); + expect(client.setupTotp).toBeTypeOf('function'); + expect(client.verifyTotpSetup).toBeTypeOf('function'); + expect(client.disableMfa).toBeTypeOf('function'); + expect(client.getMfaStatus).toBeTypeOf('function'); + expect(client.regenerateRecoveryCodes).toBeTypeOf('function'); + // Phase 3 + expect(client.getPasskeyRegisterOptions).toBeTypeOf('function'); + expect(client.verifyPasskeyRegistration).toBeTypeOf('function'); + expect(client.getPasskeyAuthOptions).toBeTypeOf('function'); + expect(client.verifyPasskeyAuth).toBeTypeOf('function'); + expect(client.listPasskeys).toBeTypeOf('function'); + expect(client.deletePasskey).toBeTypeOf('function'); + expect(client.listDevices).toBeTypeOf('function'); + expect(client.trustDevice).toBeTypeOf('function'); + expect(client.revokeDevice).toBeTypeOf('function'); + expect(client.revokeAllDevices).toBeTypeOf('function'); + // Phase 5B + expect(client.getSecurityOverview).toBeTypeOf('function'); + expect(client.unlockUser).toBeTypeOf('function'); + expect(client.exportAuthData).toBeTypeOf('function'); + expect(client.cancelDeletion).toBeTypeOf('function'); + }); + }); +}); diff --git a/packages/auth-client/src/client.ts b/packages/auth-client/src/client.ts index 9ba1ff58..a4d0376e 100644 --- a/packages/auth-client/src/client.ts +++ b/packages/auth-client/src/client.ts @@ -18,7 +18,21 @@ * ``` */ -import type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js'; +import type { + AuthClient, + AuthClientConfig, + AuthProvider, + AuthResult, + AuthUser, + Device, + LoginEventInfo, + MfaRequiredResult, + MfaStatus, + Passkey, + SecurityOverview, + TokenStorage, + TotpSetupResult, +} from './types.js'; // ── Default localStorage adapter ───────────────────────────────── @@ -167,14 +181,18 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // ── Auth operations ───────────────────────────── - async function login(email: string, password: string): Promise { - const result = await request('/auth/login', 'POST', { + async function login(email: string, password: string): Promise { + const result = await request('/auth/login', 'POST', { email, password, productId, }); - setTokens(result.accessToken, result.refreshToken); - return result; + if ('mfaRequired' in result && result.mfaRequired) { + return result; + } + const authResult = result as AuthResult; + setTokens(authResult.accessToken, authResult.refreshToken); + return authResult; } async function register( @@ -245,6 +263,188 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { }); } + // ── OAuth / Social login (Phase 1C) ──────────────── + + async function loginWithOAuth( + provider: string, + idToken: string + ): Promise { + const result = await request( + `/auth/oauth/${provider}`, + 'POST', + { idToken }, + { skipAuth: true } + ); + if ('mfaRequired' in result && result.mfaRequired) { + return result; + } + const authResult = result as AuthResult; + setTokens(authResult.accessToken, authResult.refreshToken); + return authResult; + } + + async function loginWithGoogle(idToken: string): Promise { + return loginWithOAuth('google', idToken); + } + + async function loginWithMicrosoft(idToken: string): Promise { + return loginWithOAuth('microsoft', idToken); + } + + async function loginWithApple(idToken: string): Promise { + return loginWithOAuth('apple', idToken); + } + + // ── Provider management (Phase 1C) ───────────────── + + async function getProviders(): Promise { + return request('/auth/providers', 'GET'); + } + + async function linkProvider(provider: string, idToken: string): Promise { + await request('/auth/providers/link', 'POST', { provider, idToken }); + } + + async function unlinkProvider(provider: string): Promise { + await request(`/auth/providers/${provider}`, 'DELETE'); + } + + // ── MFA (Phase 2D) ───────────────────────────────── + + async function verifyMfa( + challengeToken: string, + code: string, + method: 'totp' | 'recovery' + ): Promise { + const result = await request('/auth/mfa/verify', 'POST', { + challengeToken, + code, + method, + }); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function setupTotp(): Promise { + return request('/auth/mfa/totp/setup', 'POST'); + } + + async function verifyTotpSetup(code: string): Promise { + await request('/auth/mfa/totp/verify-setup', 'POST', { code }); + } + + async function disableMfa(): Promise { + await request('/auth/mfa/totp', 'DELETE'); + } + + async function getMfaStatus(): Promise { + return request('/auth/mfa/status', 'GET'); + } + + async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> { + return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST'); + } + + // ── Passkeys (Phase 3) ───────────────────────────── + + async function getPasskeyRegisterOptions(): Promise { + return request('/auth/passkeys/register/options', 'POST'); + } + + async function verifyPasskeyRegistration(response: unknown): Promise { + await request('/auth/passkeys/register/verify', 'POST', response); + } + + async function getPasskeyAuthOptions(): Promise { + return request('/auth/passkeys/authenticate/options', 'POST', undefined, { + skipAuth: true, + }); + } + + async function verifyPasskeyAuth(response: unknown): Promise { + const result = await request( + '/auth/passkeys/authenticate/verify', + 'POST', + response, + { skipAuth: true } + ); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function listPasskeys(): Promise { + return request('/auth/passkeys', 'GET'); + } + + async function deletePasskey(id: string): Promise { + await request(`/auth/passkeys/${id}`, 'DELETE'); + } + + // ── Devices (Phase 3) ────────────────────────────── + + async function listDevices(): Promise { + return request('/auth/devices', 'GET'); + } + + async function trustDevice(): Promise { + await request('/auth/devices/trust', 'POST'); + } + + async function revokeDevice(deviceId: string): Promise { + await request(`/auth/devices/${deviceId}`, 'DELETE'); + } + + async function revokeAllDevices(): Promise { + await request('/auth/devices', 'DELETE'); + } + + // ── Admin security (Phase 5B) ────────────────────── + + async function getSecurityOverview(): Promise { + return request('/auth/security/overview', 'GET'); + } + + async function unlockUser(userId: string): Promise { + await request(`/auth/users/${userId}/unlock`, 'POST'); + } + + async function exportAuthData(): Promise { + return request('/auth/export', 'GET'); + } + + async function cancelDeletion(): Promise<{ message: string }> { + return request<{ message: string }>('/auth/account/cancel-deletion', 'POST'); + } + + // ── Step-up auth ──────────────────────────────────── + + async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> { + return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential }); + } + + // ── Login history ─────────────────────────────────── + + async function getLoginHistory(limit = 50): Promise { + return request(`/auth/login-events/me?limit=${limit}`, 'GET'); + } + + // ── Admin security ────────────────────────────────── + + async function getAdminLoginEvents(opts?: { + suspicious?: boolean; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + if (opts?.suspicious) params.set('suspicious', 'true'); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return request(`/auth/login-events${qs ? `?${qs}` : ''}`, 'GET'); + } + + async function getAdminDevices(userId: string): Promise { + return request(`/auth/devices/user/${userId}`, 'GET'); + } + return { getAccessToken, getRefreshToken, @@ -261,5 +461,44 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { deleteAccount, verifyEmail, resendVerification, + // OAuth / Social login (Phase 1C) + loginWithGoogle, + loginWithMicrosoft, + loginWithApple, + // Provider management (Phase 1C) + getProviders, + linkProvider, + unlinkProvider, + // MFA (Phase 2D) + verifyMfa, + setupTotp, + verifyTotpSetup, + disableMfa, + getMfaStatus, + regenerateRecoveryCodes, + // Passkeys (Phase 3) + getPasskeyRegisterOptions, + verifyPasskeyRegistration, + getPasskeyAuthOptions, + verifyPasskeyAuth, + listPasskeys, + deletePasskey, + // Devices (Phase 3) + listDevices, + trustDevice, + revokeDevice, + revokeAllDevices, + // Admin security (Phase 5B) + getSecurityOverview, + unlockUser, + exportAuthData, + cancelDeletion, + // Step-up auth + stepUp, + // Login history + getLoginHistory, + // Admin queries + getAdminLoginEvents, + getAdminDevices, }; } diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 8e46b225..9592e2ed 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -1,2 +1,16 @@ export { createAuthClient } from './client.js'; -export type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js'; +export type { + AuthClient, + AuthClientConfig, + AuthProvider, + AuthResult, + AuthUser, + Device, + LoginEventInfo, + MfaRequiredResult, + MfaStatus, + Passkey, + SecurityOverview, + TokenStorage, + TotpSetupResult, +} from './types.js'; diff --git a/packages/auth-client/src/types.ts b/packages/auth-client/src/types.ts index 20391b48..d26b3168 100644 --- a/packages/auth-client/src/types.ts +++ b/packages/auth-client/src/types.ts @@ -32,6 +32,10 @@ export interface AuthUser { displayName: string; role: string; plan: string; + mfaEnabled?: boolean; + mfaMethods?: string[]; + providers?: string[]; + products?: string[]; } export interface AuthResult { @@ -40,6 +44,70 @@ export interface AuthResult { user: AuthUser; } +export interface MfaRequiredResult { + mfaRequired: true; + mfaChallenge: string; + methods: string[]; +} + +export interface TotpSetupResult { + otpauthUri: string; + qrDataUrl: string; + recoveryCodes: string[]; +} + +export interface MfaStatus { + mfaEnabled: boolean; + methods: string[]; + recoveryCodesRemaining: number; +} + +export interface AuthProvider { + provider: string; + email: string; + linkedAt: string; + lastUsedAt: string | null; +} + +export interface Passkey { + id: string; + friendlyName: string; + deviceType: 'platform' | 'cross-platform'; + backedUp: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +export interface Device { + id: string; + name: string; + platform: string; + trustLevel: 'trusted' | 'remembered' | 'unknown'; + trustExpiresAt: string | null; + lastIp: string; + lastLoginAt: string; + createdAt: string; +} + +export interface LoginEventInfo { + id: string; + eventType: string; + method: string; + ip: string; + userAgent: string; + riskScore: number; + riskFactors: string[]; + createdAt: string; +} + +export interface SecurityOverview { + totalUsers: number; + mfaAdoptionPercent: number; + providerDistribution: Record; + activeSessions: number; + suspiciousEvents24h: number; +} + export interface AuthClient { // ── Token management ──────────────────────────── getAccessToken(): string | null; @@ -49,11 +117,56 @@ export interface AuthClient { isAuthenticated(): boolean; // ── Auth operations ───────────────────────────── - login(email: string, password: string): Promise; + login(email: string, password: string): Promise; register(email: string, password: string, displayName: string): Promise; getMe(): Promise; refreshAccessToken(): Promise; + // ── OAuth / Social login ──────────────────────── + loginWithGoogle(idToken: string): Promise; + loginWithMicrosoft(idToken: string): Promise; + loginWithApple(idToken: string): Promise; + + // ── Provider management ───────────────────────── + getProviders(): Promise; + linkProvider(provider: string, idToken: string): Promise; + unlinkProvider(provider: string): Promise; + + // ── MFA ───────────────────────────────────────── + verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise; + setupTotp(): Promise; + verifyTotpSetup(code: string): Promise; + disableMfa(): Promise; + getMfaStatus(): Promise; + regenerateRecoveryCodes(): Promise<{ codes: string[] }>; + + // ── Passkeys (WebAuthn) ───────────────────────── + getPasskeyRegisterOptions(): Promise; + verifyPasskeyRegistration(response: unknown): Promise; + getPasskeyAuthOptions(): Promise; + verifyPasskeyAuth(response: unknown): Promise; + listPasskeys(): Promise; + deletePasskey(id: string): Promise; + + // ── Devices ───────────────────────────────────── + listDevices(): Promise; + trustDevice(): Promise; + revokeDevice(deviceId: string): Promise; + revokeAllDevices(): Promise; + + // ── Step-up auth ──────────────────────────────── + stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>; + + // ── Login history ─────────────────────────────── + getLoginHistory(limit?: number): Promise; + + // ── Admin security ────────────────────────────── + getSecurityOverview(): Promise; + unlockUser(userId: string): Promise; + getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise; + getAdminDevices(userId: string): Promise; + exportAuthData(): Promise; + // ── Password management ───────────────────────── forgotPassword(email: string): Promise<{ message: string }>; resetPassword(token: string, newPassword: string): Promise<{ message: string }>; @@ -61,6 +174,7 @@ export interface AuthClient { // ── Account management ────────────────────────── deleteAccount(password: string): Promise<{ message: string }>; + cancelDeletion(): Promise<{ message: string }>; // ── Email verification ────────────────────────── verifyEmail(token: string): Promise<{ message: string }>; diff --git a/packages/auth-ui/package.json b/packages/auth-ui/package.json new file mode 100644 index 00000000..afbb9362 --- /dev/null +++ b/packages/auth-ui/package.json @@ -0,0 +1,33 @@ +{ + "name": "@bytelyst/auth-ui", + "version": "0.1.0", + "type": "module", + "description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4" + } +} diff --git a/packages/auth-ui/src/LoginForm.tsx b/packages/auth-ui/src/LoginForm.tsx new file mode 100644 index 00000000..d4fbbc87 --- /dev/null +++ b/packages/auth-ui/src/LoginForm.tsx @@ -0,0 +1,116 @@ +import { useState, type FormEvent } from 'react'; +import { SocialButtons } from './SocialButtons.js'; +import type { LoginFormProps } from './types.js'; + +/** + * Email/password login form with optional social login buttons. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function LoginForm({ + onSubmit, + providers, + onSocialLogin, + isLoading = false, + error, + className, +}: LoginFormProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(email, password); + } + + return ( +
+
+ setEmail(e.target.value)} + required + disabled={isLoading} + data-testid="bl-login-email" + style={{ + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + }} + /> + setPassword(e.target.value)} + required + disabled={isLoading} + data-testid="bl-login-password" + style={{ + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + }} + /> + + {error && ( +
+ {error} +
+ )} + + +
+ + {providers && providers.length > 0 && onSocialLogin && ( + <> +
+
+ or +
+
+ + + )} +
+ ); +} diff --git a/packages/auth-ui/src/MfaChallenge.tsx b/packages/auth-ui/src/MfaChallenge.tsx new file mode 100644 index 00000000..289cfe03 --- /dev/null +++ b/packages/auth-ui/src/MfaChallenge.tsx @@ -0,0 +1,114 @@ +import { useState, type FormEvent } from 'react'; +import type { MfaChallengeProps } from './types.js'; + +/** + * MFA code entry form (6-digit TOTP or recovery code). + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function MfaChallenge({ + onSubmit, + onUseRecovery, + methods, + isLoading = false, + error, + className, +}: MfaChallengeProps) { + const [code, setCode] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(code); + } + + return ( +
+
+
+ Enter your authentication code +
+ + {methods && methods.length > 0 && ( +
+ Available methods: {methods.join(', ')} +
+ )} + + setCode(e.target.value)} + required + disabled={isLoading} + maxLength={8} + data-testid="bl-mfa-code" + style={{ + padding: '12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '24px', + textAlign: 'center', + letterSpacing: '4px', + fontFamily: 'monospace', + }} + /> + + {error && ( +
+ {error} +
+ )} + + + + {onUseRecovery && ( + + )} +
+
+ ); +} diff --git a/packages/auth-ui/src/SocialButtons.tsx b/packages/auth-ui/src/SocialButtons.tsx new file mode 100644 index 00000000..8e0a67aa --- /dev/null +++ b/packages/auth-ui/src/SocialButtons.tsx @@ -0,0 +1,48 @@ +import type { SocialButtonsProps, SocialProvider } from './types.js'; + +const PROVIDER_LABELS: Record = { + google: 'Google', + microsoft: 'Microsoft', + apple: 'Apple', +}; + +/** + * Renders social login buttons for the configured providers. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function SocialButtons({ + providers, + onSelect, + disabled = false, + className, +}: SocialButtonsProps) { + return ( +
+ {providers.map(provider => ( + + ))} +
+ ); +} diff --git a/packages/auth-ui/src/__tests__/auth-ui.test.tsx b/packages/auth-ui/src/__tests__/auth-ui.test.tsx new file mode 100644 index 00000000..32e0f12d --- /dev/null +++ b/packages/auth-ui/src/__tests__/auth-ui.test.tsx @@ -0,0 +1,154 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { LoginForm } from '../LoginForm.js'; +import { MfaChallenge } from '../MfaChallenge.js'; +import { SocialButtons } from '../SocialButtons.js'; + +describe('@bytelyst/auth-ui', () => { + beforeEach(() => { + cleanup(); + }); + + describe('SocialButtons', () => { + it('renders buttons for each provider', () => { + const onSelect = vi.fn(); + render(); + + expect(screen.getByTestId('bl-social-google')).toBeDefined(); + expect(screen.getByTestId('bl-social-microsoft')).toBeDefined(); + expect(screen.getByTestId('bl-social-apple')).toBeDefined(); + expect(screen.getByText('Continue with Google')).toBeDefined(); + expect(screen.getByText('Continue with Microsoft')).toBeDefined(); + expect(screen.getByText('Continue with Apple')).toBeDefined(); + }); + + it('calls onSelect with provider when clicked', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('bl-social-google')); + expect(onSelect).toHaveBeenCalledWith('google'); + }); + + it('disables buttons when disabled prop is true', () => { + const onSelect = vi.fn(); + render(); + + const btn = screen.getByTestId('bl-social-google'); + expect(btn.getAttribute('disabled')).toBe(''); + }); + }); + + describe('LoginForm', () => { + it('renders email, password, and submit button', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-login-email')).toBeDefined(); + expect(screen.getByTestId('bl-login-password')).toBeDefined(); + expect(screen.getByTestId('bl-login-submit')).toBeDefined(); + expect(screen.getByText('Sign in')).toBeDefined(); + }); + + it('calls onSubmit with email and password', () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId('bl-login-email'), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByTestId('bl-login-password'), { + target: { value: 'password123' }, + }); + fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!); + + expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123'); + }); + + it('displays error message', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-login-error')).toBeDefined(); + expect(screen.getByText('Invalid credentials')).toBeDefined(); + }); + + it('renders social buttons when providers are given', () => { + const onSubmit = vi.fn(); + const onSocialLogin = vi.fn(); + render( + + ); + + expect(screen.getByTestId('bl-social-google')).toBeDefined(); + expect(screen.getByTestId('bl-social-apple')).toBeDefined(); + + fireEvent.click(screen.getByTestId('bl-social-google')); + expect(onSocialLogin).toHaveBeenCalledWith('google'); + }); + + it('shows loading state', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByText('Signing in...')).toBeDefined(); + const btn = screen.getByTestId('bl-login-submit'); + expect(btn.getAttribute('disabled')).toBe(''); + }); + }); + + describe('MfaChallenge', () => { + it('renders code input and verify button', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-code')).toBeDefined(); + expect(screen.getByTestId('bl-mfa-submit')).toBeDefined(); + expect(screen.getByText('Verify')).toBeDefined(); + }); + + it('calls onSubmit with code', () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId('bl-mfa-code'), { + target: { value: '123456' }, + }); + fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!); + + expect(onSubmit).toHaveBeenCalledWith('123456'); + }); + + it('displays available methods', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-methods')).toBeDefined(); + expect(screen.getByText('Available methods: totp, recovery')).toBeDefined(); + }); + + it('shows recovery code button when handler provided', () => { + const onSubmit = vi.fn(); + const onUseRecovery = vi.fn(); + render(); + + const recoveryBtn = screen.getByTestId('bl-mfa-recovery'); + expect(recoveryBtn).toBeDefined(); + + fireEvent.click(recoveryBtn); + expect(onUseRecovery).toHaveBeenCalledOnce(); + }); + + it('displays error message', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-error')).toBeDefined(); + expect(screen.getByText('Invalid code')).toBeDefined(); + }); + }); +}); diff --git a/packages/auth-ui/src/index.ts b/packages/auth-ui/src/index.ts new file mode 100644 index 00000000..9803e793 --- /dev/null +++ b/packages/auth-ui/src/index.ts @@ -0,0 +1,9 @@ +export { LoginForm } from './LoginForm.js'; +export { MfaChallenge } from './MfaChallenge.js'; +export { SocialButtons } from './SocialButtons.js'; +export type { + LoginFormProps, + MfaChallengeProps, + SocialButtonsProps, + SocialProvider, +} from './types.js'; diff --git a/packages/auth-ui/src/types.ts b/packages/auth-ui/src/types.ts new file mode 100644 index 00000000..db3f9bd5 --- /dev/null +++ b/packages/auth-ui/src/types.ts @@ -0,0 +1,42 @@ +export type SocialProvider = 'google' | 'microsoft' | 'apple'; + +export interface LoginFormProps { + /** Called when user submits email/password. */ + onSubmit: (email: string, password: string) => void; + /** Social providers to display. */ + providers?: SocialProvider[]; + /** Called when user clicks a social login button. */ + onSocialLogin?: (provider: SocialProvider) => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface MfaChallengeProps { + /** Called when user submits the MFA code. */ + onSubmit: (code: string) => void; + /** Called when user clicks "Use recovery code". */ + onUseRecovery?: () => void; + /** MFA methods available. */ + methods?: string[]; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface SocialButtonsProps { + /** Providers to display buttons for. */ + providers: SocialProvider[]; + /** Called when a provider button is clicked. */ + onSelect: (provider: SocialProvider) => void; + /** Whether the buttons are disabled. */ + disabled?: boolean; + /** Additional CSS class for the root element. */ + className?: string; +} diff --git a/packages/auth-ui/tsconfig.json b/packages/auth-ui/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/auth-ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/auth-ui/vitest.config.ts b/packages/auth-ui/vitest.config.ts new file mode 100644 index 00000000..a9f5456f --- /dev/null +++ b/packages/auth-ui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + }, +}); diff --git a/packages/auth/src/__tests__/rs256.test.ts b/packages/auth/src/__tests__/rs256.test.ts new file mode 100644 index 00000000..a96832c5 --- /dev/null +++ b/packages/auth/src/__tests__/rs256.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { generateKeyPair } from 'jose'; +import { createJwtUtils } from '../index.js'; + +describe('JWT RS256 support (Phase 4C)', () => { + const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; + let rsaPrivateKey: string; + let rsaPublicKey: string; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + + // Generate RSA key pair for testing (extractable required for PEM export in jose v6) + const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); + const { exportPKCS8, exportSPKI } = await import('jose'); + rsaPrivateKey = await exportPKCS8(privateKey); + rsaPublicKey = await exportSPKI(publicKey); + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('signs and verifies tokens with RS256', async () => { + const jwt = createJwtUtils({ + issuer: 'test-rs256', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await jwt.createAccessToken({ + sub: 'user-1', + email: 'rs256@test.com', + role: 'admin', + }); + + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.email).toBe('rs256@test.com'); + expect(payload!.type).toBe('access'); + }); + + it('dual verify: RS256 verifier can fall back to HS256 tokens', async () => { + // Create an HS256 token (simulates old tokens during migration) + const hs256Jwt = createJwtUtils({ issuer: 'dual-test' }); + const hs256Token = await hs256Jwt.createAccessToken({ + sub: 'u-old', + email: 'old@test.com', + role: 'user', + }); + + // Verify with RS256-configured jwt (should fall back to HS256) + const dualJwt = createJwtUtils({ + issuer: 'dual-test', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const payload = await dualJwt.verifyToken(hs256Token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('u-old'); + expect(payload!.email).toBe('old@test.com'); + }); + + it('RS256 token is NOT verified by HS256-only verifier with different issuer', async () => { + const rs256Jwt = createJwtUtils({ + issuer: 'rs256-only', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await rs256Jwt.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + + // HS256-only verifier with different issuer should reject + const hs256Jwt = createJwtUtils({ issuer: 'different-issuer' }); + const result = await hs256Jwt.verifyToken(token); + expect(result).toBeNull(); + }); + + it('RS256 refresh token works', async () => { + const jwt = createJwtUtils({ + issuer: 'test-rs256', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await jwt.createRefreshToken({ sub: 'user-1' }); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.type).toBe('refresh'); + }); + + it('throws when RS256 signing without private key', async () => { + const jwt = createJwtUtils({ + issuer: 'test-no-key', + algorithm: 'RS256', + rsaPublicKey, // public only, no private + }); + + await expect( + jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) + ).rejects.toThrow('rsaPrivateKey is required'); + }); + + it('HS256 still works as default (backward compatible)', async () => { + const jwt = createJwtUtils({ issuer: 'hs256-compat' }); + + const token = await jwt.createAccessToken({ + sub: 'u1', + email: 'compat@test.com', + role: 'user', + }); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('u1'); + expect(payload!.email).toBe('compat@test.com'); + }); +}); diff --git a/packages/auth/src/jwt.ts b/packages/auth/src/jwt.ts index 4e279990..57f8bb15 100644 --- a/packages/auth/src/jwt.ts +++ b/packages/auth/src/jwt.ts @@ -1,12 +1,24 @@ /** - * JWT utilities — configurable issuer and expiry. - * Uses jose library for standards-compliant JWT handling. + * JWT utilities — configurable issuer, expiry, and algorithm. + * Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose. + * + * RS256 mode (Phase 4C SmartAuth): + * - Sign with RSA private key (PEM) + * - Verify with RSA public key (PEM) or remote JWKS URL + * - Dual verification: tries RS256 first, falls back to HS256 during migration */ -import { SignJWT, jwtVerify } from 'jose'; +import { + SignJWT, + jwtVerify, + importPKCS8, + importSPKI, + createRemoteJWKSet, + type CryptoKey as JoseCryptoKey, +} from 'jose'; import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js'; -function getSecret(): Uint8Array { +function getHmacSecret(): Uint8Array { const secret = process.env.JWT_SECRET; if (!secret) throw new Error('JWT_SECRET must be set'); return new TextEncoder().encode(secret); @@ -17,47 +29,145 @@ function getSecret(): Uint8Array { * * @example * ```ts - * const jwt = createJwtUtils({ issuer: "lysnrai", accessTokenExpiry: "1h" }); - * const token = await jwt.createAccessToken({ sub: "u1", email: "a@b.com", role: "admin" }); - * const payload = await jwt.verifyToken(token); + * // HS256 (default, backward-compatible) + * const jwt = createJwtUtils({ issuer: "bytelyst-platform" }); + * + * // RS256 (SmartAuth Phase 4C) + * const jwt = createJwtUtils({ + * issuer: "bytelyst-platform", + * algorithm: "RS256", + * rsaPrivateKey: process.env.JWT_PRIVATE_KEY, + * rsaPublicKey: process.env.JWT_PUBLIC_KEY, + * }); + * + * // RS256 verify-only (product backends — no private key) + * const jwt = createJwtUtils({ + * issuer: "bytelyst-platform", + * algorithm: "RS256", + * jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json", + * }); * ``` */ export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { - const { issuer, accessTokenExpiry = '1h', refreshTokenExpiry = '30d' } = options; + const { + issuer, + accessTokenExpiry = '1h', + refreshTokenExpiry = '30d', + algorithm = 'HS256', + rsaPrivateKey, + rsaPublicKey, + jwksUrl, + } = options; + + // ── Key caches ──────────────────────────────────── + + let _rsaPrivateKeyObj: JoseCryptoKey | null = null; + let _rsaPublicKeyObj: JoseCryptoKey | null = null; + let _jwksKeySet: ReturnType | null = null; + + async function getRsaPrivateKey(): Promise { + if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj; + if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing'); + _rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey; + return _rsaPrivateKeyObj; + } + + async function getRsaPublicKey(): Promise { + if (_rsaPublicKeyObj) return _rsaPublicKeyObj; + if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification'); + _rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey; + return _rsaPublicKeyObj; + } + + function getJwksKeySet(): ReturnType { + if (_jwksKeySet) return _jwksKeySet; + if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification'); + _jwksKeySet = createRemoteJWKSet(new URL(jwksUrl)); + return _jwksKeySet; + } + + // ── Signing ─────────────────────────────────────── + + async function sign(claims: Record, expiry: string): Promise { + if (algorithm === 'RS256') { + const key = await getRsaPrivateKey(); + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime(expiry) + .setIssuer(issuer) + .sign(key); + } + return new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(expiry) + .setIssuer(issuer) + .sign(getHmacSecret()); + } + + // ── Verification (dual: RS256 first, HS256 fallback) ── + + async function verifyWithRS256(token: string): Promise { + try { + if (jwksUrl) { + const keySet = getJwksKeySet(); + const { payload } = await jwtVerify(token, keySet, { issuer }); + return payload as unknown as TokenPayload; + } + if (rsaPublicKey) { + const key = await getRsaPublicKey(); + const { payload } = await jwtVerify(token, key, { issuer }); + return payload as unknown as TokenPayload; + } + return null; + } catch { + return null; + } + } + + async function verifyWithHS256(token: string): Promise { + try { + const secret = getHmacSecret(); + const { payload } = await jwtVerify(token, secret, { issuer }); + return payload as unknown as TokenPayload; + } catch { + return null; + } + } return { async createAccessToken(payload) { - return new SignJWT({ - ...payload, - productId: payload.productId || issuer, - type: 'access', - }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(accessTokenExpiry) - .setIssuer(issuer) - .sign(getSecret()); + return sign( + { + ...payload, + productId: payload.productId || issuer, + type: 'access', + }, + accessTokenExpiry + ); }, async createRefreshToken(payload) { - return new SignJWT({ - sub: payload.sub, - productId: payload.productId || issuer, - type: 'refresh', - }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(refreshTokenExpiry) - .setIssuer(issuer) - .sign(getSecret()); + return sign( + { + sub: payload.sub, + productId: payload.productId || issuer, + type: 'refresh', + }, + refreshTokenExpiry + ); }, async verifyToken(token: string) { + // Dual verification: try RS256 first (if configured), then HS256 fallback + if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) { + const rs256Result = await verifyWithRS256(token); + if (rs256Result) return rs256Result; + } + // HS256 fallback (safe during migration; removed after full RS256 rollout) try { - const { payload } = await jwtVerify(token, getSecret(), { - issuer, - }); - return payload as unknown as TokenPayload; + return await verifyWithHS256(token); } catch { return null; } diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index dfd2daab..6455fbef 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -19,6 +19,14 @@ export interface JwtUtilsOptions { issuer: string; accessTokenExpiry?: string; refreshTokenExpiry?: string; + /** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */ + algorithm?: 'HS256' | 'RS256'; + /** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */ + rsaPrivateKey?: string; + /** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */ + rsaPublicKey?: string; + /** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */ + jwksUrl?: string; } export interface JwtUtils { diff --git a/packages/react-auth/src/__tests__/smartauth.test.tsx b/packages/react-auth/src/__tests__/smartauth.test.tsx new file mode 100644 index 00000000..104e2e70 --- /dev/null +++ b/packages/react-auth/src/__tests__/smartauth.test.tsx @@ -0,0 +1,338 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { createAuthProvider } from '../index.js'; + +interface TestUser { + email: string; + name: string; + role: string; + [key: string]: unknown; +} + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +const store: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) delete store[key]; + }), + length: 0, + key: vi.fn(), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); + +function createTestAuth(overrides?: Partial>[0]>) { + return createAuthProvider({ + storagePrefix: 'sa', + loginEndpoint: '/auth/login', + mapLoginResponse: (data: unknown) => { + const d = data as { user: TestUser; accessToken: string; refreshToken: string }; + return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; + }, + ...overrides, + }); +} + +describe('react-auth SmartAuth features', () => { + beforeEach(() => { + cleanup(); + localStorageMock.clear(); + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + // ── Phase 1C: loginWithGoogle ────────────────────── + + it('loginWithGoogle sets user on success', async () => { + const apiResponse = { + user: { email: 'g@gmail.com', name: 'Google User', role: 'user' }, + accessToken: 'g-at', + refreshToken: 'g-rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginWithGoogleFn: (idToken: string) => Promise; + + function Component() { + const { loginWithGoogle, user, isAuthenticated } = useAuth(); + loginWithGoogleFn = loginWithGoogle; + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + + let result = false; + await act(async () => { + result = await loginWithGoogleFn!('google-id-token'); + }); + + expect(result).toBe(true); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('email').textContent).toBe('g@gmail.com'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'g-at'); + }); + + it('loginWithGoogle returns false on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'Invalid token' }), + headers: new Headers({ 'content-type': 'application/json' }), + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginWithGoogleFn: (idToken: string) => Promise; + + function Component() { + const { loginWithGoogle, isAuthenticated, error } = useAuth(); + loginWithGoogleFn = loginWithGoogle; + return ( +
+ {String(isAuthenticated)} + {error ?? 'none'} +
+ ); + } + + render( + + + + ); + + let result = true; + await act(async () => { + result = await loginWithGoogleFn!('bad-token'); + }); + + expect(result).toBe(false); + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); + + // ── Phase 2D: MFA challenge flow ────────────────── + + it('login triggers MFA state when mfaRequired returned', async () => { + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'challenge-xyz', + methods: ['totp'], + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mfaResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const onMfaRequired = vi.fn(); + const { AuthProvider, useAuth } = createTestAuth({ onMfaRequired }); + let loginFn: (email: string, password: string) => Promise; + + function Component() { + const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth(); + loginFn = login; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} + {mfaChallenge ?? 'none'} + {mfaMethods.join(',') || 'none'} +
+ ); + } + + render( + + + + ); + + let result = true; + await act(async () => { + result = await loginFn!('user@test.com', 'pass'); + }); + + // Login returns false (MFA required, not yet authenticated) + expect(result).toBe(false); + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('mfa').textContent).toBe('true'); + expect(screen.getByTestId('challenge').textContent).toBe('challenge-xyz'); + expect(screen.getByTestId('methods').textContent).toBe('totp'); + expect(onMfaRequired).toHaveBeenCalledWith('challenge-xyz', ['totp']); + }); + + it('verifyMfa completes login after MFA challenge', async () => { + // Step 1: login triggers MFA + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'challenge-abc', + methods: ['totp'], + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mfaResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise; + + function Component() { + const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth(); + loginFn = login; + verifyMfaFn = verifyMfa; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + await act(async () => { + await loginFn!('user@test.com', 'pass'); + }); + + expect(screen.getByTestId('mfa').textContent).toBe('true'); + expect(screen.getByTestId('auth').textContent).toBe('false'); + + // Step 2: verify MFA + const verifyResponse = { + user: { email: 'user@test.com', name: 'Test', role: 'user' }, + accessToken: 'mfa-at', + refreshToken: 'mfa-rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => verifyResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + let verifyResult = false; + await act(async () => { + verifyResult = await verifyMfaFn!('123456', 'totp'); + }); + + expect(verifyResult).toBe(true); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('mfa').textContent).toBe('false'); + expect(screen.getByTestId('email').textContent).toBe('user@test.com'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'mfa-at'); + }); + + // ── Phase 1C: Provider management ───────────────── + + it('providers starts empty and exposes link/unlink', () => { + const { AuthProvider, useAuth } = createTestAuth(); + + function Component() { + const { providers, linkProvider, unlinkProvider, refreshProviders } = useAuth(); + return ( +
+ {providers.length} + {String(typeof linkProvider === 'function')} + {String(typeof unlinkProvider === 'function')} + {String(typeof refreshProviders === 'function')} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('count').textContent).toBe('0'); + expect(screen.getByTestId('hasLink').textContent).toBe('true'); + expect(screen.getByTestId('hasUnlink').textContent).toBe('true'); + expect(screen.getByTestId('hasRefresh').textContent).toBe('true'); + }); + + // ── Logout clears SmartAuth state ───────────────── + + it('logout clears providers and MFA state', async () => { + // Login first + const loginResponse = { + user: { email: 'a@b.com', name: 'A', role: 'user' }, + accessToken: 'at', + refreshToken: 'rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => loginResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + let logoutFn: () => void; + + function Component() { + const { login, logout, isAuthenticated, mfaRequired } = useAuth(); + loginFn = login; + logoutFn = logout; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} +
+ ); + } + + render( + + + + ); + + await act(async () => { + await loginFn!('a@b.com', 'pass'); + }); + + expect(screen.getByTestId('auth').textContent).toBe('true'); + + act(() => { + logoutFn!(); + }); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('mfa').textContent).toBe('false'); + }); +}); diff --git a/packages/react-auth/src/auth-context.tsx b/packages/react-auth/src/auth-context.tsx index a3c7dc6e..d513a13b 100644 --- a/packages/react-auth/src/auth-context.tsx +++ b/packages/react-auth/src/auth-context.tsx @@ -10,7 +10,7 @@ import { type ReactNode, } from 'react'; import { createApiClient } from '@bytelyst/api-client'; -import type { AuthConfig, AuthContextValue, BaseUser } from './types.js'; +import type { AuthConfig, AuthContextValue, AuthProviderInfo, BaseUser } from './types.js'; /** * Create a typed auth provider + hook for a specific user type. @@ -51,6 +51,11 @@ export function createAuthProvider(config: Au onLoginFallback, onInit, onLogout, + oauthEndpoint = '/auth/oauth', + providersEndpoint = '/auth/providers', + linkProviderEndpoint = '/auth/providers/link', + mfaVerifyEndpoint = '/auth/mfa/verify', + onMfaRequired, } = config; const USER_KEY = `${storagePrefix}_auth_user`; @@ -96,6 +101,10 @@ export function createAuthProvider(config: Au const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [providers, setProviders] = useState([]); + const [mfaRequired, setMfaRequired] = useState(false); + const [mfaMethods, setMfaMethods] = useState([]); + const [mfaChallenge, setMfaChallenge] = useState(null); const refreshTimerRef = useRef | null>(null); const api = createApiClient({ @@ -138,12 +147,30 @@ export function createAuthProvider(config: Au }; }, [user, refreshAccessToken, refreshIntervalMs]); + // ── MFA challenge helper ───────────────────────── + + function handleMfaChallenge(data: Record): boolean { + if (data && typeof data === 'object' && 'mfaRequired' in data && data.mfaRequired === true) { + const challenge = data.mfaChallenge as string; + const methods = data.methods as string[]; + setMfaRequired(true); + setMfaChallenge(challenge); + setMfaMethods(methods ?? []); + onMfaRequired?.(challenge, methods ?? []); + return true; + } + return false; + } + // ── Login ────────────────────────────────────── const login = useCallback( async (email: string, password: string) => { setIsLoading(true); setError(null); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); try { const { data, error: fetchError } = await api.safeFetch(loginEndpoint, { method: 'POST', @@ -151,6 +178,9 @@ export function createAuthProvider(config: Au }); if (data && !fetchError) { + if (handleMfaChallenge(data as Record)) { + return false; + } const mapped = mapLoginResponse(data); setUser(mapped.user); saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); @@ -207,11 +237,154 @@ export function createAuthProvider(config: Au [api] ); + // ── Social login (Phase 1C) ──────────────────── + + const loginWithOAuth = useCallback( + async (provider: string, idToken: string) => { + setIsLoading(true); + setError(null); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + try { + const { data, error: fetchError } = await api.safeFetch( + `${oauthEndpoint}/${provider}`, + { method: 'POST', body: JSON.stringify({ idToken }) } + ); + + if (data && !fetchError) { + if (handleMfaChallenge(data as Record)) { + return false; + } + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + return true; + } + + setError(fetchError || `${provider} login failed`); + return false; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + const loginWithGoogle = useCallback( + (idToken: string) => loginWithOAuth('google', idToken), + [loginWithOAuth] + ); + + const loginWithMicrosoft = useCallback( + (idToken: string) => loginWithOAuth('microsoft', idToken), + [loginWithOAuth] + ); + + const loginWithApple = useCallback( + (idToken: string) => loginWithOAuth('apple', idToken), + [loginWithOAuth] + ); + + // ── Provider management (Phase 1C) ──────────── + + const refreshProviders = useCallback(async () => { + try { + const data = await api.fetch(providersEndpoint, { method: 'GET' }); + setProviders(data); + } catch { + // non-fatal — providers list is supplementary + } + }, [api]); + + const linkProvider = useCallback( + async (provider: string, idToken: string) => { + setIsLoading(true); + setError(null); + try { + const { error: fetchError } = await api.safeFetch(linkProviderEndpoint, { + method: 'POST', + body: JSON.stringify({ provider, idToken }), + }); + if (fetchError) { + setError(fetchError); + return false; + } + await refreshProviders(); + return true; + } finally { + setIsLoading(false); + } + }, + [api, refreshProviders] + ); + + const unlinkProvider = useCallback( + async (provider: string) => { + setIsLoading(true); + setError(null); + try { + const { error: fetchError } = await api.safeFetch( + `${providersEndpoint}/${provider}`, + { method: 'DELETE' } + ); + if (fetchError) { + setError(fetchError); + return false; + } + await refreshProviders(); + return true; + } finally { + setIsLoading(false); + } + }, + [api, refreshProviders] + ); + + // ── MFA verify (Phase 2D) ───────────────────── + + const verifyMfa = useCallback( + async (code: string, method: 'totp' | 'recovery') => { + if (!mfaChallenge) { + setError('No MFA challenge in progress'); + return false; + } + setIsLoading(true); + setError(null); + try { + const { data, error: fetchError } = await api.safeFetch(mfaVerifyEndpoint, { + method: 'POST', + body: JSON.stringify({ challengeToken: mfaChallenge, code, method }), + }); + + if (data && !fetchError) { + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + return true; + } + + setError(fetchError || 'MFA verification failed'); + return false; + } finally { + setIsLoading(false); + } + }, + [api, mfaChallenge] + ); + // ── Logout ───────────────────────────────────── const logout = useCallback(() => { setUser(null); clearSession(); + setProviders([]); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); onLogout?.(); }, []); @@ -332,6 +505,20 @@ export function createAuthProvider(config: Au deleteAccount, updateUser, clearMessages, + // SmartAuth: Social login (Phase 1C) + loginWithGoogle, + loginWithMicrosoft, + loginWithApple, + // SmartAuth: Provider management (Phase 1C) + providers, + linkProvider, + unlinkProvider, + refreshProviders, + // SmartAuth: MFA state (Phase 2D) + mfaRequired, + mfaMethods, + mfaChallenge, + verifyMfa, }} > {children} diff --git a/packages/react-auth/src/index.ts b/packages/react-auth/src/index.ts index 549ad3de..424c6495 100644 --- a/packages/react-auth/src/index.ts +++ b/packages/react-auth/src/index.ts @@ -1,2 +1,8 @@ export { createAuthProvider } from './auth-context.js'; -export type { BaseUser, AuthContextValue, AuthConfig, LoginResult } from './types.js'; +export type { + AuthProviderInfo, + BaseUser, + AuthContextValue, + AuthConfig, + LoginResult, +} from './types.js'; diff --git a/packages/react-auth/src/types.ts b/packages/react-auth/src/types.ts index f7287f60..13ebd6cd 100644 --- a/packages/react-auth/src/types.ts +++ b/packages/react-auth/src/types.ts @@ -5,6 +5,13 @@ export interface BaseUser { [key: string]: unknown; } +export interface AuthProviderInfo { + provider: string; + email: string; + linkedAt: string; + lastUsedAt: string | null; +} + export interface AuthContextValue { user: TUser | null; isAuthenticated: boolean; @@ -19,6 +26,23 @@ export interface AuthContextValue { deleteAccount: (password: string) => Promise; updateUser: (updates: Partial) => void; clearMessages: () => void; + + // ── SmartAuth: Social login (Phase 1C) ──────────── + loginWithGoogle: (idToken: string) => Promise; + loginWithMicrosoft: (idToken: string) => Promise; + loginWithApple: (idToken: string) => Promise; + + // ── SmartAuth: Provider management (Phase 1C) ───── + providers: AuthProviderInfo[]; + linkProvider: (provider: string, idToken: string) => Promise; + unlinkProvider: (provider: string) => Promise; + refreshProviders: () => Promise; + + // ── SmartAuth: MFA state (Phase 2D) ─────────────── + mfaRequired: boolean; + mfaMethods: string[]; + mfaChallenge: string | null; + verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise; } export interface LoginResult { @@ -48,4 +72,16 @@ export interface AuthConfig { /** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */ onInit?: () => LoginResult | null; onLogout?: () => void; + + // ── SmartAuth endpoint config (Phase 1C+) ───────── + /** Endpoint for OAuth social login. Default: '/auth/oauth'. Provider appended as path segment. */ + oauthEndpoint?: string; + /** Endpoint for listing providers. Default: '/auth/providers'. */ + providersEndpoint?: string; + /** Endpoint for linking a provider. Default: '/auth/providers/link'. */ + linkProviderEndpoint?: string; + /** Endpoint for MFA verification. Default: '/auth/mfa/verify'. */ + mfaVerifyEndpoint?: string; + /** Callback when MFA is required after login. Receives challenge token and methods. */ + onMfaRequired?: (challenge: string, methods: string[]) => void; }