feat(auth): add Phase 5C-5E endpoints + SDK methods — TOTP secret, push approvals, QR auth

- GET /auth/mfa/totp/secret — retrieve decrypted TOTP secret for auth app
- POST /auth/mfa/push/create, GET /pending, POST /:id/respond, GET /:id/status
- POST /auth/qr/create, POST /auth/qr/confirm, GET /auth/qr/:id/status
- Kotlin SDK: getTotpSecret, getPendingApprovals, respondToApproval, confirmQrLogin
- Swift SDK: getTotpSecret, getPendingApprovals, respondToApproval, confirmQrLogin
- All 53 auth tests passing
This commit is contained in:
saravanakumardb1 2026-03-12 15:01:51 -07:00
parent b1b3fe42df
commit f4b9124065
19 changed files with 1011 additions and 67 deletions

View File

@ -0,0 +1,86 @@
---
description: Advanced code review for PRs across ByteLyst workspace repos
---
# Advanced Code Review
Your task is to find all potential bugs and code improvements in the code
changes. Focus on:
1. Logic errors and incorrect behavior
2. Edge cases that aren't handled
3. Null/undefined reference issues
4. Race conditions or concurrency issues
5. Security vulnerabilities
6. Improper resource management or resource leaks
7. API contract violations
8. Incorrect caching behavior, including cache staleness issues, cache
key-related bugs, incorrect cache invalidation, and ineffective caching
9. Violations of existing code patterns or conventions
10. Duplicate code
Make sure to:
1. If you find any pre-existing bugs in the code, you should also report those
since it's important for us maintain general code quality for the user.
2. Do NOT report issues that are speculative or low-confidence. All your
conclusions should be based on a complete understanding of the codebase.
3. Remember that if you were given a specific git commit, it may not be checked
out and local code states may be different.
## Scope
All code across the ByteLyst workspace repos:
- **learning_ai_common_plat** - Shared platform packages and services
- packages/ - @bytelyst/\* shared libraries
- services/ - platform-service, extraction-service
- dashboards/ - admin-web, tracker-web
- **Product repos** - Individual product backends and applications
- learning_voice_ai_agent (LysnrAI)
- learning_multimodal_memory_agents (MindLyst)
- learning_ai_clock (ChronoMind)
- learning_ai_fastgap (NomGap)
- learning_ai_flowmonk (FlowMonk)
- learning_ai_jarvis_jr (JarvisJr)
- learning_ai_peakpulse (PeakPulse)
- learning_ai_notes (NoteLett)
## Domain Context
This is a multi-product ecosystem with shared platform services. Key architectural patterns:
- **Platform services** (Fastify 5, TypeScript ESM) provide auth, telemetry, feature flags, etc.
- **Shared packages** (@bytelyst/\*) eliminate duplication across products
- **Product backends** handle domain-specific logic (port 4010-4017)
- **Web apps** use Next.js 16 + React 19
- **Mobile apps** use native platforms (SwiftUI, Jetpack Compose, React Native)
## Style
Make sure the code follows existing conventions:
- TypeScript: ESM, strict types, Zod validation
- Services: Fastify 5 with types.ts → repository.ts → routes.ts pattern
- Cosmos DB: All documents include productId field
- No console.log in production (use req.log/app.log in Fastify, structlog in Python)
- Commit messages: type(scope): description
- Colors: Use design tokens from @bytelyst/design-tokens, never hardcode
## Target Branch
Measure against the main branch of the respective repo since that will be the branch we will merge into.
## Comments
Do not make code changes directly. Instead, suggest changes so the reviewer can evaluate them manually.
## Grammar
Do not include em dash in any outputs.
## Summary
At the end, provide a numbered list of all potential issues found. Each issue should have a number so it can be referred to easily (e.g. "3").
Also include a summary and explanation of the change of the PR or diff in general from the target branch. Use mermaid diagrams, anecdotes, and any other format you see fit.

View File

@ -1,9 +1,9 @@
Last refresh: 2026-03-11T14:36:06Z (2026-03-11 07:36:06 PDT)
Cascade conversations: 50 (297M)
Memories: 77
Last refresh: 2026-03-12T19:01:41Z (2026-03-12 12:01:41 PDT)
Cascade conversations: 50 (370M)
Memories: 80
Implicit context: 20
Code tracker dirs: 190
File edit history: 2782 entries
Code tracker dirs: 136
File edit history: 2908 entries
Workspace storage: 29 workspaces
Repo docs: 7 files across 2 repos
Repo workflows: 40 files across 9 repos
Repo workflows: 42 files across 10 repos

View File

@ -0,0 +1,86 @@
---
description: Advanced code review for PRs across ByteLyst workspace repos
---
# Advanced Code Review
Your task is to find all potential bugs and code improvements in the code
changes. Focus on:
1. Logic errors and incorrect behavior
2. Edge cases that aren't handled
3. Null/undefined reference issues
4. Race conditions or concurrency issues
5. Security vulnerabilities
6. Improper resource management or resource leaks
7. API contract violations
8. Incorrect caching behavior, including cache staleness issues, cache
key-related bugs, incorrect cache invalidation, and ineffective caching
9. Violations of existing code patterns or conventions
10. Duplicate code
Make sure to:
1. If you find any pre-existing bugs in the code, you should also report those
since it's important for us maintain general code quality for the user.
2. Do NOT report issues that are speculative or low-confidence. All your
conclusions should be based on a complete understanding of the codebase.
3. Remember that if you were given a specific git commit, it may not be checked
out and local code states may be different.
## Scope
All code across the ByteLyst workspace repos:
- **learning_ai_common_plat** - Shared platform packages and services
- packages/ - @bytelyst/\* shared libraries
- services/ - platform-service, extraction-service
- dashboards/ - admin-web, tracker-web
- **Product repos** - Individual product backends and applications
- learning_voice_ai_agent (LysnrAI)
- learning_multimodal_memory_agents (MindLyst)
- learning_ai_clock (ChronoMind)
- learning_ai_fastgap (NomGap)
- learning_ai_flowmonk (FlowMonk)
- learning_ai_jarvis_jr (JarvisJr)
- learning_ai_peakpulse (PeakPulse)
- learning_ai_notes (NoteLett)
## Domain Context
This is a multi-product ecosystem with shared platform services. Key architectural patterns:
- **Platform services** (Fastify 5, TypeScript ESM) provide auth, telemetry, feature flags, etc.
- **Shared packages** (@bytelyst/\*) eliminate duplication across products
- **Product backends** handle domain-specific logic (port 4010-4017)
- **Web apps** use Next.js 16 + React 19
- **Mobile apps** use native platforms (SwiftUI, Jetpack Compose, React Native)
## Style
Make sure the code follows existing conventions:
- TypeScript: ESM, strict types, Zod validation
- Services: Fastify 5 with types.ts → repository.ts → routes.ts pattern
- Cosmos DB: All documents include productId field
- No console.log in production (use req.log/app.log in Fastify, structlog in Python)
- Commit messages: type(scope): description
- Colors: Use design tokens from @bytelyst/design-tokens, never hardcode
## Target Branch
Measure against the main branch of the respective repo since that will be the branch we will merge into.
## Comments
Do not make code changes directly. Instead, suggest changes so the reviewer can evaluate them manually.
## Grammar
Do not include em dash in any outputs.
## Summary
At the end, provide a numbered list of all potential issues found. Each issue should have a number so it can be referred to easily (e.g. "3").
Also include a summary and explanation of the change of the PR or diff in general from the target branch. Use mermaid diagrams, anecdotes, and any other format you see fit.

View File

@ -0,0 +1,72 @@
---
description: Run a network transfer audit for a time window and generate an interactive HTML report
---
# Network Transfer Audit Workflow
The primary interface for running audits is the **Sensor Audit Dashboard**.
## Steps
1. **Ensure the dashboard is running**
```bash
/Users/sd9235/code/mygh/learning_ai_mac_tooling/audit-ctl.sh start
```
2. **Open the dashboard**
```bash
/Users/sd9235/code/mygh/learning_ai_mac_tooling/audit-ctl.sh open
```
Navigate to **Run Audit** in the sidebar.
3. **Choose a mode in the dashboard UI**:
- **One-shot Audit** — Analyze past network activity for a time window
- **Monitor** — Actively monitor transfers for a duration, then report
- **Continuous Collection** — Long-running data collection into SQLite
4. **Click Start Audit** — Output streams in real-time. When done, click **Open Report** to view the HTML report.
5. **View reports** — Go to the **Reports** page in the sidebar to see all generated HTML reports.
6. **Browse sessions** — Go to **Sessions** to explore collected data and sensor samples. Click **Generate Report** on any session to create an HTML report from it.
## CLI Fallback
For headless or SSH scenarios, the CLI still works:
```
python3 tools/network_transfer_audit.py [TIME] [OPTIONS]
Time (pick one, required):
--last DURATION Relative: 30m, 2h, 1d, 7d
--from DATETIME Absolute start: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"
Options:
--to DATETIME End time (default: now)
--live Include live connection snapshot
--monitor DURATION Monitor transfers for DURATION (5m, 10m, 1h) before report
--json Output as JSON
--html FILE Generate interactive HTML report
Continuous Collection:
--collect DURATION Collect data for DURATION (e.g. 12h, 1d) into SQLite
--interval SECONDS Collection interval (default: 300 = 5 min)
Query Collected Data:
--data SESSION_ID Query collected data from a session
--sessions List all collected sessions
```
## Management Script
```bash
./audit-ctl.sh start # Start dashboard (API + UI on port 8742)
./audit-ctl.sh stop # Stop dashboard
./audit-ctl.sh status # Check health + stats
./audit-ctl.sh collect 12h # CLI collection shortcut
./audit-ctl.sh install # Auto-start on login via launchd
./audit-ctl.sh open # Open dashboard in browser
```

View File

@ -59,6 +59,7 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
expect(opts.method).toBe('POST');
const body = JSON.parse(opts.body);
expect(body.idToken).toBe('google-id-token-123');
expect(body.productId).toBe('testapp');
});
it('returns MFA challenge when mfaRequired is true', async () => {
@ -138,14 +139,16 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
describe('getProviders', () => {
it('calls GET /auth/providers', async () => {
const providers = [
{
provider: 'google',
email: 'g@gmail.com',
linkedAt: '2025-01-01T00:00:00Z',
lastUsedAt: null,
},
];
const providers = {
providers: [
{
provider: 'google',
email: 'g@gmail.com',
linkedAt: '2025-01-01T00:00:00Z',
lastUsedAt: null,
},
],
};
globalThis.fetch = mockFetchResponse(providers);
const client = createAuthClient({
@ -238,10 +241,10 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
});
describe('setupTotp', () => {
it('calls POST /auth/mfa/totp/setup', async () => {
it('calls POST /auth/mfa/setup', async () => {
const setup = {
secret: 'ABCDEFGH',
otpauthUri: 'otpauth://totp/Test?secret=ABC',
qrDataUrl: 'data:image/png;base64,...',
recoveryCodes: ['code1', 'code2'],
};
globalThis.fetch = mockFetchResponse(setup);
@ -256,10 +259,11 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
const result = await client.setupTotp();
expect(result.otpauthUri).toContain('otpauth://');
expect(result.secret).toBe('ABCDEFGH');
expect(result.recoveryCodes).toHaveLength(2);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/auth/mfa/totp/setup');
expect(url).toBe('http://localhost:4003/api/auth/mfa/setup');
});
});
@ -284,18 +288,20 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
// ── Phase 3: Passkeys ─────────────────────────────
describe('listPasskeys', () => {
it('calls GET /auth/passkeys', async () => {
const passkeys = [
{
id: 'pk1',
friendlyName: 'MacBook',
deviceType: 'platform',
backedUp: true,
lastUsedAt: null,
createdAt: '2025-01-01T00:00:00Z',
},
];
globalThis.fetch = mockFetchResponse(passkeys);
it('calls GET /auth/passkeys and unwraps response', async () => {
const data = {
passkeys: [
{
credentialId: 'pk1',
friendlyName: 'MacBook',
deviceType: 'singleDevice',
backedUp: true,
lastUsedAt: '2025-01-01T00:00:00Z',
createdAt: '2025-01-01T00:00:00Z',
},
],
};
globalThis.fetch = mockFetchResponse(data);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
@ -307,6 +313,7 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
const result = await client.listPasskeys();
expect(result).toHaveLength(1);
expect(result[0].friendlyName).toBe('MacBook');
expect(result[0].credentialId).toBe('pk1');
});
});
@ -338,19 +345,22 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
// ── Phase 3: Devices ──────────────────────────────
describe('listDevices', () => {
it('calls GET /auth/devices', async () => {
const devices = [
{
id: 'd1',
name: 'Chrome on Mac',
platform: 'web',
trustLevel: 'trusted',
trustExpiresAt: '2025-04-01T00:00:00Z',
lastLoginAt: '2025-01-01T00:00:00Z',
createdAt: '2025-01-01T00:00:00Z',
},
];
globalThis.fetch = mockFetchResponse(devices);
it('calls GET /auth/devices and unwraps response', async () => {
const data = {
devices: [
{
fingerprint: 'fp-abc',
trustLevel: 'trusted',
deviceInfo: { platform: 'web' },
lastIp: '1.2.3.4',
trustExpiresAt: '2025-04-01T00:00:00Z',
createdAt: '2025-01-01T00:00:00Z',
lastSeenAt: '2025-01-01T00:00:00Z',
isTrusted: true,
},
],
};
globalThis.fetch = mockFetchResponse(data);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
@ -362,11 +372,12 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
const result = await client.listDevices();
expect(result).toHaveLength(1);
expect(result[0].trustLevel).toBe('trusted');
expect(result[0].fingerprint).toBe('fp-abc');
});
});
describe('revokeDevice', () => {
it('calls DELETE /auth/devices/:id', async () => {
describe('trustDevice', () => {
it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => {
globalThis.fetch = mockFetchResponse({});
const client = createAuthClient({
@ -376,14 +387,56 @@ describe('@bytelyst/auth-client — SmartAuth', () => {
});
client.setTokens('tok', 'ref');
await client.revokeDevice('device-xyz');
await client.trustDevice('fp-abc', 'trusted', { platform: 'web' });
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/auth/devices/device-xyz');
expect(url).toBe('http://localhost:4003/api/auth/devices/trust');
expect(opts.method).toBe('POST');
const body = JSON.parse(opts.body);
expect(body.fingerprint).toBe('fp-abc');
expect(body.trustLevel).toBe('trusted');
expect(body.deviceInfo).toEqual({ platform: 'web' });
});
});
describe('revokeDevice', () => {
it('calls DELETE /auth/devices/:fingerprint', async () => {
globalThis.fetch = mockFetchResponse({});
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('tok', 'ref');
await client.revokeDevice('fp-xyz');
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz');
expect(opts.method).toBe('DELETE');
});
});
describe('revokeAllDevices', () => {
it('calls POST /auth/devices/revoke-all', async () => {
globalThis.fetch = mockFetchResponse({});
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('tok', 'ref');
await client.revokeAllDevices();
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all');
expect(opts.method).toBe('POST');
});
});
// ── Phase 5B: Admin security ──────────────────────
describe('getSecurityOverview', () => {

View File

@ -51,8 +51,8 @@ export interface MfaRequiredResult {
}
export interface TotpSetupResult {
secret: string;
otpauthUri: string;
qrDataUrl: string;
recoveryCodes: string[];
}
@ -70,33 +70,37 @@ export interface AuthProvider {
}
export interface Passkey {
id: string;
credentialId: string;
friendlyName: string;
deviceType: 'platform' | 'cross-platform';
deviceType: string;
backedUp: boolean;
lastUsedAt: string | null;
lastUsedAt: string;
createdAt: string;
}
export interface Device {
id: string;
name: string;
platform: string;
fingerprint: string;
trustLevel: 'trusted' | 'remembered' | 'unknown';
trustExpiresAt: string | null;
lastIp: string;
lastLoginAt: string;
deviceInfo: Record<string, string | undefined>;
lastIp?: string;
lastLocation?: string;
trustExpiresAt: string;
createdAt: string;
lastSeenAt: string;
isTrusted: boolean;
}
export interface LoginEventInfo {
id: string;
eventType: string;
result: string;
method: string;
ip: string;
userAgent: string;
userAgent?: string;
fingerprint?: string;
location?: string;
riskLevel: string;
riskScore: number;
riskFactors: string[];
riskFlags: string[];
createdAt: string;
}
@ -136,7 +140,7 @@ export interface AuthClient {
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
setupTotp(): Promise<TotpSetupResult>;
verifyTotpSetup(code: string): Promise<void>;
disableMfa(): Promise<void>;
disableMfa(code: string): Promise<void>;
getMfaStatus(): Promise<MfaStatus>;
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
@ -150,8 +154,12 @@ export interface AuthClient {
// ── Devices ─────────────────────────────────────
listDevices(): Promise<Device[]>;
trustDevice(): Promise<void>;
revokeDevice(deviceId: string): Promise<void>;
trustDevice(
fingerprint: string,
trustLevel: 'trusted' | 'remembered',
deviceInfo?: Record<string, string>
): Promise<void>;
revokeDevice(fingerprint: string): Promise<void>;
revokeAllDevices(): Promise<void>;
// ── Step-up auth ────────────────────────────────

View File

@ -129,6 +129,51 @@ class BLAuthClient(
val stepUpToken: String,
)
// ── Phase 5C5E types ─────────────────────────────────────
@Serializable
data class TotpSecret(
val secret: String,
val issuer: String,
val accountName: String,
val digits: Int = 6,
val period: Int = 30,
val algorithm: String = "SHA1",
)
@Serializable
data class PushApproval(
val id: String,
val requestProductId: String,
val requestPlatform: String,
val requestIp: String,
val requestGeo: Geo? = null,
val createdAt: String,
val expiresAt: String,
)
@Serializable
data class PushApprovalResponse(
val id: String,
val status: String,
val respondedAt: String? = null,
)
@Serializable
data class QrChallenge(
val id: String,
val challengeToken: String,
val expiresAt: String,
)
@Serializable
data class QrStatus(
val status: String,
val accessToken: String? = null,
val refreshToken: String? = null,
val user: AuthUser? = null,
)
/** Exception when MFA is required after login. */
class MfaRequiredException(val challenge: MfaChallenge) : Exception("MFA required")
@ -511,6 +556,38 @@ class BLAuthClient(
return client.json.decodeFromString<List<LoginEvent>>(response)
}
// ── TOTP Secret Retrieval (Phase 5C) ─────────────────────
/** Get the decrypted TOTP secret for local code generation (auth app). */
suspend fun getTotpSecret(): TotpSecret {
val response = client.request("GET", "/api/auth/mfa/totp/secret")
return client.json.decodeFromString<TotpSecret>(response)
}
// ── Push Approvals (Phase 5D) ────────────────────────────
/** List pending push MFA approvals for the current user. */
suspend fun getPendingApprovals(): List<PushApproval> {
val response = client.request("GET", "/api/auth/mfa/push/pending")
return client.json.decodeFromString<List<PushApproval>>(response)
}
/** Respond to a push MFA approval (approve or deny). */
suspend fun respondToApproval(approvalId: String, action: String): PushApprovalResponse {
val body = encodeMap(mapOf("action" to action))
val response = client.request("POST", "/api/auth/mfa/push/$approvalId/respond", body)
return client.json.decodeFromString<PushApprovalResponse>(response)
}
// ── QR Auth (Phase 5E) ───────────────────────────────────
/** Confirm a QR login challenge from the auth app. */
suspend fun confirmQrLogin(challengeToken: String): MessageResponse {
val body = encodeMap(mapOf("challengeToken" to challengeToken))
val response = client.request("POST", "/api/auth/qr/confirm", body)
return client.json.decodeFromString<MessageResponse>(response)
}
// ── Session restore ─────────────────────────────────────
/**

View File

@ -56,6 +56,7 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
linkProviderEndpoint = '/auth/providers/link',
mfaVerifyEndpoint = '/auth/mfa/verify',
onMfaRequired,
productId: configProductId,
} = config;
const USER_KEY = `${storagePrefix}_auth_user`;
@ -247,9 +248,11 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
setMfaChallenge(null);
setMfaMethods([]);
try {
const oauthBody: Record<string, string> = { idToken };
if (configProductId) oauthBody.productId = configProductId;
const { data, error: fetchError } = await api.safeFetch<unknown>(
`${oauthEndpoint}/${provider}`,
{ method: 'POST', body: JSON.stringify({ idToken }) }
{ method: 'POST', body: JSON.stringify(oauthBody) }
);
if (data && !fetchError) {
@ -290,8 +293,10 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
const refreshProviders = useCallback(async () => {
try {
const data = await api.fetch<AuthProviderInfo[]>(providersEndpoint, { method: 'GET' });
setProviders(data);
const data = await api.fetch<{ providers: AuthProviderInfo[] }>(providersEndpoint, {
method: 'GET',
});
setProviders(data.providers ?? []);
} catch {
// non-fatal — providers list is supplementary
}

View File

@ -54,6 +54,8 @@ export interface LoginResult<TUser extends BaseUser = BaseUser> {
export interface AuthConfig<TUser extends BaseUser = BaseUser> {
/** Base URL for auth API calls. Default: '/api'. */
baseUrl?: string;
/** Product identifier sent with OAuth requests. */
productId?: string;
storagePrefix: string;
loginEndpoint: string;
registerEndpoint?: string;

View File

@ -118,6 +118,51 @@ public struct BLGeo: Codable, Sendable {
public let city: String
}
// MARK: - Phase 5C5E Types
/// Decrypted TOTP secret for local code generation in the auth app.
public struct BLTotpSecret: Codable, Sendable {
public let secret: String
public let issuer: String
public let accountName: String
public let digits: Int
public let period: Int
public let algorithm: String
}
/// Pending push MFA approval.
public struct BLPushApproval: Codable, Sendable {
public let id: String
public let requestProductId: String
public let requestPlatform: String
public let requestIp: String
public let requestGeo: BLGeo?
public let createdAt: String
public let expiresAt: String
}
/// Push approval response after approve/deny.
public struct BLPushApprovalResponse: Codable, Sendable {
public let id: String
public let status: String
public let respondedAt: String?
}
/// QR challenge for desktop/TV login.
public struct BLQrChallenge: Codable, Sendable {
public let id: String
public let challengeToken: String
public let expiresAt: String
}
/// QR challenge poll status.
public struct BLQrStatus: Codable, Sendable {
public let status: String
public let accessToken: String?
public let refreshToken: String?
public let user: BLAuthUser?
}
// MARK: - Auth Errors
/// Auth-specific errors for SmartAuth flows.
@ -503,6 +548,34 @@ public final class BLAuthClient {
)
}
// MARK: - TOTP Secret Retrieval (Phase 5C)
/// Get the decrypted TOTP secret for local code generation (auth app).
public func getTotpSecret() async throws -> BLTotpSecret {
return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self)
}
// MARK: - Push Approvals (Phase 5D)
/// List pending push MFA approvals for the current user.
public func getPendingApprovals() async throws -> [BLPushApproval] {
return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self)
}
/// Respond to a push MFA approval (approve or deny).
public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse {
let body = ["action": action]
return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self)
}
// MARK: - QR Auth (Phase 5E)
/// Confirm a QR login challenge from the auth app.
public func confirmQrLogin(challengeToken: String) async throws {
let body = ["challengeToken": challengeToken]
_ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body)
}
/// Restore session from stored tokens. Call on app launch.
public func restoreSession() async {
guard isAuthenticated else {

34
pnpm-lock.yaml generated
View File

@ -188,7 +188,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -282,7 +282,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -321,6 +321,27 @@ importers:
packages/auth-client: {}
packages/auth-ui:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
packages/blob:
dependencies:
'@bytelyst/storage':
@ -473,6 +494,15 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/llm-router:
devDependencies:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/logger: {}
packages/monitoring: {}

View File

@ -7,6 +7,7 @@
* POST /auth/mfa/disable disable MFA (requires valid code)
* POST /auth/mfa/recovery/regenerate regenerate recovery codes
* GET /auth/mfa/status check MFA status for current user
* GET /auth/mfa/totp/secret retrieve decrypted TOTP secret (for auth app)
*
* Admin MFA policies (Phase 2C):
* GET /auth/mfa/policies/:productId get MFA policy
@ -241,6 +242,32 @@ export async function mfaRoutes(app: FastifyInstance) {
};
});
// ── TOTP Secret Retrieval (for auth app) ─────────────────
app.get('/auth/mfa/totp/secret', async req => {
const payload = req.jwtPayload;
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
const mfaDoc = await mfaRepo.getByUserId(payload.sub);
if (!mfaDoc || !mfaDoc.verified) {
throw new BadRequestError('MFA is not enabled');
}
const secret = mfaRepo.decryptSecret(mfaDoc.encryptedSecret, mfaDoc.iv, mfaDoc.authTag);
const user = await userRepo.getById(payload.sub);
const issuer = 'ByteLyst';
const accountName = user?.email ?? payload.sub;
return {
secret,
issuer,
accountName,
digits: 6,
period: 30,
algorithm: 'SHA1',
};
});
// ── Admin MFA Policies (Phase 2C) ────────────────────────
app.get('/auth/mfa/policies/:productId', async req => {

View File

@ -0,0 +1,75 @@
/**
* Push approval repository in-memory store with TTL expiry.
* Push approvals are ephemeral (5-minute TTL) so no Cosmos container needed.
*/
import type { PushApprovalDoc } from './types.js';
import { randomUUID } from 'node:crypto';
const store = new Map<string, PushApprovalDoc>();
const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
/** Expire old entries on access. */
function sweep(): void {
const now = Date.now();
for (const [, doc] of store) {
if (new Date(doc.expiresAt).getTime() <= now && doc.status === 'pending') {
doc.status = 'expired';
}
}
}
export function create(
params: Omit<PushApprovalDoc, 'id' | 'status' | 'createdAt' | 'expiresAt'>
): PushApprovalDoc {
sweep();
const now = new Date();
const doc: PushApprovalDoc = {
id: `pushappr_${randomUUID().slice(0, 8)}`,
...params,
status: 'pending',
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(),
};
store.set(doc.id, doc);
return doc;
}
export function getById(id: string): PushApprovalDoc | undefined {
sweep();
return store.get(id);
}
export function getPendingByUserId(userId: string): PushApprovalDoc[] {
sweep();
const results: PushApprovalDoc[] = [];
for (const doc of store.values()) {
if (doc.userId === userId && doc.status === 'pending') {
results.push(doc);
}
}
return results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function respond(id: string, action: 'approved' | 'denied'): PushApprovalDoc | undefined {
sweep();
const doc = store.get(id);
if (!doc || doc.status !== 'pending') return undefined;
doc.status = action;
doc.respondedAt = new Date().toISOString();
return doc;
}
export function getByChallenge(challengeToken: string): PushApprovalDoc | undefined {
sweep();
for (const doc of store.values()) {
if (doc.challengeToken === challengeToken) return doc;
}
return undefined;
}
/** Clear all — for testing. */
export function _clear(): void {
store.clear();
}

View File

@ -0,0 +1,106 @@
/**
* Push MFA approval endpoints for SmartAuth Phase 5D.
*
* POST /auth/mfa/push/create create a push approval request (internal/service)
* GET /auth/mfa/push/pending list pending approvals for current user
* POST /auth/mfa/push/:id/respond approve or deny a push approval
* GET /auth/mfa/push/:id/status poll approval status (for requesting client)
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, UnauthorizedError, NotFoundError } from '../../../lib/errors.js';
import * as repo from './repository.js';
import { PushApprovalRespondSchema, PushApprovalCreateSchema } from './types.js';
export async function pushApprovalRoutes(app: FastifyInstance) {
// ── Create push approval (called by login flow when push MFA is triggered) ──
app.post('/auth/mfa/push/create', async req => {
const payload = req.jwtPayload;
// Allow service-to-service or admin calls
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
if (!payload.role || !['super_admin', 'admin', 'service'].includes(payload.role)) {
throw new UnauthorizedError('Service or admin access required');
}
const parsed = PushApprovalCreateSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const approval = repo.create({
productId: 'smartauth',
userId: parsed.data.userId,
requestProductId: parsed.data.requestProductId,
requestPlatform: parsed.data.requestPlatform,
requestIp: parsed.data.requestIp,
requestGeo: parsed.data.requestGeo,
challengeToken: parsed.data.challengeToken,
});
req.log.info(
{ approvalId: approval.id, userId: parsed.data.userId },
'[auth] Push approval created'
);
return { id: approval.id, expiresAt: approval.expiresAt };
});
// ── List pending approvals for current user ─────────────────
app.get('/auth/mfa/push/pending', async req => {
const payload = req.jwtPayload;
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
const pending = repo.getPendingByUserId(payload.sub);
return pending.map(a => ({
id: a.id,
requestProductId: a.requestProductId,
requestPlatform: a.requestPlatform,
requestIp: a.requestIp,
requestGeo: a.requestGeo,
createdAt: a.createdAt,
expiresAt: a.expiresAt,
}));
});
// ── Respond to a push approval ─────────────────────────────
app.post('/auth/mfa/push/:id/respond', async req => {
const payload = req.jwtPayload;
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
const { id } = req.params as { id: string };
const parsed = PushApprovalRespondSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const approval = repo.getById(id);
if (!approval) throw new NotFoundError('Approval not found');
if (approval.userId !== payload.sub) throw new UnauthorizedError('Not your approval');
if (approval.status !== 'pending') {
throw new BadRequestError(`Approval already ${approval.status}`);
}
const action = parsed.data.action === 'approve' ? ('approved' as const) : ('denied' as const);
const updated = repo.respond(id, action);
if (!updated) throw new BadRequestError('Failed to respond to approval');
req.log.info({ approvalId: id, action, userId: payload.sub }, '[auth] Push approval responded');
return { id: updated.id, status: updated.status, respondedAt: updated.respondedAt };
});
// ── Poll approval status (for the requesting login client) ──
app.get('/auth/mfa/push/:id/status', async req => {
const { id } = req.params as { id: string };
const approval = repo.getById(id);
if (!approval) throw new NotFoundError('Approval not found');
return {
id: approval.id,
status: approval.status,
respondedAt: approval.respondedAt ?? null,
};
});
}

View File

@ -0,0 +1,44 @@
/**
* Push MFA approval types for SmartAuth Phase 5D.
* Push approvals allow a logged-in auth app to approve/deny login attempts.
*/
import { z } from 'zod';
export interface PushApprovalDoc {
id: string; // "pushappr_{random}"
productId: string;
userId: string;
/** Which product triggered the login */
requestProductId: string;
/** Client platform that requested login */
requestPlatform: string;
/** IP of the login request */
requestIp: string;
/** Geo location (best effort) */
requestGeo?: { country: string; city: string };
status: 'pending' | 'approved' | 'denied' | 'expired';
/** Challenge token for the pending login */
challengeToken: string;
createdAt: string;
expiresAt: string;
respondedAt?: string;
}
export const PushApprovalRespondSchema = z.object({
action: z.enum(['approve', 'deny']),
});
export const PushApprovalCreateSchema = z.object({
userId: z.string().min(1),
requestProductId: z.string().min(1),
requestPlatform: z.string().min(1),
requestIp: z.string().default('unknown'),
requestGeo: z
.object({
country: z.string(),
city: z.string(),
})
.optional(),
challengeToken: z.string().min(1),
});

View File

@ -0,0 +1,64 @@
/**
* QR auth repository in-memory store with TTL expiry.
* QR challenges are ephemeral (3-minute TTL) so no Cosmos container needed.
*/
import type { QrChallengeDoc } from './types.js';
import { randomUUID, randomBytes } from 'node:crypto';
const store = new Map<string, QrChallengeDoc>();
const EXPIRY_MS = 3 * 60 * 1000; // 3 minutes
function sweep(): void {
const now = Date.now();
for (const [, doc] of store) {
if (new Date(doc.expiresAt).getTime() <= now && doc.status === 'pending') {
doc.status = 'expired';
}
}
}
export function create(productId: string, platform: string): QrChallengeDoc {
sweep();
const now = new Date();
const doc: QrChallengeDoc = {
id: `qr_${randomUUID().slice(0, 8)}`,
challengeToken: randomBytes(32).toString('base64url'),
productId,
platform,
status: 'pending',
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(),
};
store.set(doc.id, doc);
return doc;
}
export function getById(id: string): QrChallengeDoc | undefined {
sweep();
return store.get(id);
}
export function getByChallenge(challengeToken: string): QrChallengeDoc | undefined {
sweep();
for (const doc of store.values()) {
if (doc.challengeToken === challengeToken) return doc;
}
return undefined;
}
export function confirm(challengeToken: string, userId: string): QrChallengeDoc | undefined {
sweep();
const doc = getByChallenge(challengeToken);
if (!doc || doc.status !== 'pending') return undefined;
doc.status = 'confirmed';
doc.userId = userId;
doc.confirmedAt = new Date().toISOString();
return doc;
}
/** Clear all — for testing. */
export function _clear(): void {
store.clear();
}

View File

@ -0,0 +1,100 @@
/**
* QR auth endpoints for SmartAuth Phase 5E.
*
* POST /auth/qr/create create a QR challenge (desktop/TV client)
* POST /auth/qr/confirm confirm QR login from auth app (mobile, authenticated)
* GET /auth/qr/:id/status poll QR challenge status (desktop/TV client)
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, UnauthorizedError, NotFoundError } from '../../../lib/errors.js';
import * as repo from './repository.js';
import * as jwt from '../jwt.js';
import * as userRepo from '../repository.js';
import { QrChallengeCreateSchema, QrConfirmSchema } from './types.js';
export async function qrAuthRoutes(app: FastifyInstance) {
// ── Create QR challenge (unauthenticated — desktop shows QR) ──
app.post('/auth/qr/create', async req => {
const parsed = QrChallengeCreateSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const challenge = repo.create(parsed.data.productId, parsed.data.platform);
req.log.info({ challengeId: challenge.id }, '[auth] QR challenge created');
return {
id: challenge.id,
challengeToken: challenge.challengeToken,
expiresAt: challenge.expiresAt,
};
});
// ── Confirm QR login (auth app — authenticated user scans QR) ──
app.post('/auth/qr/confirm', async req => {
const payload = req.jwtPayload;
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
const parsed = QrConfirmSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const challenge = repo.confirm(parsed.data.challengeToken, payload.sub);
if (!challenge) {
throw new BadRequestError('Invalid, expired, or already confirmed QR challenge');
}
req.log.info({ challengeId: challenge.id, userId: payload.sub }, '[auth] QR login confirmed');
return { message: 'Login authorized', challengeId: challenge.id };
});
// ── Poll QR challenge status (desktop polls until confirmed) ──
app.get('/auth/qr/:id/status', async req => {
const { id } = req.params as { id: string };
const challenge = repo.getById(id);
if (!challenge) throw new NotFoundError('QR challenge not found');
if (challenge.status === 'confirmed' && challenge.userId) {
// Issue tokens for the confirmed user
const user = await userRepo.getById(challenge.userId);
if (!user) throw new NotFoundError('User not found');
const accessToken = await jwt.createAccessToken({
sub: user.id,
email: user.email,
role: user.role as 'user' | 'admin' | 'super_admin',
productId: challenge.productId,
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
});
const refreshToken = await jwt.createRefreshToken({
sub: user.id,
productId: challenge.productId,
});
return {
status: 'confirmed',
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
plan: user.plan,
role: user.role,
},
};
}
return {
status: challenge.status,
accessToken: null,
refreshToken: null,
user: null,
};
});
}

View File

@ -0,0 +1,32 @@
/**
* QR auth types for SmartAuth Phase 5E.
* QR auth allows a mobile auth app to scan a QR code on desktop/TV
* and authorize the login from the phone.
*/
import { z } from 'zod';
export interface QrChallengeDoc {
id: string; // "qr_{random}"
/** The challenge token embedded in the QR code */
challengeToken: string;
/** Which product is requesting login */
productId: string;
/** Client platform that displayed the QR (e.g. "web", "desktop") */
platform: string;
status: 'pending' | 'confirmed' | 'expired';
/** Set when the auth app confirms */
userId?: string;
createdAt: string;
expiresAt: string;
confirmedAt?: string;
}
export const QrChallengeCreateSchema = z.object({
productId: z.string().min(1),
platform: z.string().min(1).default('web'),
});
export const QrConfirmSchema = z.object({
challengeToken: z.string().min(1),
});

View File

@ -29,6 +29,8 @@ import { mfaRoutes } from './modules/auth/mfa/routes.js';
import { passkeyRoutes } from './modules/auth/passkeys/routes.js';
import { deviceRoutes } from './modules/auth/devices/routes.js';
import { loginEventRoutes } from './modules/auth/login-events/routes.js';
import { pushApprovalRoutes } from './modules/auth/push-approvals/routes.js';
import { qrAuthRoutes } from './modules/auth/qr-auth/routes.js';
import { auditRoutes } from './modules/audit/routes.js';
import { notificationRoutes } from './modules/notifications/routes.js';
import { flagRoutes } from './modules/flags/routes.js';
@ -122,6 +124,8 @@ await app.register(mfaRoutes, { prefix: '/api' });
await app.register(passkeyRoutes, { prefix: '/api' });
await app.register(deviceRoutes, { prefix: '/api' });
await app.register(loginEventRoutes, { prefix: '/api' });
await app.register(pushApprovalRoutes, { prefix: '/api' });
await app.register(qrAuthRoutes, { prefix: '/api' });
await app.register(auditRoutes, { prefix: '/api' });
await app.register(notificationRoutes, { prefix: '/api' });
await app.register(flagRoutes, { prefix: '/api' });