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:
parent
c8b520ba12
commit
53f2a97d40
518
packages/auth-client/src/__tests__/smartauth.test.ts
Normal file
518
packages/auth-client/src/__tests__/smartauth.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 ─────────────────────────────────
|
// ── Default localStorage adapter ─────────────────────────────────
|
||||||
|
|
||||||
@ -167,14 +181,18 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
|
|||||||
|
|
||||||
// ── Auth operations ─────────────────────────────
|
// ── Auth operations ─────────────────────────────
|
||||||
|
|
||||||
async function login(email: string, password: string): Promise<AuthResult> {
|
async function login(email: string, password: string): Promise<AuthResult | MfaRequiredResult> {
|
||||||
const result = await request<AuthResult>('/auth/login', 'POST', {
|
const result = await request<AuthResult | MfaRequiredResult>('/auth/login', 'POST', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
productId,
|
productId,
|
||||||
});
|
});
|
||||||
setTokens(result.accessToken, result.refreshToken);
|
if ('mfaRequired' in result && result.mfaRequired) {
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
const authResult = result as AuthResult;
|
||||||
|
setTokens(authResult.accessToken, authResult.refreshToken);
|
||||||
|
return authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(
|
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 {
|
return {
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
@ -261,5 +461,44 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
|
|||||||
deleteAccount,
|
deleteAccount,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
resendVerification,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,16 @@
|
|||||||
export { createAuthClient } from './client.js';
|
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';
|
||||||
|
|||||||
@ -32,6 +32,10 @@ export interface AuthUser {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
role: string;
|
role: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
|
mfaEnabled?: boolean;
|
||||||
|
mfaMethods?: string[];
|
||||||
|
providers?: string[];
|
||||||
|
products?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
@ -40,6 +44,70 @@ export interface AuthResult {
|
|||||||
user: AuthUser;
|
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 {
|
export interface AuthClient {
|
||||||
// ── Token management ────────────────────────────
|
// ── Token management ────────────────────────────
|
||||||
getAccessToken(): string | null;
|
getAccessToken(): string | null;
|
||||||
@ -49,11 +117,56 @@ export interface AuthClient {
|
|||||||
isAuthenticated(): boolean;
|
isAuthenticated(): boolean;
|
||||||
|
|
||||||
// ── Auth operations ─────────────────────────────
|
// ── 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>;
|
register(email: string, password: string, displayName: string): Promise<AuthResult>;
|
||||||
getMe(): Promise<AuthUser>;
|
getMe(): Promise<AuthUser>;
|
||||||
refreshAccessToken(): Promise<boolean>;
|
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 ─────────────────────────
|
// ── Password management ─────────────────────────
|
||||||
forgotPassword(email: string): Promise<{ message: string }>;
|
forgotPassword(email: string): Promise<{ message: string }>;
|
||||||
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
|
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
|
||||||
@ -61,6 +174,7 @@ export interface AuthClient {
|
|||||||
|
|
||||||
// ── Account management ──────────────────────────
|
// ── Account management ──────────────────────────
|
||||||
deleteAccount(password: string): Promise<{ message: string }>;
|
deleteAccount(password: string): Promise<{ message: string }>;
|
||||||
|
cancelDeletion(): Promise<{ message: string }>;
|
||||||
|
|
||||||
// ── Email verification ──────────────────────────
|
// ── Email verification ──────────────────────────
|
||||||
verifyEmail(token: string): Promise<{ message: string }>;
|
verifyEmail(token: string): Promise<{ message: string }>;
|
||||||
|
|||||||
33
packages/auth-ui/package.json
Normal file
33
packages/auth-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
116
packages/auth-ui/src/LoginForm.tsx
Normal file
116
packages/auth-ui/src/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
packages/auth-ui/src/MfaChallenge.tsx
Normal file
114
packages/auth-ui/src/MfaChallenge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
packages/auth-ui/src/SocialButtons.tsx
Normal file
48
packages/auth-ui/src/SocialButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
packages/auth-ui/src/__tests__/auth-ui.test.tsx
Normal file
154
packages/auth-ui/src/__tests__/auth-ui.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
9
packages/auth-ui/src/index.ts
Normal file
9
packages/auth-ui/src/index.ts
Normal 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';
|
||||||
42
packages/auth-ui/src/types.ts
Normal file
42
packages/auth-ui/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
11
packages/auth-ui/tsconfig.json
Normal file
11
packages/auth-ui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
7
packages/auth-ui/vitest.config.ts
Normal file
7
packages/auth-ui/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
133
packages/auth/src/__tests__/rs256.test.ts
Normal file
133
packages/auth/src/__tests__/rs256.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,12 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* JWT utilities — configurable issuer and expiry.
|
* JWT utilities — configurable issuer, expiry, and algorithm.
|
||||||
* Uses jose library for standards-compliant JWT handling.
|
* 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';
|
import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js';
|
||||||
|
|
||||||
function getSecret(): Uint8Array {
|
function getHmacSecret(): Uint8Array {
|
||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
if (!secret) throw new Error('JWT_SECRET must be set');
|
if (!secret) throw new Error('JWT_SECRET must be set');
|
||||||
return new TextEncoder().encode(secret);
|
return new TextEncoder().encode(secret);
|
||||||
@ -17,47 +29,145 @@ function getSecret(): Uint8Array {
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const jwt = createJwtUtils({ issuer: "lysnrai", accessTokenExpiry: "1h" });
|
* // HS256 (default, backward-compatible)
|
||||||
* const token = await jwt.createAccessToken({ sub: "u1", email: "a@b.com", role: "admin" });
|
* const jwt = createJwtUtils({ issuer: "bytelyst-platform" });
|
||||||
* const payload = await jwt.verifyToken(token);
|
*
|
||||||
|
* // 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 {
|
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 {
|
return {
|
||||||
async createAccessToken(payload) {
|
async createAccessToken(payload) {
|
||||||
return new SignJWT({
|
return sign(
|
||||||
...payload,
|
{
|
||||||
productId: payload.productId || issuer,
|
...payload,
|
||||||
type: 'access',
|
productId: payload.productId || issuer,
|
||||||
})
|
type: 'access',
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
},
|
||||||
.setIssuedAt()
|
accessTokenExpiry
|
||||||
.setExpirationTime(accessTokenExpiry)
|
);
|
||||||
.setIssuer(issuer)
|
|
||||||
.sign(getSecret());
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createRefreshToken(payload) {
|
async createRefreshToken(payload) {
|
||||||
return new SignJWT({
|
return sign(
|
||||||
sub: payload.sub,
|
{
|
||||||
productId: payload.productId || issuer,
|
sub: payload.sub,
|
||||||
type: 'refresh',
|
productId: payload.productId || issuer,
|
||||||
})
|
type: 'refresh',
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
},
|
||||||
.setIssuedAt()
|
refreshTokenExpiry
|
||||||
.setExpirationTime(refreshTokenExpiry)
|
);
|
||||||
.setIssuer(issuer)
|
|
||||||
.sign(getSecret());
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async verifyToken(token: string) {
|
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 {
|
try {
|
||||||
const { payload } = await jwtVerify(token, getSecret(), {
|
return await verifyWithHS256(token);
|
||||||
issuer,
|
|
||||||
});
|
|
||||||
return payload as unknown as TokenPayload;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,14 @@ export interface JwtUtilsOptions {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
accessTokenExpiry?: string;
|
accessTokenExpiry?: string;
|
||||||
refreshTokenExpiry?: 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 {
|
export interface JwtUtils {
|
||||||
|
|||||||
338
packages/react-auth/src/__tests__/smartauth.test.tsx
Normal file
338
packages/react-auth/src/__tests__/smartauth.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createApiClient } from '@bytelyst/api-client';
|
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.
|
* 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,
|
onLoginFallback,
|
||||||
onInit,
|
onInit,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
oauthEndpoint = '/auth/oauth',
|
||||||
|
providersEndpoint = '/auth/providers',
|
||||||
|
linkProviderEndpoint = '/auth/providers/link',
|
||||||
|
mfaVerifyEndpoint = '/auth/mfa/verify',
|
||||||
|
onMfaRequired,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
const USER_KEY = `${storagePrefix}_auth_user`;
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = 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 refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const api = createApiClient({
|
const api = createApiClient({
|
||||||
@ -138,12 +147,30 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
};
|
};
|
||||||
}, [user, refreshAccessToken, refreshIntervalMs]);
|
}, [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 ──────────────────────────────────────
|
// ── Login ──────────────────────────────────────
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMfaRequired(false);
|
||||||
|
setMfaChallenge(null);
|
||||||
|
setMfaMethods([]);
|
||||||
try {
|
try {
|
||||||
const { data, error: fetchError } = await api.safeFetch<unknown>(loginEndpoint, {
|
const { data, error: fetchError } = await api.safeFetch<unknown>(loginEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -151,6 +178,9 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (data && !fetchError) {
|
if (data && !fetchError) {
|
||||||
|
if (handleMfaChallenge(data as Record<string, unknown>)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const mapped = mapLoginResponse(data);
|
const mapped = mapLoginResponse(data);
|
||||||
setUser(mapped.user);
|
setUser(mapped.user);
|
||||||
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
|
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
|
||||||
@ -207,11 +237,154 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
[api]
|
[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 ─────────────────────────────────────
|
// ── Logout ─────────────────────────────────────
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
clearSession();
|
clearSession();
|
||||||
|
setProviders([]);
|
||||||
|
setMfaRequired(false);
|
||||||
|
setMfaChallenge(null);
|
||||||
|
setMfaMethods([]);
|
||||||
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
|
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
|
||||||
onLogout?.();
|
onLogout?.();
|
||||||
}, []);
|
}, []);
|
||||||
@ -332,6 +505,20 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
deleteAccount,
|
deleteAccount,
|
||||||
updateUser,
|
updateUser,
|
||||||
clearMessages,
|
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}
|
{children}
|
||||||
|
|||||||
@ -1,2 +1,8 @@
|
|||||||
export { createAuthProvider } from './auth-context.js';
|
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';
|
||||||
|
|||||||
@ -5,6 +5,13 @@ export interface BaseUser {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthProviderInfo {
|
||||||
|
provider: string;
|
||||||
|
email: string;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
|
export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
|
||||||
user: TUser | null;
|
user: TUser | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@ -19,6 +26,23 @@ export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
|
|||||||
deleteAccount: (password: string) => Promise<boolean>;
|
deleteAccount: (password: string) => Promise<boolean>;
|
||||||
updateUser: (updates: Partial<TUser>) => void;
|
updateUser: (updates: Partial<TUser>) => void;
|
||||||
clearMessages: () => 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> {
|
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. */
|
/** 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;
|
onInit?: () => LoginResult<TUser> | null;
|
||||||
onLogout?: () => void;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user