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:
parent
b1b3fe42df
commit
f4b9124065
86
.windsurf/workflows/run-code-review.md
Normal file
86
.windsurf/workflows/run-code-review.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
```
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 ────────────────────────────────
|
||||
|
||||
@ -129,6 +129,51 @@ class BLAuthClient(
|
||||
val stepUpToken: String,
|
||||
)
|
||||
|
||||
// ── Phase 5C–5E 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 ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -118,6 +118,51 @@ public struct BLGeo: Codable, Sendable {
|
||||
public let city: String
|
||||
}
|
||||
|
||||
// MARK: - Phase 5C–5E 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
34
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
100
services/platform-service/src/modules/auth/qr-auth/routes.ts
Normal file
100
services/platform-service/src/modules/auth/qr-auth/routes.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
32
services/platform-service/src/modules/auth/qr-auth/types.ts
Normal file
32
services/platform-service/src/modules/auth/qr-auth/types.ts
Normal 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),
|
||||
});
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user