Skip to content

Commit 2b26a44

Browse files
tofikwestclaude
andcommitted
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>
1 parent caef0d9 commit 2b26a44

11 files changed

Lines changed: 432 additions & 236 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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=${code}&state=${state}`;
51+
setStatus('success');
52+
} catch (err) {
53+
console.error('Device auth callback failed:', err);
54+
setStatus('error');
55+
setErrorMessage(
56+
err instanceof Error
57+
? err.message
58+
: 'Failed to complete sign-in. Please try again from the Comp AI agent.',
59+
);
60+
}
61+
}
62+
63+
exchangeAndRedirect();
64+
}, [searchParams]);
65+
66+
return (
67+
<div className="flex min-h-dvh flex-col text-foreground">
68+
<main className="flex flex-1 items-center justify-center p-6">
69+
<Card className="w-full max-w-md">
70+
<CardHeader className="text-center space-y-3 pt-10">
71+
<Icons.Logo className="h-10 w-10 mx-auto" />
72+
<CardTitle className="text-xl tracking-tight text-card-foreground">
73+
{status === 'redirecting' && 'Completing sign-in...'}
74+
{status === 'success' && 'Sign-in complete!'}
75+
{status === 'error' && 'Sign-in failed'}
76+
</CardTitle>
77+
</CardHeader>
78+
<CardContent className="text-center pb-10">
79+
{status === 'redirecting' && (
80+
<div className="flex flex-col items-center gap-3">
81+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
82+
<p className="text-sm text-muted-foreground">
83+
Redirecting to the Comp AI agent...
84+
</p>
85+
</div>
86+
)}
87+
{status === 'success' && (
88+
<div className="flex flex-col items-center gap-3">
89+
<CheckCircle2 className="h-6 w-6 text-green-500" />
90+
<p className="text-sm text-muted-foreground">
91+
You can close this tab and return to the Comp AI agent.
92+
</p>
93+
</div>
94+
)}
95+
{status === 'error' && (
96+
<p className="text-sm text-destructive">
97+
{errorMessage}
98+
</p>
99+
)}
100+
</CardContent>
101+
</Card>
102+
</main>
103+
</div>
104+
);
105+
}

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

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

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

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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
// Fetch and immediately delete (single-use)
40+
const stored = await kv.get<StoredAuthCode>(kvKey);
41+
42+
if (!stored) {
43+
return NextResponse.json(
44+
{ error: 'Invalid or expired authorization code' },
45+
{ status: 401 },
46+
);
47+
}
48+
49+
// Delete immediately to prevent replay
50+
await kv.del(kvKey);
51+
52+
return NextResponse.json({
53+
session_token: stored.sessionToken,
54+
user_id: stored.userId,
55+
});
56+
} catch (error) {
57+
console.error('Error exchanging device auth code:', error);
58+
return NextResponse.json({ error: 'Failed to exchange auth code' }, { status: 500 });
59+
}
60+
}

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

Lines changed: 6 additions & 3 deletions
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
@@ -30,8 +35,6 @@ export function GoogleSignIn({
3035
});
3136
}
3237

33-
console.log('******* redirectTo', redirectTo.toString());
34-
3538
await authClient.signIn.social({
3639
provider: 'google',
3740
callbackURL: redirectTo.toString(),

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
@@ -24,9 +24,10 @@ type OtpFormValues = z.infer<typeof otpFormSchema>;
2424

2525
interface OtpFormProps {
2626
email: string;
27+
deviceAuthRedirect?: string;
2728
}
2829

29-
export function OtpForm({ email }: OtpFormProps) {
30+
export function OtpForm({ email, deviceAuthRedirect }: OtpFormProps) {
3031
const [isLoading, setIsLoading] = useState(false);
3132
const router = useRouter();
3233
const form = useForm<OtpFormValues>({
@@ -40,7 +41,7 @@ export function OtpForm({ email }: OtpFormProps) {
4041
const { execute, isExecuting } = useAction(login, {
4142
onSuccess: () => {
4243
toast.success('OTP verified');
43-
router.push('/');
44+
router.push(deviceAuthRedirect || '/');
4445
},
4546
onError: (error) => {
4647
toast.error(error.error.serverError as string);

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

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

2020
type Props = {
2121
className?: string;
22+
deviceAuthRedirect?: string;
2223
};
2324

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

0 commit comments

Comments
 (0)