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 ─────────────────────────────────
|
||||
|
||||
@ -167,14 +181,18 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
|
||||
|
||||
// ── Auth operations ─────────────────────────────
|
||||
|
||||
async function login(email: string, password: string): Promise<AuthResult> {
|
||||
const result = await request<AuthResult>('/auth/login', 'POST', {
|
||||
async function login(email: string, password: string): Promise<AuthResult | MfaRequiredResult> {
|
||||
const result = await request<AuthResult | MfaRequiredResult>('/auth/login', 'POST', {
|
||||
email,
|
||||
password,
|
||||
productId,
|
||||
});
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
return result;
|
||||
if ('mfaRequired' in result && result.mfaRequired) {
|
||||
return result;
|
||||
}
|
||||
const authResult = result as AuthResult;
|
||||
setTokens(authResult.accessToken, authResult.refreshToken);
|
||||
return authResult;
|
||||
}
|
||||
|
||||
async function register(
|
||||
@ -245,6 +263,188 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ── OAuth / Social login (Phase 1C) ────────────────
|
||||
|
||||
async function loginWithOAuth(
|
||||
provider: string,
|
||||
idToken: string
|
||||
): Promise<AuthResult | MfaRequiredResult> {
|
||||
const result = await request<AuthResult | MfaRequiredResult>(
|
||||
`/auth/oauth/${provider}`,
|
||||
'POST',
|
||||
{ idToken },
|
||||
{ skipAuth: true }
|
||||
);
|
||||
if ('mfaRequired' in result && result.mfaRequired) {
|
||||
return result;
|
||||
}
|
||||
const authResult = result as AuthResult;
|
||||
setTokens(authResult.accessToken, authResult.refreshToken);
|
||||
return authResult;
|
||||
}
|
||||
|
||||
async function loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||
return loginWithOAuth('google', idToken);
|
||||
}
|
||||
|
||||
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||
return loginWithOAuth('microsoft', idToken);
|
||||
}
|
||||
|
||||
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||
return loginWithOAuth('apple', idToken);
|
||||
}
|
||||
|
||||
// ── Provider management (Phase 1C) ─────────────────
|
||||
|
||||
async function getProviders(): Promise<AuthProvider[]> {
|
||||
return request<AuthProvider[]>('/auth/providers', 'GET');
|
||||
}
|
||||
|
||||
async function linkProvider(provider: string, idToken: string): Promise<void> {
|
||||
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
|
||||
}
|
||||
|
||||
async function unlinkProvider(provider: string): Promise<void> {
|
||||
await request<void>(`/auth/providers/${provider}`, 'DELETE');
|
||||
}
|
||||
|
||||
// ── MFA (Phase 2D) ─────────────────────────────────
|
||||
|
||||
async function verifyMfa(
|
||||
challengeToken: string,
|
||||
code: string,
|
||||
method: 'totp' | 'recovery'
|
||||
): Promise<AuthResult> {
|
||||
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
|
||||
challengeToken,
|
||||
code,
|
||||
method,
|
||||
});
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function setupTotp(): Promise<TotpSetupResult> {
|
||||
return request<TotpSetupResult>('/auth/mfa/totp/setup', 'POST');
|
||||
}
|
||||
|
||||
async function verifyTotpSetup(code: string): Promise<void> {
|
||||
await request<void>('/auth/mfa/totp/verify-setup', 'POST', { code });
|
||||
}
|
||||
|
||||
async function disableMfa(): Promise<void> {
|
||||
await request<void>('/auth/mfa/totp', 'DELETE');
|
||||
}
|
||||
|
||||
async function getMfaStatus(): Promise<MfaStatus> {
|
||||
return request<MfaStatus>('/auth/mfa/status', 'GET');
|
||||
}
|
||||
|
||||
async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> {
|
||||
return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST');
|
||||
}
|
||||
|
||||
// ── Passkeys (Phase 3) ─────────────────────────────
|
||||
|
||||
async function getPasskeyRegisterOptions(): Promise<unknown> {
|
||||
return request<unknown>('/auth/passkeys/register/options', 'POST');
|
||||
}
|
||||
|
||||
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
|
||||
await request<void>('/auth/passkeys/register/verify', 'POST', response);
|
||||
}
|
||||
|
||||
async function getPasskeyAuthOptions(): Promise<unknown> {
|
||||
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
|
||||
const result = await request<AuthResult>(
|
||||
'/auth/passkeys/authenticate/verify',
|
||||
'POST',
|
||||
response,
|
||||
{ skipAuth: true }
|
||||
);
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listPasskeys(): Promise<Passkey[]> {
|
||||
return request<Passkey[]>('/auth/passkeys', 'GET');
|
||||
}
|
||||
|
||||
async function deletePasskey(id: string): Promise<void> {
|
||||
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Devices (Phase 3) ──────────────────────────────
|
||||
|
||||
async function listDevices(): Promise<Device[]> {
|
||||
return request<Device[]>('/auth/devices', 'GET');
|
||||
}
|
||||
|
||||
async function trustDevice(): Promise<void> {
|
||||
await request<void>('/auth/devices/trust', 'POST');
|
||||
}
|
||||
|
||||
async function revokeDevice(deviceId: string): Promise<void> {
|
||||
await request<void>(`/auth/devices/${deviceId}`, 'DELETE');
|
||||
}
|
||||
|
||||
async function revokeAllDevices(): Promise<void> {
|
||||
await request<void>('/auth/devices', 'DELETE');
|
||||
}
|
||||
|
||||
// ── Admin security (Phase 5B) ──────────────────────
|
||||
|
||||
async function getSecurityOverview(): Promise<SecurityOverview> {
|
||||
return request<SecurityOverview>('/auth/security/overview', 'GET');
|
||||
}
|
||||
|
||||
async function unlockUser(userId: string): Promise<void> {
|
||||
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
|
||||
}
|
||||
|
||||
async function exportAuthData(): Promise<unknown> {
|
||||
return request<unknown>('/auth/export', 'GET');
|
||||
}
|
||||
|
||||
async function cancelDeletion(): Promise<{ message: string }> {
|
||||
return request<{ message: string }>('/auth/account/cancel-deletion', 'POST');
|
||||
}
|
||||
|
||||
// ── Step-up auth ────────────────────────────────────
|
||||
|
||||
async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> {
|
||||
return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential });
|
||||
}
|
||||
|
||||
// ── Login history ───────────────────────────────────
|
||||
|
||||
async function getLoginHistory(limit = 50): Promise<LoginEventInfo[]> {
|
||||
return request<LoginEventInfo[]>(`/auth/login-events/me?limit=${limit}`, 'GET');
|
||||
}
|
||||
|
||||
// ── Admin security ──────────────────────────────────
|
||||
|
||||
async function getAdminLoginEvents(opts?: {
|
||||
suspicious?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<LoginEventInfo[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.suspicious) params.set('suspicious', 'true');
|
||||
if (opts?.limit) params.set('limit', String(opts.limit));
|
||||
const qs = params.toString();
|
||||
return request<LoginEventInfo[]>(`/auth/login-events${qs ? `?${qs}` : ''}`, 'GET');
|
||||
}
|
||||
|
||||
async function getAdminDevices(userId: string): Promise<Device[]> {
|
||||
return request<Device[]>(`/auth/devices/user/${userId}`, 'GET');
|
||||
}
|
||||
|
||||
return {
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
@ -261,5 +461,44 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
|
||||
deleteAccount,
|
||||
verifyEmail,
|
||||
resendVerification,
|
||||
// OAuth / Social login (Phase 1C)
|
||||
loginWithGoogle,
|
||||
loginWithMicrosoft,
|
||||
loginWithApple,
|
||||
// Provider management (Phase 1C)
|
||||
getProviders,
|
||||
linkProvider,
|
||||
unlinkProvider,
|
||||
// MFA (Phase 2D)
|
||||
verifyMfa,
|
||||
setupTotp,
|
||||
verifyTotpSetup,
|
||||
disableMfa,
|
||||
getMfaStatus,
|
||||
regenerateRecoveryCodes,
|
||||
// Passkeys (Phase 3)
|
||||
getPasskeyRegisterOptions,
|
||||
verifyPasskeyRegistration,
|
||||
getPasskeyAuthOptions,
|
||||
verifyPasskeyAuth,
|
||||
listPasskeys,
|
||||
deletePasskey,
|
||||
// Devices (Phase 3)
|
||||
listDevices,
|
||||
trustDevice,
|
||||
revokeDevice,
|
||||
revokeAllDevices,
|
||||
// Admin security (Phase 5B)
|
||||
getSecurityOverview,
|
||||
unlockUser,
|
||||
exportAuthData,
|
||||
cancelDeletion,
|
||||
// Step-up auth
|
||||
stepUp,
|
||||
// Login history
|
||||
getLoginHistory,
|
||||
// Admin queries
|
||||
getAdminLoginEvents,
|
||||
getAdminDevices,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,2 +1,16 @@
|
||||
export { createAuthClient } from './client.js';
|
||||
export type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js';
|
||||
export type {
|
||||
AuthClient,
|
||||
AuthClientConfig,
|
||||
AuthProvider,
|
||||
AuthResult,
|
||||
AuthUser,
|
||||
Device,
|
||||
LoginEventInfo,
|
||||
MfaRequiredResult,
|
||||
MfaStatus,
|
||||
Passkey,
|
||||
SecurityOverview,
|
||||
TokenStorage,
|
||||
TotpSetupResult,
|
||||
} from './types.js';
|
||||
|
||||
@ -32,6 +32,10 @@ export interface AuthUser {
|
||||
displayName: string;
|
||||
role: string;
|
||||
plan: string;
|
||||
mfaEnabled?: boolean;
|
||||
mfaMethods?: string[];
|
||||
providers?: string[];
|
||||
products?: string[];
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
@ -40,6 +44,70 @@ export interface AuthResult {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export interface MfaRequiredResult {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
methods: string[];
|
||||
}
|
||||
|
||||
export interface TotpSetupResult {
|
||||
otpauthUri: string;
|
||||
qrDataUrl: string;
|
||||
recoveryCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaStatus {
|
||||
mfaEnabled: boolean;
|
||||
methods: string[];
|
||||
recoveryCodesRemaining: number;
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
provider: string;
|
||||
email: string;
|
||||
linkedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Passkey {
|
||||
id: string;
|
||||
friendlyName: string;
|
||||
deviceType: 'platform' | 'cross-platform';
|
||||
backedUp: boolean;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
trustLevel: 'trusted' | 'remembered' | 'unknown';
|
||||
trustExpiresAt: string | null;
|
||||
lastIp: string;
|
||||
lastLoginAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginEventInfo {
|
||||
id: string;
|
||||
eventType: string;
|
||||
method: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
riskScore: number;
|
||||
riskFactors: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SecurityOverview {
|
||||
totalUsers: number;
|
||||
mfaAdoptionPercent: number;
|
||||
providerDistribution: Record<string, number>;
|
||||
activeSessions: number;
|
||||
suspiciousEvents24h: number;
|
||||
}
|
||||
|
||||
export interface AuthClient {
|
||||
// ── Token management ────────────────────────────
|
||||
getAccessToken(): string | null;
|
||||
@ -49,11 +117,56 @@ export interface AuthClient {
|
||||
isAuthenticated(): boolean;
|
||||
|
||||
// ── Auth operations ─────────────────────────────
|
||||
login(email: string, password: string): Promise<AuthResult>;
|
||||
login(email: string, password: string): Promise<AuthResult | MfaRequiredResult>;
|
||||
register(email: string, password: string, displayName: string): Promise<AuthResult>;
|
||||
getMe(): Promise<AuthUser>;
|
||||
refreshAccessToken(): Promise<boolean>;
|
||||
|
||||
// ── OAuth / Social login ────────────────────────
|
||||
loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||
loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||
loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||
|
||||
// ── Provider management ─────────────────────────
|
||||
getProviders(): Promise<AuthProvider[]>;
|
||||
linkProvider(provider: string, idToken: string): Promise<void>;
|
||||
unlinkProvider(provider: string): Promise<void>;
|
||||
|
||||
// ── MFA ─────────────────────────────────────────
|
||||
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
|
||||
setupTotp(): Promise<TotpSetupResult>;
|
||||
verifyTotpSetup(code: string): Promise<void>;
|
||||
disableMfa(): Promise<void>;
|
||||
getMfaStatus(): Promise<MfaStatus>;
|
||||
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
|
||||
|
||||
// ── Passkeys (WebAuthn) ─────────────────────────
|
||||
getPasskeyRegisterOptions(): Promise<unknown>;
|
||||
verifyPasskeyRegistration(response: unknown): Promise<void>;
|
||||
getPasskeyAuthOptions(): Promise<unknown>;
|
||||
verifyPasskeyAuth(response: unknown): Promise<AuthResult>;
|
||||
listPasskeys(): Promise<Passkey[]>;
|
||||
deletePasskey(id: string): Promise<void>;
|
||||
|
||||
// ── Devices ─────────────────────────────────────
|
||||
listDevices(): Promise<Device[]>;
|
||||
trustDevice(): Promise<void>;
|
||||
revokeDevice(deviceId: string): Promise<void>;
|
||||
revokeAllDevices(): Promise<void>;
|
||||
|
||||
// ── Step-up auth ────────────────────────────────
|
||||
stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>;
|
||||
|
||||
// ── Login history ───────────────────────────────
|
||||
getLoginHistory(limit?: number): Promise<LoginEventInfo[]>;
|
||||
|
||||
// ── Admin security ──────────────────────────────
|
||||
getSecurityOverview(): Promise<SecurityOverview>;
|
||||
unlockUser(userId: string): Promise<void>;
|
||||
getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise<LoginEventInfo[]>;
|
||||
getAdminDevices(userId: string): Promise<Device[]>;
|
||||
exportAuthData(): Promise<unknown>;
|
||||
|
||||
// ── Password management ─────────────────────────
|
||||
forgotPassword(email: string): Promise<{ message: string }>;
|
||||
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
|
||||
@ -61,6 +174,7 @@ export interface AuthClient {
|
||||
|
||||
// ── Account management ──────────────────────────
|
||||
deleteAccount(password: string): Promise<{ message: string }>;
|
||||
cancelDeletion(): Promise<{ message: string }>;
|
||||
|
||||
// ── Email verification ──────────────────────────
|
||||
verifyEmail(token: string): Promise<{ message: string }>;
|
||||
|
||||
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.
|
||||
* Uses jose library for standards-compliant JWT handling.
|
||||
* JWT utilities — configurable issuer, expiry, and algorithm.
|
||||
* Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose.
|
||||
*
|
||||
* RS256 mode (Phase 4C SmartAuth):
|
||||
* - Sign with RSA private key (PEM)
|
||||
* - Verify with RSA public key (PEM) or remote JWKS URL
|
||||
* - Dual verification: tries RS256 first, falls back to HS256 during migration
|
||||
*/
|
||||
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import {
|
||||
SignJWT,
|
||||
jwtVerify,
|
||||
importPKCS8,
|
||||
importSPKI,
|
||||
createRemoteJWKSet,
|
||||
type CryptoKey as JoseCryptoKey,
|
||||
} from 'jose';
|
||||
import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js';
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
function getHmacSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error('JWT_SECRET must be set');
|
||||
return new TextEncoder().encode(secret);
|
||||
@ -17,47 +29,145 @@ function getSecret(): Uint8Array {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jwt = createJwtUtils({ issuer: "lysnrai", accessTokenExpiry: "1h" });
|
||||
* const token = await jwt.createAccessToken({ sub: "u1", email: "a@b.com", role: "admin" });
|
||||
* const payload = await jwt.verifyToken(token);
|
||||
* // HS256 (default, backward-compatible)
|
||||
* const jwt = createJwtUtils({ issuer: "bytelyst-platform" });
|
||||
*
|
||||
* // RS256 (SmartAuth Phase 4C)
|
||||
* const jwt = createJwtUtils({
|
||||
* issuer: "bytelyst-platform",
|
||||
* algorithm: "RS256",
|
||||
* rsaPrivateKey: process.env.JWT_PRIVATE_KEY,
|
||||
* rsaPublicKey: process.env.JWT_PUBLIC_KEY,
|
||||
* });
|
||||
*
|
||||
* // RS256 verify-only (product backends — no private key)
|
||||
* const jwt = createJwtUtils({
|
||||
* issuer: "bytelyst-platform",
|
||||
* algorithm: "RS256",
|
||||
* jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createJwtUtils(options: JwtUtilsOptions): JwtUtils {
|
||||
const { issuer, accessTokenExpiry = '1h', refreshTokenExpiry = '30d' } = options;
|
||||
const {
|
||||
issuer,
|
||||
accessTokenExpiry = '1h',
|
||||
refreshTokenExpiry = '30d',
|
||||
algorithm = 'HS256',
|
||||
rsaPrivateKey,
|
||||
rsaPublicKey,
|
||||
jwksUrl,
|
||||
} = options;
|
||||
|
||||
// ── Key caches ────────────────────────────────────
|
||||
|
||||
let _rsaPrivateKeyObj: JoseCryptoKey | null = null;
|
||||
let _rsaPublicKeyObj: JoseCryptoKey | null = null;
|
||||
let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
|
||||
if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj;
|
||||
if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing');
|
||||
_rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey;
|
||||
return _rsaPrivateKeyObj;
|
||||
}
|
||||
|
||||
async function getRsaPublicKey(): Promise<JoseCryptoKey> {
|
||||
if (_rsaPublicKeyObj) return _rsaPublicKeyObj;
|
||||
if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification');
|
||||
_rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey;
|
||||
return _rsaPublicKeyObj;
|
||||
}
|
||||
|
||||
function getJwksKeySet(): ReturnType<typeof createRemoteJWKSet> {
|
||||
if (_jwksKeySet) return _jwksKeySet;
|
||||
if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification');
|
||||
_jwksKeySet = createRemoteJWKSet(new URL(jwksUrl));
|
||||
return _jwksKeySet;
|
||||
}
|
||||
|
||||
// ── Signing ───────────────────────────────────────
|
||||
|
||||
async function sign(claims: Record<string, unknown>, expiry: string): Promise<string> {
|
||||
if (algorithm === 'RS256') {
|
||||
const key = await getRsaPrivateKey();
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiry)
|
||||
.setIssuer(issuer)
|
||||
.sign(key);
|
||||
}
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiry)
|
||||
.setIssuer(issuer)
|
||||
.sign(getHmacSecret());
|
||||
}
|
||||
|
||||
// ── Verification (dual: RS256 first, HS256 fallback) ──
|
||||
|
||||
async function verifyWithRS256(token: string): Promise<TokenPayload | null> {
|
||||
try {
|
||||
if (jwksUrl) {
|
||||
const keySet = getJwksKeySet();
|
||||
const { payload } = await jwtVerify(token, keySet, { issuer });
|
||||
return payload as unknown as TokenPayload;
|
||||
}
|
||||
if (rsaPublicKey) {
|
||||
const key = await getRsaPublicKey();
|
||||
const { payload } = await jwtVerify(token, key, { issuer });
|
||||
return payload as unknown as TokenPayload;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyWithHS256(token: string): Promise<TokenPayload | null> {
|
||||
try {
|
||||
const secret = getHmacSecret();
|
||||
const { payload } = await jwtVerify(token, secret, { issuer });
|
||||
return payload as unknown as TokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async createAccessToken(payload) {
|
||||
return new SignJWT({
|
||||
...payload,
|
||||
productId: payload.productId || issuer,
|
||||
type: 'access',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(accessTokenExpiry)
|
||||
.setIssuer(issuer)
|
||||
.sign(getSecret());
|
||||
return sign(
|
||||
{
|
||||
...payload,
|
||||
productId: payload.productId || issuer,
|
||||
type: 'access',
|
||||
},
|
||||
accessTokenExpiry
|
||||
);
|
||||
},
|
||||
|
||||
async createRefreshToken(payload) {
|
||||
return new SignJWT({
|
||||
sub: payload.sub,
|
||||
productId: payload.productId || issuer,
|
||||
type: 'refresh',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(refreshTokenExpiry)
|
||||
.setIssuer(issuer)
|
||||
.sign(getSecret());
|
||||
return sign(
|
||||
{
|
||||
sub: payload.sub,
|
||||
productId: payload.productId || issuer,
|
||||
type: 'refresh',
|
||||
},
|
||||
refreshTokenExpiry
|
||||
);
|
||||
},
|
||||
|
||||
async verifyToken(token: string) {
|
||||
// Dual verification: try RS256 first (if configured), then HS256 fallback
|
||||
if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) {
|
||||
const rs256Result = await verifyWithRS256(token);
|
||||
if (rs256Result) return rs256Result;
|
||||
}
|
||||
// HS256 fallback (safe during migration; removed after full RS256 rollout)
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret(), {
|
||||
issuer,
|
||||
});
|
||||
return payload as unknown as TokenPayload;
|
||||
return await verifyWithHS256(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -19,6 +19,14 @@ export interface JwtUtilsOptions {
|
||||
issuer: string;
|
||||
accessTokenExpiry?: string;
|
||||
refreshTokenExpiry?: string;
|
||||
/** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */
|
||||
algorithm?: 'HS256' | 'RS256';
|
||||
/** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */
|
||||
rsaPrivateKey?: string;
|
||||
/** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */
|
||||
rsaPublicKey?: string;
|
||||
/** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */
|
||||
jwksUrl?: string;
|
||||
}
|
||||
|
||||
export interface JwtUtils {
|
||||
|
||||
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,
|
||||
} from 'react';
|
||||
import { createApiClient } from '@bytelyst/api-client';
|
||||
import type { AuthConfig, AuthContextValue, BaseUser } from './types.js';
|
||||
import type { AuthConfig, AuthContextValue, AuthProviderInfo, BaseUser } from './types.js';
|
||||
|
||||
/**
|
||||
* Create a typed auth provider + hook for a specific user type.
|
||||
@ -51,6 +51,11 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
onLoginFallback,
|
||||
onInit,
|
||||
onLogout,
|
||||
oauthEndpoint = '/auth/oauth',
|
||||
providersEndpoint = '/auth/providers',
|
||||
linkProviderEndpoint = '/auth/providers/link',
|
||||
mfaVerifyEndpoint = '/auth/mfa/verify',
|
||||
onMfaRequired,
|
||||
} = config;
|
||||
|
||||
const USER_KEY = `${storagePrefix}_auth_user`;
|
||||
@ -96,6 +101,10 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [providers, setProviders] = useState<AuthProviderInfo[]>([]);
|
||||
const [mfaRequired, setMfaRequired] = useState(false);
|
||||
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
|
||||
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const api = createApiClient({
|
||||
@ -138,12 +147,30 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
};
|
||||
}, [user, refreshAccessToken, refreshIntervalMs]);
|
||||
|
||||
// ── MFA challenge helper ─────────────────────────
|
||||
|
||||
function handleMfaChallenge(data: Record<string, unknown>): boolean {
|
||||
if (data && typeof data === 'object' && 'mfaRequired' in data && data.mfaRequired === true) {
|
||||
const challenge = data.mfaChallenge as string;
|
||||
const methods = data.methods as string[];
|
||||
setMfaRequired(true);
|
||||
setMfaChallenge(challenge);
|
||||
setMfaMethods(methods ?? []);
|
||||
onMfaRequired?.(challenge, methods ?? []);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Login ──────────────────────────────────────
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setMfaRequired(false);
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
try {
|
||||
const { data, error: fetchError } = await api.safeFetch<unknown>(loginEndpoint, {
|
||||
method: 'POST',
|
||||
@ -151,6 +178,9 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
});
|
||||
|
||||
if (data && !fetchError) {
|
||||
if (handleMfaChallenge(data as Record<string, unknown>)) {
|
||||
return false;
|
||||
}
|
||||
const mapped = mapLoginResponse(data);
|
||||
setUser(mapped.user);
|
||||
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
|
||||
@ -207,11 +237,154 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
[api]
|
||||
);
|
||||
|
||||
// ── Social login (Phase 1C) ────────────────────
|
||||
|
||||
const loginWithOAuth = useCallback(
|
||||
async (provider: string, idToken: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setMfaRequired(false);
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
try {
|
||||
const { data, error: fetchError } = await api.safeFetch<unknown>(
|
||||
`${oauthEndpoint}/${provider}`,
|
||||
{ method: 'POST', body: JSON.stringify({ idToken }) }
|
||||
);
|
||||
|
||||
if (data && !fetchError) {
|
||||
if (handleMfaChallenge(data as Record<string, unknown>)) {
|
||||
return false;
|
||||
}
|
||||
const mapped = mapLoginResponse(data);
|
||||
setUser(mapped.user);
|
||||
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
setError(fetchError || `${provider} login failed`);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const loginWithGoogle = useCallback(
|
||||
(idToken: string) => loginWithOAuth('google', idToken),
|
||||
[loginWithOAuth]
|
||||
);
|
||||
|
||||
const loginWithMicrosoft = useCallback(
|
||||
(idToken: string) => loginWithOAuth('microsoft', idToken),
|
||||
[loginWithOAuth]
|
||||
);
|
||||
|
||||
const loginWithApple = useCallback(
|
||||
(idToken: string) => loginWithOAuth('apple', idToken),
|
||||
[loginWithOAuth]
|
||||
);
|
||||
|
||||
// ── Provider management (Phase 1C) ────────────
|
||||
|
||||
const refreshProviders = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.fetch<AuthProviderInfo[]>(providersEndpoint, { method: 'GET' });
|
||||
setProviders(data);
|
||||
} catch {
|
||||
// non-fatal — providers list is supplementary
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
const linkProvider = useCallback(
|
||||
async (provider: string, idToken: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: fetchError } = await api.safeFetch<void>(linkProviderEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, idToken }),
|
||||
});
|
||||
if (fetchError) {
|
||||
setError(fetchError);
|
||||
return false;
|
||||
}
|
||||
await refreshProviders();
|
||||
return true;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[api, refreshProviders]
|
||||
);
|
||||
|
||||
const unlinkProvider = useCallback(
|
||||
async (provider: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: fetchError } = await api.safeFetch<void>(
|
||||
`${providersEndpoint}/${provider}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (fetchError) {
|
||||
setError(fetchError);
|
||||
return false;
|
||||
}
|
||||
await refreshProviders();
|
||||
return true;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[api, refreshProviders]
|
||||
);
|
||||
|
||||
// ── MFA verify (Phase 2D) ─────────────────────
|
||||
|
||||
const verifyMfa = useCallback(
|
||||
async (code: string, method: 'totp' | 'recovery') => {
|
||||
if (!mfaChallenge) {
|
||||
setError('No MFA challenge in progress');
|
||||
return false;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data, error: fetchError } = await api.safeFetch<unknown>(mfaVerifyEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ challengeToken: mfaChallenge, code, method }),
|
||||
});
|
||||
|
||||
if (data && !fetchError) {
|
||||
const mapped = mapLoginResponse(data);
|
||||
setUser(mapped.user);
|
||||
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
|
||||
setMfaRequired(false);
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
setError(fetchError || 'MFA verification failed');
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[api, mfaChallenge]
|
||||
);
|
||||
|
||||
// ── Logout ─────────────────────────────────────
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
clearSession();
|
||||
setProviders([]);
|
||||
setMfaRequired(false);
|
||||
setMfaChallenge(null);
|
||||
setMfaMethods([]);
|
||||
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
|
||||
onLogout?.();
|
||||
}, []);
|
||||
@ -332,6 +505,20 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
||||
deleteAccount,
|
||||
updateUser,
|
||||
clearMessages,
|
||||
// SmartAuth: Social login (Phase 1C)
|
||||
loginWithGoogle,
|
||||
loginWithMicrosoft,
|
||||
loginWithApple,
|
||||
// SmartAuth: Provider management (Phase 1C)
|
||||
providers,
|
||||
linkProvider,
|
||||
unlinkProvider,
|
||||
refreshProviders,
|
||||
// SmartAuth: MFA state (Phase 2D)
|
||||
mfaRequired,
|
||||
mfaMethods,
|
||||
mfaChallenge,
|
||||
verifyMfa,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,2 +1,8 @@
|
||||
export { createAuthProvider } from './auth-context.js';
|
||||
export type { BaseUser, AuthContextValue, AuthConfig, LoginResult } from './types.js';
|
||||
export type {
|
||||
AuthProviderInfo,
|
||||
BaseUser,
|
||||
AuthContextValue,
|
||||
AuthConfig,
|
||||
LoginResult,
|
||||
} from './types.js';
|
||||
|
||||
@ -5,6 +5,13 @@ export interface BaseUser {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthProviderInfo {
|
||||
provider: string;
|
||||
email: string;
|
||||
linkedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
|
||||
user: TUser | null;
|
||||
isAuthenticated: boolean;
|
||||
@ -19,6 +26,23 @@ export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
|
||||
deleteAccount: (password: string) => Promise<boolean>;
|
||||
updateUser: (updates: Partial<TUser>) => void;
|
||||
clearMessages: () => void;
|
||||
|
||||
// ── SmartAuth: Social login (Phase 1C) ────────────
|
||||
loginWithGoogle: (idToken: string) => Promise<boolean>;
|
||||
loginWithMicrosoft: (idToken: string) => Promise<boolean>;
|
||||
loginWithApple: (idToken: string) => Promise<boolean>;
|
||||
|
||||
// ── SmartAuth: Provider management (Phase 1C) ─────
|
||||
providers: AuthProviderInfo[];
|
||||
linkProvider: (provider: string, idToken: string) => Promise<boolean>;
|
||||
unlinkProvider: (provider: string) => Promise<boolean>;
|
||||
refreshProviders: () => Promise<void>;
|
||||
|
||||
// ── SmartAuth: MFA state (Phase 2D) ───────────────
|
||||
mfaRequired: boolean;
|
||||
mfaMethods: string[];
|
||||
mfaChallenge: string | null;
|
||||
verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LoginResult<TUser extends BaseUser = BaseUser> {
|
||||
@ -48,4 +72,16 @@ export interface AuthConfig<TUser extends BaseUser = BaseUser> {
|
||||
/** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */
|
||||
onInit?: () => LoginResult<TUser> | null;
|
||||
onLogout?: () => void;
|
||||
|
||||
// ── SmartAuth endpoint config (Phase 1C+) ─────────
|
||||
/** Endpoint for OAuth social login. Default: '/auth/oauth'. Provider appended as path segment. */
|
||||
oauthEndpoint?: string;
|
||||
/** Endpoint for listing providers. Default: '/auth/providers'. */
|
||||
providersEndpoint?: string;
|
||||
/** Endpoint for linking a provider. Default: '/auth/providers/link'. */
|
||||
linkProviderEndpoint?: string;
|
||||
/** Endpoint for MFA verification. Default: '/auth/mfa/verify'. */
|
||||
mfaVerifyEndpoint?: string;
|
||||
/** Callback when MFA is required after login. Receives challenge token and methods. */
|
||||
onMfaRequired?: (challenge: string, methods: string[]) => void;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user