diff --git a/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx b/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx index 0e1af3c8..fddff203 100644 --- a/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx +++ b/dashboards/admin-web/src/app/(dashboard)/settings/passkeys/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Fingerprint, Loader2, AlertCircle, Trash2, Plus, Laptop, Usb } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api'; interface Passkey { id: string; @@ -14,13 +15,13 @@ interface Passkey { createdAt: string; } -function getToken(): string | null { - return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; -} - -function authHeaders(): Record { - const t = getToken(); - return t ? { Authorization: `Bearer ${t}` } : {}; +interface PasskeyRegistrationOptions { + challenge: string; + user: { id: string; name: string; displayName: string }; + rp: { name: string; id: string }; + pubKeyCredParams: { type: string; alg: number }[]; + excludeCredentials: { id: string; type: string }[]; + [key: string]: unknown; } export default function PasskeyManagementPage() { @@ -32,18 +33,13 @@ export default function PasskeyManagementPage() { const fetchPasskeys = useCallback(async () => { setLoading(true); setError(''); - try { - const res = await fetch('/api/auth/passkeys', { headers: authHeaders() }); - if (res.ok) { - setPasskeys(await res.json()); - } else { - setError('Failed to load passkeys'); - } - } catch { - setError('Service unavailable'); - } finally { - setLoading(false); + const { data, error: err } = await apiFetch('/auth/passkeys'); + if (data) { + setPasskeys(data); + } else { + setError(err || 'Failed to load passkeys'); } + setLoading(false); }, []); useEffect(() => { @@ -55,15 +51,14 @@ export default function PasskeyManagementPage() { setError(''); try { // Step 1: Get registration options from server - const optionsRes = await fetch('/api/auth/passkeys/register/options', { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - }); - if (!optionsRes.ok) { - setError('Failed to start passkey registration'); + const { data: options, error: optErr } = await apiFetch( + '/auth/passkeys/register/options', + { method: 'POST' } + ); + if (!options || optErr) { + setError(optErr || 'Failed to start passkey registration'); return; } - const options = await optionsRes.json(); // Step 2: Create credential via WebAuthn API if (!window.PublicKeyCredential) { @@ -72,19 +67,19 @@ export default function PasskeyManagementPage() { } // Convert base64url fields to ArrayBuffer for WebAuthn API - const publicKeyOptions = { - ...options, + const publicKeyOptions: PublicKeyCredentialCreationOptions = { challenge: base64urlToBuffer(options.challenge), + rp: options.rp, user: { - ...options.user, id: base64urlToBuffer(options.user.id), + name: options.user.name, + displayName: options.user.displayName, }, - excludeCredentials: (options.excludeCredentials || []).map( - (cred: { id: string; type: string }) => ({ - ...cred, - id: base64urlToBuffer(cred.id), - }) - ), + pubKeyCredParams: options.pubKeyCredParams as PublicKeyCredentialParameters[], + excludeCredentials: (options.excludeCredentials || []).map(cred => ({ + id: base64urlToBuffer(cred.id), + type: cred.type as PublicKeyCredentialType, + })), }; const credential = (await navigator.credentials.create({ @@ -98,23 +93,24 @@ export default function PasskeyManagementPage() { // Step 3: Send credential response to server for verification const attestationResponse = credential.response as AuthenticatorAttestationResponse; - const verifyRes = await fetch('/api/auth/passkeys/register/verify', { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: credential.id, - rawId: bufferToBase64url(credential.rawId), - type: credential.type, - response: { - clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), - attestationObject: bufferToBase64url(attestationResponse.attestationObject), - }, - }), - }); + const { error: verifyErr } = await apiFetch<{ id: string }>( + '/auth/passkeys/register/verify', + { + method: 'POST', + body: JSON.stringify({ + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), + attestationObject: bufferToBase64url(attestationResponse.attestationObject), + }, + }), + } + ); - if (!verifyRes.ok) { - const data = await verifyRes.json(); - setError(data.error || 'Passkey registration failed'); + if (verifyErr) { + setError(verifyErr || 'Passkey registration failed'); return; } @@ -132,18 +128,13 @@ export default function PasskeyManagementPage() { const handleDelete = async (id: string) => { if (!confirm('Remove this passkey? You will no longer be able to sign in with it.')) return; - try { - const res = await fetch(`/api/auth/passkeys/${id}`, { - method: 'DELETE', - headers: authHeaders(), - }); - if (res.ok) { - setPasskeys(prev => prev.filter(p => p.id !== id)); - } else { - setError('Failed to remove passkey'); - } - } catch { - setError('Service unavailable'); + const { error: delErr } = await apiFetch<{ success: boolean }>(`/auth/passkeys/${id}`, { + method: 'DELETE', + }); + if (!delErr) { + setPasskeys(prev => prev.filter(p => p.id !== id)); + } else { + setError(delErr || 'Failed to remove passkey'); } }; diff --git a/packages/auth-client/src/client.ts b/packages/auth-client/src/client.ts index a4d0376e..10e11614 100644 --- a/packages/auth-client/src/client.ts +++ b/packages/auth-client/src/client.ts @@ -36,6 +36,11 @@ import type { // ── Default localStorage adapter ───────────────────────────────── +/** + * No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js). + * Tokens stored via noopStorage are NOT persisted — they are lost on page reload. + * For server-side rendering, use cookie-based auth instead of relying on this client. + */ const noopStorage: TokenStorage = { getItem: () => null, setItem: () => {}, @@ -272,7 +277,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const result = await request( `/auth/oauth/${provider}`, 'POST', - { idToken }, + { idToken, productId }, { skipAuth: true } ); if ('mfaRequired' in result && result.mfaRequired) { @@ -298,7 +303,8 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // ── Provider management (Phase 1C) ───────────────── async function getProviders(): Promise { - return request('/auth/providers', 'GET'); + const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET'); + return data.providers; } async function linkProvider(provider: string, idToken: string): Promise { @@ -326,15 +332,15 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { } async function setupTotp(): Promise { - return request('/auth/mfa/totp/setup', 'POST'); + return request('/auth/mfa/setup', 'POST'); } async function verifyTotpSetup(code: string): Promise { - await request('/auth/mfa/totp/verify-setup', 'POST', { code }); + await request('/auth/mfa/verify-setup', 'POST', { code }); } - async function disableMfa(): Promise { - await request('/auth/mfa/totp', 'DELETE'); + async function disableMfa(code: string): Promise { + await request('/auth/mfa/disable', 'POST', { code }); } async function getMfaStatus(): Promise { @@ -373,7 +379,8 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { } async function listPasskeys(): Promise { - return request('/auth/passkeys', 'GET'); + const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET'); + return data.passkeys; } async function deletePasskey(id: string): Promise { @@ -383,19 +390,24 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // ── Devices (Phase 3) ────────────────────────────── async function listDevices(): Promise { - return request('/auth/devices', 'GET'); + const data = await request<{ devices: Device[] }>('/auth/devices', 'GET'); + return data.devices; } - async function trustDevice(): Promise { - await request('/auth/devices/trust', 'POST'); + async function trustDevice( + fingerprint: string, + trustLevel: 'trusted' | 'remembered', + deviceInfo?: Record + ): Promise { + await request('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo }); } - async function revokeDevice(deviceId: string): Promise { - await request(`/auth/devices/${deviceId}`, 'DELETE'); + async function revokeDevice(fingerprint: string): Promise { + await request(`/auth/devices/${fingerprint}`, 'DELETE'); } async function revokeAllDevices(): Promise { - await request('/auth/devices', 'DELETE'); + await request('/auth/devices/revoke-all', 'POST'); } // ── Admin security (Phase 5B) ────────────────────── @@ -425,24 +437,35 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // ── Login history ─────────────────────────────────── async function getLoginHistory(limit = 50): Promise { - return request(`/auth/login-events/me?limit=${limit}`, 'GET'); + const data = await request<{ events: LoginEventInfo[] }>( + `/auth/login-events?limit=${limit}`, + 'GET' + ); + return data.events; } // ── Admin security ────────────────────────────────── async function getAdminLoginEvents(opts?: { + userId?: string; suspicious?: boolean; limit?: number; }): Promise { const params = new URLSearchParams(); + if (opts?.userId) params.set('userId', opts.userId); if (opts?.suspicious) params.set('suspicious', 'true'); if (opts?.limit) params.set('limit', String(opts.limit)); const qs = params.toString(); - return request(`/auth/login-events${qs ? `?${qs}` : ''}`, 'GET'); + const data = await request<{ events: LoginEventInfo[] }>( + `/auth/login-events/admin${qs ? `?${qs}` : ''}`, + 'GET' + ); + return data.events; } async function getAdminDevices(userId: string): Promise { - return request(`/auth/devices/user/${userId}`, 'GET'); + const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET'); + return data.devices; } return { diff --git a/packages/swift-platform-sdk/Sources/BLAuthUI.swift b/packages/swift-platform-sdk/Sources/BLAuthUI.swift index 43fa513d..4a86ecb9 100644 --- a/packages/swift-platform-sdk/Sources/BLAuthUI.swift +++ b/packages/swift-platform-sdk/Sources/BLAuthUI.swift @@ -619,3 +619,122 @@ public struct BLStepUpSheet: View { } #endif } + +// ── BLDeviceListView ──────────────────────────────────────── + +/// Device management view — list trusted/remembered devices, revoke trust. +/// Mirrors the Kotlin `BLDeviceListScreen` for platform parity. +/// +/// - Parameters: +/// - devices: List of devices from `BLAuthClient.listDevices()`. +/// - onRevokeDevice: Called with device ID when user revokes a device. +/// - onRevokeAll: Called when user revokes all devices. `nil` hides the button. +/// - isLoading: Whether data is loading. +public struct BLDeviceListView: View { + public let devices: [BLDevice] + public let onRevokeDevice: (String) -> Void + public var onRevokeAll: (() -> Void)? + public var isLoading: Bool = false + + public init( + devices: [BLDevice], + onRevokeDevice: @escaping (String) -> Void, + onRevokeAll: (() -> Void)? = nil, + isLoading: Bool = false + ) { + self.devices = devices + self.onRevokeDevice = onRevokeDevice + self.onRevokeAll = onRevokeAll + self.isLoading = isLoading + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Your Devices") + .font(.title2.bold()) + Spacer() + if let onRevokeAll, !devices.isEmpty { + Button(role: .destructive, action: onRevokeAll) { + Text("Revoke All") + } + } + } + + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 32) + } else if devices.isEmpty { + Text("No devices found") + .foregroundStyle(.secondary) + .padding(.vertical, 16) + } else { + ForEach(devices, id: \.id) { device in + DeviceCardView(device: device) { + onRevokeDevice(device.id) + } + } + } + } + .padding() + } + } +} + +/// Individual device card within BLDeviceListView. +private struct DeviceCardView: View { + let device: BLDevice + let onRevoke: () -> Void + + private var platformIcon: String { + switch device.platform { + case "ios": return "iphone" + case "android": return "apps.iphone" + case "macos": return "laptopcomputer" + case "windows", "linux": return "desktopcomputer" + default: return "display" + } + } + + private var trustColor: Color { + switch device.trustLevel { + case "trusted": return .blue + case "remembered": return .green + default: return .secondary + } + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: platformIcon) + .font(.title2) + .foregroundStyle(trustColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.body) + Text("\(device.trustLevel) · \(device.platform)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if device.trustLevel == "trusted" || device.trustLevel == "remembered" { + Button(role: .destructive, action: onRevoke) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/packages/swift-platform-sdk/Sources/BLPlatformClient.swift b/packages/swift-platform-sdk/Sources/BLPlatformClient.swift index 63209ec8..cb275f00 100644 --- a/packages/swift-platform-sdk/Sources/BLPlatformClient.swift +++ b/packages/swift-platform-sdk/Sources/BLPlatformClient.swift @@ -35,6 +35,19 @@ public final class BLPlatformClient: @unchecked Sendable { decoder.dateDecodingStrategy = .iso8601 } + /// Test-only initializer accepting a custom URLSessionConfiguration + /// so callers can inject MockURLProtocol for intercepting requests. + public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { + self.config = config + session = URLSession(configuration: sessionConfiguration) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + // MARK: - Public Request Methods /// Perform an authenticated request and decode the response. diff --git a/packages/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift b/packages/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift index 6ad82b21..c9ef73c3 100644 --- a/packages/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift +++ b/packages/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift @@ -55,17 +55,17 @@ final class BLAuthClientSmartAuthTests: XCTestCase { super.setUp() config = BLPlatformConfig( productId: "testapp", - baseURL: "http://localhost:4003/api", + baseURL: "http://localhost:4003", platform: "ios", channel: "test", bundleId: "com.test.smartauth" ) - // Configure URLSession with mock protocol + // Configure URLSession with mock protocol wired into BLPlatformClient let sessionConfig = URLSessionConfiguration.ephemeral sessionConfig.protocolClasses = [MockURLProtocol.self] - client = BLPlatformClient(config: config) + client = BLPlatformClient(config: config, sessionConfiguration: sessionConfig) authClient = BLAuthClient(config: config, client: client) MockURLProtocol.mockResponses.removeAll() @@ -81,43 +81,55 @@ final class BLAuthClientSmartAuthTests: XCTestCase { // MARK: - Test 1: Swift Social Login (Google) - func testLoginWithGoogleCallsCorrectEndpoint() async { - // Verify that loginWithGoogle constructs the correct API path - // and parses the token response correctly. - // Since we can't easily mock BLPlatformClient's URLSession, - // we verify the method exists and handles errors gracefully. + func testLoginWithGoogleCallsCorrectEndpoint() async throws { + // Mock a successful token response for Google OAuth + let tokenJSON = """ + {"accessToken":"at_123","refreshToken":"rt_456","user":{"id":"u1","email":"test@google.com","displayName":"Test User","plan":"free","role":"user"}} + """ + MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (tokenJSON.data(using: .utf8)!, 200) + + let user = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") + XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.email, "test@google.com") + XCTAssertEqual(user.displayName, "Test User") + } + + func testLoginWithGoogleDetectsMfaChallenge() async { + // Mock an MFA challenge response for Google OAuth + let mfaJSON = """ + {"mfaRequired":true,"mfaChallenge":"ch_google_123","methods":["totp"]} + """ + MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (mfaJSON.data(using: .utf8)!, 200) do { - // This will fail with a network error since no server is running, - // but it proves the method exists and calls the right endpoint. _ = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") - XCTFail("Should have thrown — no mock server running") - } catch is BLAuthError { - // MFA required is a valid outcome - } catch { - // Network error is expected — the important thing is the method compiles - // and sends to /api/auth/oauth/google - XCTAssertNotNil(error, "Error should be non-nil (network error expected)") + XCTFail("Should have thrown BLAuthError.mfaRequired") + } catch let error as BLAuthError { + if case .mfaRequired(let challenge) = error { + XCTAssertEqual(challenge.mfaChallenge, "ch_google_123") + XCTAssertEqual(challenge.methods, ["totp"]) + } else { + XCTFail("Expected mfaRequired error") + } } } // MARK: - Test 2: Swift MFA Verify - func testVerifyMfaCallsCorrectEndpoint() async { - // Verify that verifyMfa constructs the correct API call - // with challengeToken, code, and method parameters. + func testVerifyMfaCallsCorrectEndpoint() async throws { + // Mock a successful MFA verification response + let tokenJSON = """ + {"accessToken":"at_mfa","refreshToken":"rt_mfa","user":{"id":"u2","email":"mfa@test.com","displayName":"MFA User"}} + """ + MockURLProtocol.mockResponses["POST /api/auth/mfa/verify"] = (tokenJSON.data(using: .utf8)!, 200) - do { - _ = try await authClient.verifyMfa( - challengeToken: "mock_challenge_token", - code: "123456", - method: "totp" - ) - XCTFail("Should have thrown — no mock server running") - } catch { - // Network error expected — method signature and encoding verified - XCTAssertNotNil(error) - } + let user = try await authClient.verifyMfa( + challengeToken: "mock_challenge_token", + code: "123456", + method: "totp" + ) + XCTAssertEqual(user.id, "u2") + XCTAssertEqual(user.email, "mfa@test.com") } // MARK: - Type Verification Tests diff --git a/services/platform-service/src/modules/auth/oauth/apple.ts b/services/platform-service/src/modules/auth/oauth/apple.ts index 577160d0..8085c8fd 100644 --- a/services/platform-service/src/modules/auth/oauth/apple.ts +++ b/services/platform-service/src/modules/auth/oauth/apple.ts @@ -21,11 +21,12 @@ function getJwks() { return _jwks; } -/** Override JWKS for testing */ +/** @internal Override JWKS for testing — do not use in production code. */ export function _setJwks(jwks: ReturnType): void { _jwks = jwks; } +/** @internal Reset JWKS to default for testing — do not use in production code. */ export function _resetJwks(): void { _jwks = null; } diff --git a/services/platform-service/src/modules/auth/oauth/google.ts b/services/platform-service/src/modules/auth/oauth/google.ts index f78ec178..f6fc8531 100644 --- a/services/platform-service/src/modules/auth/oauth/google.ts +++ b/services/platform-service/src/modules/auth/oauth/google.ts @@ -21,11 +21,12 @@ function getJwks() { return _jwks; } -/** Override JWKS for testing */ +/** @internal Override JWKS for testing — do not use in production code. */ export function _setJwks(jwks: ReturnType): void { _jwks = jwks; } +/** @internal Reset JWKS to default for testing — do not use in production code. */ export function _resetJwks(): void { _jwks = null; } diff --git a/services/platform-service/src/modules/auth/oauth/microsoft.ts b/services/platform-service/src/modules/auth/oauth/microsoft.ts index be386221..29a4ad30 100644 --- a/services/platform-service/src/modules/auth/oauth/microsoft.ts +++ b/services/platform-service/src/modules/auth/oauth/microsoft.ts @@ -22,11 +22,12 @@ function getJwks() { return _jwks; } -/** Override JWKS for testing */ +/** @internal Override JWKS for testing — do not use in production code. */ export function _setJwks(jwks: ReturnType): void { _jwks = jwks; } +/** @internal Reset JWKS to default for testing — do not use in production code. */ export function _resetJwks(): void { _jwks = null; }