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 { 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<string, string> {
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<Passkey[]>('/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<PasskeyRegistrationOptions>(
'/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');
}
};

View File

@ -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<AuthResult | MfaRequiredResult>(
`/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<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> {
@ -326,15 +332,15 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
}
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> {
await request<void>('/auth/mfa/totp/verify-setup', 'POST', { code });
await request<void>('/auth/mfa/verify-setup', 'POST', { code });
}
async function disableMfa(): Promise<void> {
await request<void>('/auth/mfa/totp', 'DELETE');
async function disableMfa(code: string): Promise<void> {
await request<void>('/auth/mfa/disable', 'POST', { code });
}
async function getMfaStatus(): Promise<MfaStatus> {
@ -373,7 +379,8 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
}
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> {
@ -383,19 +390,24 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
// ── Devices (Phase 3) ──────────────────────────────
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> {
await request<void>('/auth/devices/trust', 'POST');
async function trustDevice(
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> {
await request<void>(`/auth/devices/${deviceId}`, 'DELETE');
async function revokeDevice(fingerprint: string): Promise<void> {
await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
}
async function revokeAllDevices(): Promise<void> {
await request<void>('/auth/devices', 'DELETE');
await request<void>('/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<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 ──────────────────────────────────
async function getAdminLoginEvents(opts?: {
userId?: string;
suspicious?: boolean;
limit?: number;
}): Promise<LoginEventInfo[]> {
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<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[]> {
return request<Device[]>(`/auth/devices/user/${userId}`, 'GET');
const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET');
return data.devices;
}
return {

View File

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

View File

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

View File

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

View File

@ -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<typeof createRemoteJWKSet>): void {
_jwks = jwks;
}
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void {
_jwks = null;
}

View File

@ -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<typeof createRemoteJWKSet>): void {
_jwks = jwks;
}
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void {
_jwks = null;
}

View File

@ -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<typeof createRemoteJWKSet>): void {
_jwks = jwks;
}
/** @internal Reset JWKS to default for testing — do not use in production code. */
export function _resetJwks(): void {
_jwks = null;
}