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 { 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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user