fix(auth): address SmartAuth agent review gaps — Swift mock wiring, passkey SDK consistency, device list parity, JSDoc, SSR docs

This commit is contained in:
saravanakumardb1 2026-03-12 12:27:08 -07:00
parent a613cf1bf9
commit ae13abfab2
8 changed files with 273 additions and 112 deletions

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Fingerprint, Loader2, AlertCircle, Trash2, Plus, Laptop, Usb } from 'lucide-react'; import { Fingerprint, Loader2, AlertCircle, Trash2, Plus, Laptop, Usb } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
interface Passkey { interface Passkey {
id: string; id: string;
@ -14,13 +15,13 @@ interface Passkey {
createdAt: string; createdAt: string;
} }
function getToken(): string | null { interface PasskeyRegistrationOptions {
return typeof window !== 'undefined' ? localStorage.getItem('admin_access_token') : null; challenge: string;
} user: { id: string; name: string; displayName: string };
rp: { name: string; id: string };
function authHeaders(): Record<string, string> { pubKeyCredParams: { type: string; alg: number }[];
const t = getToken(); excludeCredentials: { id: string; type: string }[];
return t ? { Authorization: `Bearer ${t}` } : {}; [key: string]: unknown;
} }
export default function PasskeyManagementPage() { export default function PasskeyManagementPage() {
@ -32,18 +33,13 @@ export default function PasskeyManagementPage() {
const fetchPasskeys = useCallback(async () => { const fetchPasskeys = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { const { data, error: err } = await apiFetch<Passkey[]>('/auth/passkeys');
const res = await fetch('/api/auth/passkeys', { headers: authHeaders() }); if (data) {
if (res.ok) { setPasskeys(data);
setPasskeys(await res.json()); } else {
} else { setError(err || 'Failed to load passkeys');
setError('Failed to load passkeys');
}
} catch {
setError('Service unavailable');
} finally {
setLoading(false);
} }
setLoading(false);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -55,15 +51,14 @@ export default function PasskeyManagementPage() {
setError(''); setError('');
try { try {
// Step 1: Get registration options from server // Step 1: Get registration options from server
const optionsRes = await fetch('/api/auth/passkeys/register/options', { const { data: options, error: optErr } = await apiFetch<PasskeyRegistrationOptions>(
method: 'POST', '/auth/passkeys/register/options',
headers: { ...authHeaders(), 'Content-Type': 'application/json' }, { method: 'POST' }
}); );
if (!optionsRes.ok) { if (!options || optErr) {
setError('Failed to start passkey registration'); setError(optErr || 'Failed to start passkey registration');
return; return;
} }
const options = await optionsRes.json();
// Step 2: Create credential via WebAuthn API // Step 2: Create credential via WebAuthn API
if (!window.PublicKeyCredential) { if (!window.PublicKeyCredential) {
@ -72,19 +67,19 @@ export default function PasskeyManagementPage() {
} }
// Convert base64url fields to ArrayBuffer for WebAuthn API // Convert base64url fields to ArrayBuffer for WebAuthn API
const publicKeyOptions = { const publicKeyOptions: PublicKeyCredentialCreationOptions = {
...options,
challenge: base64urlToBuffer(options.challenge), challenge: base64urlToBuffer(options.challenge),
rp: options.rp,
user: { user: {
...options.user,
id: base64urlToBuffer(options.user.id), id: base64urlToBuffer(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
}, },
excludeCredentials: (options.excludeCredentials || []).map( pubKeyCredParams: options.pubKeyCredParams as PublicKeyCredentialParameters[],
(cred: { id: string; type: string }) => ({ excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred, id: base64urlToBuffer(cred.id),
id: base64urlToBuffer(cred.id), type: cred.type as PublicKeyCredentialType,
}) })),
),
}; };
const credential = (await navigator.credentials.create({ const credential = (await navigator.credentials.create({
@ -98,23 +93,24 @@ export default function PasskeyManagementPage() {
// Step 3: Send credential response to server for verification // Step 3: Send credential response to server for verification
const attestationResponse = credential.response as AuthenticatorAttestationResponse; const attestationResponse = credential.response as AuthenticatorAttestationResponse;
const verifyRes = await fetch('/api/auth/passkeys/register/verify', { const { error: verifyErr } = await apiFetch<{ id: string }>(
method: 'POST', '/auth/passkeys/register/verify',
headers: { ...authHeaders(), 'Content-Type': 'application/json' }, {
body: JSON.stringify({ method: 'POST',
id: credential.id, body: JSON.stringify({
rawId: bufferToBase64url(credential.rawId), id: credential.id,
type: credential.type, rawId: bufferToBase64url(credential.rawId),
response: { type: credential.type,
clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), response: {
attestationObject: bufferToBase64url(attestationResponse.attestationObject), clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON),
}, attestationObject: bufferToBase64url(attestationResponse.attestationObject),
}), },
}); }),
}
);
if (!verifyRes.ok) { if (verifyErr) {
const data = await verifyRes.json(); setError(verifyErr || 'Passkey registration failed');
setError(data.error || 'Passkey registration failed');
return; return;
} }
@ -132,18 +128,13 @@ export default function PasskeyManagementPage() {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Remove this passkey? You will no longer be able to sign in with it.')) return; if (!confirm('Remove this passkey? You will no longer be able to sign in with it.')) return;
try { const { error: delErr } = await apiFetch<{ success: boolean }>(`/auth/passkeys/${id}`, {
const res = await fetch(`/api/auth/passkeys/${id}`, { method: 'DELETE',
method: 'DELETE', });
headers: authHeaders(), if (!delErr) {
}); setPasskeys(prev => prev.filter(p => p.id !== id));
if (res.ok) { } else {
setPasskeys(prev => prev.filter(p => p.id !== id)); setError(delErr || 'Failed to remove passkey');
} else {
setError('Failed to remove passkey');
}
} catch {
setError('Service unavailable');
} }
}; };

View File

@ -36,6 +36,11 @@ import type {
// ── Default localStorage adapter ───────────────────────────────── // ── 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 = { const noopStorage: TokenStorage = {
getItem: () => null, getItem: () => null,
setItem: () => {}, setItem: () => {},
@ -272,7 +277,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
const result = await request<AuthResult | MfaRequiredResult>( const result = await request<AuthResult | MfaRequiredResult>(
`/auth/oauth/${provider}`, `/auth/oauth/${provider}`,
'POST', 'POST',
{ idToken }, { idToken, productId },
{ skipAuth: true } { skipAuth: true }
); );
if ('mfaRequired' in result && result.mfaRequired) { if ('mfaRequired' in result && result.mfaRequired) {
@ -298,7 +303,8 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
// ── Provider management (Phase 1C) ───────────────── // ── Provider management (Phase 1C) ─────────────────
async function getProviders(): Promise<AuthProvider[]> { async function getProviders(): Promise<AuthProvider[]> {
return request<AuthProvider[]>('/auth/providers', 'GET'); const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET');
return data.providers;
} }
async function linkProvider(provider: string, idToken: string): Promise<void> { async function linkProvider(provider: string, idToken: string): Promise<void> {
@ -326,15 +332,15 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
} }
async function setupTotp(): Promise<TotpSetupResult> { async function setupTotp(): Promise<TotpSetupResult> {
return request<TotpSetupResult>('/auth/mfa/totp/setup', 'POST'); return request<TotpSetupResult>('/auth/mfa/setup', 'POST');
} }
async function verifyTotpSetup(code: string): Promise<void> { async function verifyTotpSetup(code: string): Promise<void> {
await request<void>('/auth/mfa/totp/verify-setup', 'POST', { code }); await request<void>('/auth/mfa/verify-setup', 'POST', { code });
} }
async function disableMfa(): Promise<void> { async function disableMfa(code: string): Promise<void> {
await request<void>('/auth/mfa/totp', 'DELETE'); await request<void>('/auth/mfa/disable', 'POST', { code });
} }
async function getMfaStatus(): Promise<MfaStatus> { async function getMfaStatus(): Promise<MfaStatus> {
@ -373,7 +379,8 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
} }
async function listPasskeys(): Promise<Passkey[]> { async function listPasskeys(): Promise<Passkey[]> {
return request<Passkey[]>('/auth/passkeys', 'GET'); const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET');
return data.passkeys;
} }
async function deletePasskey(id: string): Promise<void> { async function deletePasskey(id: string): Promise<void> {
@ -383,19 +390,24 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
// ── Devices (Phase 3) ────────────────────────────── // ── Devices (Phase 3) ──────────────────────────────
async function listDevices(): Promise<Device[]> { async function listDevices(): Promise<Device[]> {
return request<Device[]>('/auth/devices', 'GET'); const data = await request<{ devices: Device[] }>('/auth/devices', 'GET');
return data.devices;
} }
async function trustDevice(): Promise<void> { async function trustDevice(
await request<void>('/auth/devices/trust', 'POST'); fingerprint: string,
trustLevel: 'trusted' | 'remembered',
deviceInfo?: Record<string, string>
): Promise<void> {
await request<void>('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo });
} }
async function revokeDevice(deviceId: string): Promise<void> { async function revokeDevice(fingerprint: string): Promise<void> {
await request<void>(`/auth/devices/${deviceId}`, 'DELETE'); await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
} }
async function revokeAllDevices(): Promise<void> { async function revokeAllDevices(): Promise<void> {
await request<void>('/auth/devices', 'DELETE'); await request<void>('/auth/devices/revoke-all', 'POST');
} }
// ── Admin security (Phase 5B) ────────────────────── // ── Admin security (Phase 5B) ──────────────────────
@ -425,24 +437,35 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
// ── Login history ─────────────────────────────────── // ── Login history ───────────────────────────────────
async function getLoginHistory(limit = 50): Promise<LoginEventInfo[]> { async function getLoginHistory(limit = 50): Promise<LoginEventInfo[]> {
return request<LoginEventInfo[]>(`/auth/login-events/me?limit=${limit}`, 'GET'); const data = await request<{ events: LoginEventInfo[] }>(
`/auth/login-events?limit=${limit}`,
'GET'
);
return data.events;
} }
// ── Admin security ────────────────────────────────── // ── Admin security ──────────────────────────────────
async function getAdminLoginEvents(opts?: { async function getAdminLoginEvents(opts?: {
userId?: string;
suspicious?: boolean; suspicious?: boolean;
limit?: number; limit?: number;
}): Promise<LoginEventInfo[]> { }): Promise<LoginEventInfo[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts?.userId) params.set('userId', opts.userId);
if (opts?.suspicious) params.set('suspicious', 'true'); if (opts?.suspicious) params.set('suspicious', 'true');
if (opts?.limit) params.set('limit', String(opts.limit)); if (opts?.limit) params.set('limit', String(opts.limit));
const qs = params.toString(); const qs = params.toString();
return request<LoginEventInfo[]>(`/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<Device[]> { async function getAdminDevices(userId: string): Promise<Device[]> {
return request<Device[]>(`/auth/devices/user/${userId}`, 'GET'); const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET');
return data.devices;
} }
return { return {

View File

@ -619,3 +619,122 @@ public struct BLStepUpSheet: View {
} }
#endif #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))
}
}

View File

@ -35,6 +35,19 @@ public final class BLPlatformClient: @unchecked Sendable {
decoder.dateDecodingStrategy = .iso8601 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 // MARK: - Public Request Methods
/// Perform an authenticated request and decode the response. /// Perform an authenticated request and decode the response.

View File

@ -55,17 +55,17 @@ final class BLAuthClientSmartAuthTests: XCTestCase {
super.setUp() super.setUp()
config = BLPlatformConfig( config = BLPlatformConfig(
productId: "testapp", productId: "testapp",
baseURL: "http://localhost:4003/api", baseURL: "http://localhost:4003",
platform: "ios", platform: "ios",
channel: "test", channel: "test",
bundleId: "com.test.smartauth" bundleId: "com.test.smartauth"
) )
// Configure URLSession with mock protocol // Configure URLSession with mock protocol wired into BLPlatformClient
let sessionConfig = URLSessionConfiguration.ephemeral let sessionConfig = URLSessionConfiguration.ephemeral
sessionConfig.protocolClasses = [MockURLProtocol.self] sessionConfig.protocolClasses = [MockURLProtocol.self]
client = BLPlatformClient(config: config) client = BLPlatformClient(config: config, sessionConfiguration: sessionConfig)
authClient = BLAuthClient(config: config, client: client) authClient = BLAuthClient(config: config, client: client)
MockURLProtocol.mockResponses.removeAll() MockURLProtocol.mockResponses.removeAll()
@ -81,43 +81,55 @@ final class BLAuthClientSmartAuthTests: XCTestCase {
// MARK: - Test 1: Swift Social Login (Google) // MARK: - Test 1: Swift Social Login (Google)
func testLoginWithGoogleCallsCorrectEndpoint() async { func testLoginWithGoogleCallsCorrectEndpoint() async throws {
// Verify that loginWithGoogle constructs the correct API path // Mock a successful token response for Google OAuth
// and parses the token response correctly. let tokenJSON = """
// Since we can't easily mock BLPlatformClient's URLSession, {"accessToken":"at_123","refreshToken":"rt_456","user":{"id":"u1","email":"test@google.com","displayName":"Test User","plan":"free","role":"user"}}
// we verify the method exists and handles errors gracefully. """
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 { 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") _ = try await authClient.loginWithGoogle(idToken: "mock_google_id_token")
XCTFail("Should have thrown — no mock server running") XCTFail("Should have thrown BLAuthError.mfaRequired")
} catch is BLAuthError { } catch let error as BLAuthError {
// MFA required is a valid outcome if case .mfaRequired(let challenge) = error {
} catch { XCTAssertEqual(challenge.mfaChallenge, "ch_google_123")
// Network error is expected the important thing is the method compiles XCTAssertEqual(challenge.methods, ["totp"])
// and sends to /api/auth/oauth/google } else {
XCTAssertNotNil(error, "Error should be non-nil (network error expected)") XCTFail("Expected mfaRequired error")
}
} }
} }
// MARK: - Test 2: Swift MFA Verify // MARK: - Test 2: Swift MFA Verify
func testVerifyMfaCallsCorrectEndpoint() async { func testVerifyMfaCallsCorrectEndpoint() async throws {
// Verify that verifyMfa constructs the correct API call // Mock a successful MFA verification response
// with challengeToken, code, and method parameters. 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 { let user = try await authClient.verifyMfa(
_ = try await authClient.verifyMfa( challengeToken: "mock_challenge_token",
challengeToken: "mock_challenge_token", code: "123456",
code: "123456", method: "totp"
method: "totp" )
) XCTAssertEqual(user.id, "u2")
XCTFail("Should have thrown — no mock server running") XCTAssertEqual(user.email, "mfa@test.com")
} catch {
// Network error expected method signature and encoding verified
XCTAssertNotNil(error)
}
} }
// MARK: - Type Verification Tests // MARK: - Type Verification Tests

View File

@ -21,11 +21,12 @@ function getJwks() {
return _jwks; return _jwks;
} }
/** Override JWKS for testing */ /** @internal Override JWKS for testing — do not use in production code. */
export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void { export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void {
_jwks = jwks; _jwks = jwks;
} }
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void { export function _resetJwks(): void {
_jwks = null; _jwks = null;
} }

View File

@ -21,11 +21,12 @@ function getJwks() {
return _jwks; return _jwks;
} }
/** Override JWKS for testing */ /** @internal Override JWKS for testing — do not use in production code. */
export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void { export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void {
_jwks = jwks; _jwks = jwks;
} }
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void { export function _resetJwks(): void {
_jwks = null; _jwks = null;
} }

View File

@ -22,11 +22,12 @@ function getJwks() {
return _jwks; return _jwks;
} }
/** Override JWKS for testing */ /** @internal Override JWKS for testing — do not use in production code. */
export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void { export function _setJwks(jwks: ReturnType<typeof createRemoteJWKSet>): void {
_jwks = jwks; _jwks = jwks;
} }
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void { export function _resetJwks(): void {
_jwks = null; _jwks = null;
} }