Skip to content

Commit b0f32f0

Browse files
github-actions[bot]tofikwestclaude
authored
[dev] [tofikwest] fix/device-agent-system-browser-login (#2222)
* fix(device-agent): use system browser for login to support passkeys The device agent's embedded Electron BrowserWindow does not support WebAuthn/passkeys, preventing users with passkeys enabled from signing in with Google. This switches to opening the system browser (Safari, Chrome, etc.) for authentication using a localhost callback server and authorization code exchange pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(device-agent): atomic getdel for auth code + encode URL params - Use Redis GETDEL for atomic get+delete to prevent TOCTOU race on auth code exchange - URL-encode callback_port and state params to prevent parameter injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(device-agent): encode redirect params + make performLogout self-contained - URL-encode code and state in device-callback redirect - Move clearAuth() into performLogout so logout is self-contained - Remove redundant clearAuth() calls from all call sites in index.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 74be7f6 commit b0f32f0

12 files changed

Lines changed: 431 additions & 244 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client';
2+
3+
import { Icons } from '@comp/ui/icons';
4+
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
5+
import { Loader2, CheckCircle2 } from 'lucide-react';
6+
import { useSearchParams } from 'next/navigation';
7+
import { useEffect, useState } from 'react';
8+
9+
type Status = 'redirecting' | 'success' | 'error';
10+
11+
export default function DeviceCallbackPage() {
12+
const searchParams = useSearchParams();
13+
const [status, setStatus] = useState<Status>('redirecting');
14+
const [errorMessage, setErrorMessage] = useState('');
15+
16+
useEffect(() => {
17+
const callbackPort = searchParams.get('callback_port');
18+
const state = searchParams.get('state');
19+
20+
if (!callbackPort || !state) {
21+
setStatus('error');
22+
setErrorMessage('Missing required parameters. Please try signing in again from the Comp AI agent.');
23+
return;
24+
}
25+
26+
const port = Number.parseInt(callbackPort, 10);
27+
if (Number.isNaN(port) || port < 1 || port > 65535) {
28+
setStatus('error');
29+
setErrorMessage('Invalid callback port. Please try signing in again from the Comp AI agent.');
30+
return;
31+
}
32+
33+
async function exchangeAndRedirect() {
34+
try {
35+
// Generate an auth code using the authenticated session
36+
const response = await fetch('/api/device-agent/auth-code', {
37+
method: 'POST',
38+
headers: { 'Content-Type': 'application/json' },
39+
body: JSON.stringify({ callback_port: port, state }),
40+
});
41+
42+
if (!response.ok) {
43+
const data = await response.json().catch(() => ({}));
44+
throw new Error(data.error || `Server returned ${response.status}`);
45+
}
46+
47+
const { code } = await response.json();
48+
49+
// Redirect to the device agent's localhost server
50+
window.location.href = `http://localhost:${port}/auth-callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state!)}`;
51+
52+
setStatus('success');
53+
} catch (err) {
54+
console.error('Device auth callback failed:', err);
55+
setStatus('error');
56+
setErrorMessage(
57+
err instanceof Error
58+
? err.message
59+
: 'Failed to complete sign-in. Please try again from the Comp AI agent.',
60+
);
61+
}
62+
}
63+
64+
exchangeAndRedirect();
65+
}, [searchParams]);
66+
67+
return (
68+
<div className="flex min-h-dvh flex-col text-foreground">
69+
<main className="flex flex-1 items-center justify-center p-6">
70+
<Card className="w-full max-w-md">
71+
<CardHeader className="text-center space-y-3 pt-10">
72+
<Icons.Logo className="h-10 w-10 mx-auto" />
73+
<CardTitle className="text-xl tracking-tight text-card-foreground">
74+
{status === 'redirecting' && 'Completing sign-in...'}
75+
{status === 'success' && 'Sign-in complete!'}
76+
{status === 'error' && 'Sign-in failed'}
77+
</CardTitle>
78+
</CardHeader>
79+
<CardContent className="text-center pb-10">
80+
{status === 'redirecting' && (
81+
<div className="flex flex-col items-center gap-3">
82+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
83+
<p className="text-sm text-muted-foreground">
84+
Redirecting to the Comp AI agent...
85+
</p>
86+
</div>
87+
)}
88+
{status === 'success' && (
89+
<div className="flex flex-col items-center gap-3">
90+
<CheckCircle2 className="h-6 w-6 text-green-500" />
91+
<p className="text-sm text-muted-foreground">
92+
You can close this tab and return to the Comp AI agent.
93+
</p>
94+
</div>
95+
)}
96+
{status === 'error' && (
97+
<p className="text-sm text-destructive">
98+
{errorMessage}
99+
</p>
100+
)}
101+
</CardContent>
102+
</Card>
103+
</main>
104+
</div>
105+
);
106+
}

apps/portal/src/app/(public)/auth/page.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ export const metadata: Metadata = {
1818
title: 'Login | Comp AI',
1919
};
2020

21-
export default async function Page() {
21+
export default async function Page({
22+
searchParams,
23+
}: {
24+
searchParams: Promise<Record<string, string | string[] | undefined>>;
25+
}) {
26+
const params = await searchParams;
27+
const isDeviceAuth = params.device_auth === 'true';
28+
const callbackPort = typeof params.callback_port === 'string' ? params.callback_port : undefined;
29+
const state = typeof params.state === 'string' ? params.state : undefined;
30+
31+
const deviceAuthRedirect =
32+
isDeviceAuth && callbackPort && state
33+
? `/auth/device-callback?callback_port=${encodeURIComponent(callbackPort)}&state=${encodeURIComponent(state)}`
34+
: undefined;
35+
2236
const defaultSignInOptions = (
2337
<div className="flex flex-col space-y-2">
24-
<OtpSignIn />
38+
<OtpSignIn deviceAuthRedirect={deviceAuthRedirect} />
2539
</div>
2640
);
2741

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { auth } from '@/app/lib/auth';
2+
import { client as kv } from '@comp/kv';
3+
import { randomBytes } from 'crypto';
4+
import { type NextRequest, NextResponse } from 'next/server';
5+
import { z } from 'zod';
6+
7+
export const runtime = 'nodejs';
8+
export const dynamic = 'force-dynamic';
9+
10+
const authCodeSchema = z.object({
11+
callback_port: z.number().int().min(1).max(65535),
12+
state: z.string().min(1),
13+
});
14+
15+
/**
16+
* Generates a short-lived authorization code for the device agent.
17+
* Called by the portal frontend after successful login when device_auth=true.
18+
* The code can be exchanged for a session token via /api/device-agent/exchange-code.
19+
*/
20+
export async function POST(req: NextRequest) {
21+
try {
22+
const session = await auth.api.getSession({ headers: req.headers });
23+
24+
if (!session?.user) {
25+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
26+
}
27+
28+
const body = await req.json();
29+
const parsed = authCodeSchema.safeParse(body);
30+
31+
if (!parsed.success) {
32+
return NextResponse.json(
33+
{ error: 'Invalid request body', details: parsed.error.flatten() },
34+
{ status: 400 },
35+
);
36+
}
37+
38+
const { state } = parsed.data;
39+
40+
// Use the raw session token from the database (not the signed cookie value).
41+
// The bearer plugin expects the raw token and signs it internally.
42+
const sessionToken = session.session.token;
43+
44+
// Generate a single-use authorization code
45+
const code = randomBytes(32).toString('hex');
46+
47+
// Store in KV with 2-minute expiry
48+
await kv.set(
49+
`device-auth:${code}`,
50+
{
51+
sessionToken,
52+
userId: session.user.id,
53+
state,
54+
createdAt: Date.now(),
55+
},
56+
{ ex: 120 },
57+
);
58+
59+
return NextResponse.json({ code });
60+
} catch (error) {
61+
console.error('Error generating device auth code:', error);
62+
return NextResponse.json({ error: 'Failed to generate auth code' }, { status: 500 });
63+
}
64+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { client as kv } from '@comp/kv';
2+
import { NextResponse } from 'next/server';
3+
import { z } from 'zod';
4+
5+
export const runtime = 'nodejs';
6+
export const dynamic = 'force-dynamic';
7+
8+
const exchangeCodeSchema = z.object({
9+
code: z.string().min(1),
10+
});
11+
12+
interface StoredAuthCode {
13+
sessionToken: string;
14+
userId: string;
15+
state: string;
16+
createdAt: number;
17+
}
18+
19+
/**
20+
* Exchanges a single-use authorization code for a session token.
21+
* No authentication required — the code itself is the proof of auth.
22+
* This follows the same pattern as OAuth authorization code exchange.
23+
*/
24+
export async function POST(req: Request) {
25+
try {
26+
const body = await req.json();
27+
const parsed = exchangeCodeSchema.safeParse(body);
28+
29+
if (!parsed.success) {
30+
return NextResponse.json(
31+
{ error: 'Invalid request body', details: parsed.error.flatten() },
32+
{ status: 400 },
33+
);
34+
}
35+
36+
const { code } = parsed.data;
37+
const kvKey = `device-auth:${code}`;
38+
39+
// Atomic get+delete to prevent race condition (TOCTOU)
40+
const stored = await kv.getdel<StoredAuthCode>(kvKey);
41+
42+
if (!stored) {
43+
return NextResponse.json(
44+
{ error: 'Invalid or expired authorization code' },
45+
{ status: 401 },
46+
);
47+
}
48+
49+
return NextResponse.json({
50+
session_token: stored.sessionToken,
51+
user_id: stored.userId,
52+
});
53+
} catch (error) {
54+
console.error('Error exchanging device auth code:', error);
55+
return NextResponse.json({ error: 'Failed to exchange auth code' }, { status: 500 });
56+
}
57+
}

apps/portal/src/app/components/google-sign-in.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ export function GoogleSignIn({
2020

2121
// Build the callback URL with search params
2222
const baseURL = window.location.origin;
23-
const path = inviteCode ? `/invite/${inviteCode}` : '/';
23+
const isDeviceAuth = searchParams?.get('device_auth') === 'true';
24+
const path = isDeviceAuth
25+
? '/auth/device-callback'
26+
: inviteCode
27+
? `/invite/${inviteCode}`
28+
: '/';
2429
const redirectTo = new URL(path, baseURL);
2530

2631
// Append all search params if they exist

apps/portal/src/app/components/microsoft-sign-in.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ export function MicrosoftSignIn({
2222
try {
2323
// Build the callback URL with search params
2424
const baseURL = window.location.origin;
25-
const path = inviteCode ? `/invite/${inviteCode}` : '/';
25+
const isDeviceAuth = searchParams?.get('device_auth') === 'true';
26+
const path = isDeviceAuth
27+
? '/auth/device-callback'
28+
: inviteCode
29+
? `/invite/${inviteCode}`
30+
: '/';
2631
const redirectTo = new URL(path, baseURL);
2732

2833
// Append all search params if they exist

apps/portal/src/app/components/otp-form.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ type OtpFormValues = z.infer<typeof otpFormSchema>;
2525

2626
interface OtpFormProps {
2727
email: string;
28+
deviceAuthRedirect?: string;
2829
}
2930

30-
export function OtpForm({ email }: OtpFormProps) {
31+
export function OtpForm({ email, deviceAuthRedirect }: OtpFormProps) {
3132
const [isLoading, setIsLoading] = useState(false);
3233
const router = useRouter();
3334
const form = useForm<OtpFormValues>({
@@ -70,7 +71,7 @@ export function OtpForm({ email }: OtpFormProps) {
7071
}
7172

7273
toast.success('OTP verified');
73-
router.push('/');
74+
router.push(deviceAuthRedirect || '/');
7475
} catch {
7576
toast.error('An unexpected error occurred');
7677
} finally {

apps/portal/src/app/components/otp.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ const formSchema = z.object({
2020

2121
type Props = {
2222
className?: string;
23+
deviceAuthRedirect?: string;
2324
};
2425

25-
export function OtpSignIn({ className }: Props) {
26+
export function OtpSignIn({ className, deviceAuthRedirect }: Props) {
2627
const [isLoading, setLoading] = useState(false);
2728
const [isSent, setSent] = useState(false);
2829
const [_email, setEmail] = useState<string>();
@@ -57,7 +58,7 @@ export function OtpSignIn({ className }: Props) {
5758
if (isSent) {
5859
return (
5960
<div className={cn('flex flex-col space-y-4', className)}>
60-
<OtpForm email={_email ?? ''} />
61+
<OtpForm email={_email ?? ''} deviceAuthRedirect={deviceAuthRedirect} />
6162
</div>
6263
);
6364
}

0 commit comments

Comments
 (0)