feat(auth): SmartAuth SDK packages — OAuth, MFA, passkeys, devices, RS256, auth-ui

Phase 1C: @bytelyst/auth-client + @bytelyst/react-auth Google Sign-In
- loginWithGoogle/Microsoft/Apple(idToken) → POST /auth/oauth/:provider
- getProviders/linkProvider/unlinkProvider → provider management
- React context: loginWithGoogle, providers state, refreshProviders

Phase 2D: MFA + Social Login SDK + Auth UI
- verifyMfa/setupTotp/verifyTotpSetup/disableMfa/getMfaStatus
- regenerateRecoveryCodes → recovery code management
- React context: mfaRequired/mfaChallenge/mfaMethods state, verifyMfa action
- login() handles MfaLoginResult (returns false, sets MFA state)
- NEW @bytelyst/auth-ui: LoginForm, MfaChallenge, SocialButtons components

Phase 3: Passkeys + Device SDK
- getPasskeyRegisterOptions/verifyPasskeyRegistration
- getPasskeyAuthOptions/verifyPasskeyAuth/listPasskeys/deletePasskey
- listDevices/trustDevice/revokeDevice/revokeAllDevices

Phase 4C: @bytelyst/auth RS256 support
- createJwtUtils({ algorithm: 'RS256', rsaPrivateKey, rsaPublicKey })
- Dual verification: RS256 first, HS256 fallback (migration-safe)
- Remote JWKS support via jwksUrl option
- Backward-compatible: HS256 remains default

Phase 5B: Admin security endpoints
- getSecurityOverview/unlockUser/exportAuthData/cancelDeletion

Tests: 101 total (36 auth-client + 21 react-auth + 13 auth-ui + 31 auth)
Builds: all 4 packages pass tsc
This commit is contained in:
saravanakumardb1 2026-03-12 10:50:56 -07:00
parent c8b520ba12
commit 53f2a97d40
20 changed files with 2278 additions and 41 deletions

View File

@ -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<string, string> } {
const store = new Map<string, string>();
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<typeof createMockStorage>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
});

View File

@ -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<AuthResult> {
const result = await request<AuthResult>('/auth/login', 'POST', {
async function login(email: string, password: string): Promise<AuthResult | MfaRequiredResult> {
const result = await request<AuthResult | MfaRequiredResult>('/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<AuthResult | MfaRequiredResult> {
const result = await request<AuthResult | MfaRequiredResult>(
`/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<AuthResult | MfaRequiredResult> {
return loginWithOAuth('google', idToken);
}
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
return loginWithOAuth('microsoft', idToken);
}
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
return loginWithOAuth('apple', idToken);
}
// ── Provider management (Phase 1C) ─────────────────
async function getProviders(): Promise<AuthProvider[]> {
return request<AuthProvider[]>('/auth/providers', 'GET');
}
async function linkProvider(provider: string, idToken: string): Promise<void> {
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
}
async function unlinkProvider(provider: string): Promise<void> {
await request<void>(`/auth/providers/${provider}`, 'DELETE');
}
// ── MFA (Phase 2D) ─────────────────────────────────
async function verifyMfa(
challengeToken: string,
code: string,
method: 'totp' | 'recovery'
): Promise<AuthResult> {
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
challengeToken,
code,
method,
});
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function setupTotp(): Promise<TotpSetupResult> {
return request<TotpSetupResult>('/auth/mfa/totp/setup', 'POST');
}
async function verifyTotpSetup(code: string): Promise<void> {
await request<void>('/auth/mfa/totp/verify-setup', 'POST', { code });
}
async function disableMfa(): Promise<void> {
await request<void>('/auth/mfa/totp', 'DELETE');
}
async function getMfaStatus(): Promise<MfaStatus> {
return request<MfaStatus>('/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<unknown> {
return request<unknown>('/auth/passkeys/register/options', 'POST');
}
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
await request<void>('/auth/passkeys/register/verify', 'POST', response);
}
async function getPasskeyAuthOptions(): Promise<unknown> {
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
skipAuth: true,
});
}
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
const result = await request<AuthResult>(
'/auth/passkeys/authenticate/verify',
'POST',
response,
{ skipAuth: true }
);
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function listPasskeys(): Promise<Passkey[]> {
return request<Passkey[]>('/auth/passkeys', 'GET');
}
async function deletePasskey(id: string): Promise<void> {
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
}
// ── Devices (Phase 3) ──────────────────────────────
async function listDevices(): Promise<Device[]> {
return request<Device[]>('/auth/devices', 'GET');
}
async function trustDevice(): Promise<void> {
await request<void>('/auth/devices/trust', 'POST');
}
async function revokeDevice(deviceId: string): Promise<void> {
await request<void>(`/auth/devices/${deviceId}`, 'DELETE');
}
async function revokeAllDevices(): Promise<void> {
await request<void>('/auth/devices', 'DELETE');
}
// ── Admin security (Phase 5B) ──────────────────────
async function getSecurityOverview(): Promise<SecurityOverview> {
return request<SecurityOverview>('/auth/security/overview', 'GET');
}
async function unlockUser(userId: string): Promise<void> {
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
}
async function exportAuthData(): Promise<unknown> {
return request<unknown>('/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<LoginEventInfo[]> {
return request<LoginEventInfo[]>(`/auth/login-events/me?limit=${limit}`, 'GET');
}
// ── Admin security ──────────────────────────────────
async function getAdminLoginEvents(opts?: {
suspicious?: boolean;
limit?: number;
}): Promise<LoginEventInfo[]> {
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<LoginEventInfo[]>(`/auth/login-events${qs ? `?${qs}` : ''}`, 'GET');
}
async function getAdminDevices(userId: string): Promise<Device[]> {
return request<Device[]>(`/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,
};
}

View File

@ -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';

View File

@ -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<string, number>;
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<AuthResult>;
login(email: string, password: string): Promise<AuthResult | MfaRequiredResult>;
register(email: string, password: string, displayName: string): Promise<AuthResult>;
getMe(): Promise<AuthUser>;
refreshAccessToken(): Promise<boolean>;
// ── OAuth / Social login ────────────────────────
loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult>;
loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult>;
loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult>;
// ── Provider management ─────────────────────────
getProviders(): Promise<AuthProvider[]>;
linkProvider(provider: string, idToken: string): Promise<void>;
unlinkProvider(provider: string): Promise<void>;
// ── MFA ─────────────────────────────────────────
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
setupTotp(): Promise<TotpSetupResult>;
verifyTotpSetup(code: string): Promise<void>;
disableMfa(): Promise<void>;
getMfaStatus(): Promise<MfaStatus>;
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
// ── Passkeys (WebAuthn) ─────────────────────────
getPasskeyRegisterOptions(): Promise<unknown>;
verifyPasskeyRegistration(response: unknown): Promise<void>;
getPasskeyAuthOptions(): Promise<unknown>;
verifyPasskeyAuth(response: unknown): Promise<AuthResult>;
listPasskeys(): Promise<Passkey[]>;
deletePasskey(id: string): Promise<void>;
// ── Devices ─────────────────────────────────────
listDevices(): Promise<Device[]>;
trustDevice(): Promise<void>;
revokeDevice(deviceId: string): Promise<void>;
revokeAllDevices(): Promise<void>;
// ── Step-up auth ────────────────────────────────
stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>;
// ── Login history ───────────────────────────────
getLoginHistory(limit?: number): Promise<LoginEventInfo[]>;
// ── Admin security ──────────────────────────────
getSecurityOverview(): Promise<SecurityOverview>;
unlockUser(userId: string): Promise<void>;
getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise<LoginEventInfo[]>;
getAdminDevices(userId: string): Promise<Device[]>;
exportAuthData(): Promise<unknown>;
// ── 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 }>;

View File

@ -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"
}
}

View File

@ -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 (
<div className={className} data-testid="bl-login-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<input
type="email"
placeholder="Email"
value={email}
onChange={e => 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',
}}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => 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 && (
<div
data-testid="bl-login-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
data-testid="bl-login-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading ? 0.6 : 1,
}}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{providers && providers.length > 0 && onSocialLogin && (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '16px 0',
color: 'var(--bl-muted, #999)',
fontSize: '13px',
}}
>
<hr
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
/>
or
<hr
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
/>
</div>
<SocialButtons providers={providers} onSelect={onSocialLogin} disabled={isLoading} />
</>
)}
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-mfa-challenge">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
Enter your authentication code
</div>
{methods && methods.length > 0 && (
<div
data-testid="bl-mfa-methods"
style={{ fontSize: '12px', color: 'var(--bl-muted, #999)' }}
>
Available methods: {methods.join(', ')}
</div>
)}
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
value={code}
onChange={e => 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 && (
<div
data-testid="bl-mfa-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading || code.length < 6}
data-testid="bl-mfa-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading || code.length < 6 ? 0.6 : 1,
}}
>
{isLoading ? 'Verifying...' : 'Verify'}
</button>
{onUseRecovery && (
<button
type="button"
onClick={onUseRecovery}
disabled={isLoading}
data-testid="bl-mfa-recovery"
style={{
padding: '8px',
border: 'none',
background: 'transparent',
color: 'var(--bl-link, #0066ff)',
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
}}
>
Use a recovery code
</button>
)}
</form>
</div>
);
}

View File

@ -0,0 +1,48 @@
import type { SocialButtonsProps, SocialProvider } from './types.js';
const PROVIDER_LABELS: Record<SocialProvider, string> = {
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 (
<div
className={className}
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
data-testid="bl-social-buttons"
>
{providers.map(provider => (
<button
key={provider}
type="button"
onClick={() => onSelect(provider)}
disabled={disabled}
data-testid={`bl-social-${provider}`}
style={{
padding: '10px 16px',
border: '1px solid var(--bl-border, #ccc)',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-surface, #fff)',
color: 'var(--bl-text, #333)',
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '14px',
opacity: disabled ? 0.6 : 1,
}}
>
Continue with {PROVIDER_LABELS[provider]}
</button>
))}
</div>
);
}

View File

@ -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(<SocialButtons providers={['google', 'microsoft', 'apple']} onSelect={onSelect} />);
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(<SocialButtons providers={['google']} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('bl-social-google'));
expect(onSelect).toHaveBeenCalledWith('google');
});
it('disables buttons when disabled prop is true', () => {
const onSelect = vi.fn();
render(<SocialButtons providers={['google']} onSelect={onSelect} disabled />);
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(<LoginForm onSubmit={onSubmit} />);
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(<LoginForm onSubmit={onSubmit} />);
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(<LoginForm onSubmit={onSubmit} error="Invalid credentials" />);
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(
<LoginForm
onSubmit={onSubmit}
providers={['google', 'apple']}
onSocialLogin={onSocialLogin}
/>
);
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(<LoginForm onSubmit={onSubmit} isLoading />);
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(<MfaChallenge onSubmit={onSubmit} />);
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(<MfaChallenge onSubmit={onSubmit} />);
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(<MfaChallenge onSubmit={onSubmit} methods={['totp', 'recovery']} />);
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(<MfaChallenge onSubmit={onSubmit} onUseRecovery={onUseRecovery} />);
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(<MfaChallenge onSubmit={onSubmit} error="Invalid code" />);
expect(screen.getByTestId('bl-mfa-error')).toBeDefined();
expect(screen.getByText('Invalid code')).toBeDefined();
});
});
});

View File

@ -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';

View File

@ -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;
}

View File

@ -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"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
},
});

View File

@ -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');
});
});

View File

@ -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<typeof createRemoteJWKSet> | null = null;
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
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<JoseCryptoKey> {
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<typeof createRemoteJWKSet> {
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<string, unknown>, expiry: string): Promise<string> {
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<TokenPayload | null> {
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<TokenPayload | null> {
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;
}

View File

@ -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 {

View File

@ -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<string, string> = {};
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<Parameters<typeof createAuthProvider<TestUser>>[0]>) {
return createAuthProvider<TestUser>({
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<boolean>;
function Component() {
const { loginWithGoogle, user, isAuthenticated } = useAuth();
loginWithGoogleFn = loginWithGoogle;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
function Component() {
const { loginWithGoogle, isAuthenticated, error } = useAuth();
loginWithGoogleFn = loginWithGoogle;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="error">{error ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
function Component() {
const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth();
loginFn = login;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
<span data-testid="challenge">{mfaChallenge ?? 'none'}</span>
<span data-testid="methods">{mfaMethods.join(',') || 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise<boolean>;
function Component() {
const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth();
loginFn = login;
verifyMfaFn = verifyMfa;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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 (
<div>
<span data-testid="count">{providers.length}</span>
<span data-testid="hasLink">{String(typeof linkProvider === 'function')}</span>
<span data-testid="hasUnlink">{String(typeof unlinkProvider === 'function')}</span>
<span data-testid="hasRefresh">{String(typeof refreshProviders === 'function')}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
let logoutFn: () => void;
function Component() {
const { login, logout, isAuthenticated, mfaRequired } = useAuth();
loginFn = login;
logoutFn = logout;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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');
});
});

View File

@ -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<TUser extends BaseUser = BaseUser>(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<TUser extends BaseUser = BaseUser>(config: Au
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [providers, setProviders] = useState<AuthProviderInfo[]>([]);
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const api = createApiClient({
@ -138,12 +147,30 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
};
}, [user, refreshAccessToken, refreshIntervalMs]);
// ── MFA challenge helper ─────────────────────────
function handleMfaChallenge(data: Record<string, unknown>): 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<unknown>(loginEndpoint, {
method: 'POST',
@ -151,6 +178,9 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
});
if (data && !fetchError) {
if (handleMfaChallenge(data as Record<string, unknown>)) {
return false;
}
const mapped = mapLoginResponse(data);
setUser(mapped.user);
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
@ -207,11 +237,154 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(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<unknown>(
`${oauthEndpoint}/${provider}`,
{ method: 'POST', body: JSON.stringify({ idToken }) }
);
if (data && !fetchError) {
if (handleMfaChallenge(data as Record<string, unknown>)) {
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<AuthProviderInfo[]>(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<void>(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<void>(
`${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<unknown>(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<TUser extends BaseUser = BaseUser>(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}

View File

@ -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';

View File

@ -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<TUser extends BaseUser = BaseUser> {
user: TUser | null;
isAuthenticated: boolean;
@ -19,6 +26,23 @@ export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
deleteAccount: (password: string) => Promise<boolean>;
updateUser: (updates: Partial<TUser>) => void;
clearMessages: () => void;
// ── SmartAuth: Social login (Phase 1C) ────────────
loginWithGoogle: (idToken: string) => Promise<boolean>;
loginWithMicrosoft: (idToken: string) => Promise<boolean>;
loginWithApple: (idToken: string) => Promise<boolean>;
// ── SmartAuth: Provider management (Phase 1C) ─────
providers: AuthProviderInfo[];
linkProvider: (provider: string, idToken: string) => Promise<boolean>;
unlinkProvider: (provider: string) => Promise<boolean>;
refreshProviders: () => Promise<void>;
// ── SmartAuth: MFA state (Phase 2D) ───────────────
mfaRequired: boolean;
mfaMethods: string[];
mfaChallenge: string | null;
verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise<boolean>;
}
export interface LoginResult<TUser extends BaseUser = BaseUser> {
@ -48,4 +72,16 @@ export interface AuthConfig<TUser extends BaseUser = BaseUser> {
/** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */
onInit?: () => LoginResult<TUser> | 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;
}