fix(auth): address SmartAuth agent review gaps — Swift mock wiring, passkey SDK consistency, device list parity, JSDoc, SSR docs
This commit is contained in:
parent
a613cf1bf9
commit
ae13abfab2
@ -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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user