Skip to content

Commit a339bca

Browse files
authored
fix(auth): add web session pepper revocation (#2788)
* fix(auth): add web session pepper revocation * fix(auth): separate browser session sign-out * fix(auth): rotate peppers on soft delete * fix(auth): preserve legacy web session revocation * chore(db): drop stale web session migration * fix(auth): regenerate web session migration
1 parent bc924c3 commit a339bca

26 files changed

Lines changed: 18338 additions & 18 deletions

apps/web/src/app/(app)/components/SidebarUserFooter.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ type SidebarUserFooterProps = {
2020

2121
export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooterProps) {
2222
const handleLogout = async () => {
23-
await signOut({ callbackUrl: '/' });
23+
try {
24+
await fetch('/api/auth/revoke-web-session', { method: 'POST' });
25+
} finally {
26+
await signOut({ callbackUrl: '/' });
27+
}
2428
};
2529

2630
// Get user initials for avatar fallback

apps/web/src/app/admin/components/UserAdmin/ResetAPIKeyButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function ResetAPIKeyButton({ userId }: { userId: string }) {
4141
<DialogTitle>Reset API keys</DialogTitle>
4242
<DialogDescription>
4343
Are you sure that you want to reset the API keys for this user? This action can not be
44-
undone!
44+
undone. Browser sessions will stay signed in.
4545
</DialogDescription>
4646
</DialogHeader>
4747

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Button } from '@/components/ui/button';
5+
import {
6+
Dialog,
7+
DialogClose,
8+
DialogContent,
9+
DialogDescription,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
13+
DialogTrigger,
14+
} from '@/components/ui/dialog';
15+
import { useMutation } from '@tanstack/react-query';
16+
import { useTRPC } from '@/lib/trpc/utils';
17+
import { toast } from 'sonner';
18+
19+
export default function SignOutBrowserSessionsButton({ userId }: { userId: string }) {
20+
const [isDialogOpen, setDialogOpen] = useState(false);
21+
const trpc = useTRPC();
22+
23+
const signOutBrowserSessionsMutation = useMutation(
24+
trpc.admin.users.signOutBrowserSessions.mutationOptions({
25+
onSuccess: () => {
26+
toast.success('Browser sessions were successfully signed out!');
27+
setDialogOpen(false);
28+
},
29+
})
30+
);
31+
32+
return (
33+
<Dialog open={isDialogOpen} onOpenChange={setDialogOpen}>
34+
<DialogTrigger asChild>
35+
<Button size="sm" variant="outline">
36+
Sign out browser sessions
37+
</Button>
38+
</DialogTrigger>
39+
<DialogContent>
40+
<DialogHeader>
41+
<DialogTitle>Sign out browser sessions</DialogTitle>
42+
<DialogDescription>
43+
Are you sure that you want to sign out all browser sessions for this user? CLI, VS Code,
44+
JetBrains, and other API tokens will continue to work.
45+
</DialogDescription>
46+
</DialogHeader>
47+
48+
<DialogFooter>
49+
<DialogClose asChild>
50+
<Button variant="outline" disabled={signOutBrowserSessionsMutation.isPending}>
51+
Cancel
52+
</Button>
53+
</DialogClose>
54+
<Button
55+
variant="destructive"
56+
onClick={() => signOutBrowserSessionsMutation.mutate({ userId })}
57+
disabled={signOutBrowserSessionsMutation.isPending}
58+
>
59+
{signOutBrowserSessionsMutation.isPending
60+
? 'Signing out...'
61+
: 'Sign Out Browser Sessions'}
62+
</Button>
63+
</DialogFooter>
64+
</DialogContent>
65+
</Dialog>
66+
);
67+
}

apps/web/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { formatDate } from '@/lib/admin-utils';
88
import type { UserDetailProps } from '@/types/admin';
99
import ResetAPIKeyButton from './ResetAPIKeyButton';
1010
import ResetToMagicLinkLoginButton from './ResetToMagicLinkLoginButton';
11+
import SignOutBrowserSessionsButton from './SignOutBrowserSessionsButton';
1112
import { Button } from '@/components/ui/button';
1213
import Link from 'next/link';
1314
import { SquareArrowOutUpRight, Webhook } from 'lucide-react';
@@ -56,6 +57,7 @@ export function UserAdminAccountInfo(user: UserAdminAccountInfoProps) {
5657
<div className="flex flex-wrap items-center gap-2">
5758
<UserStatusBadge is_detail={true} user={user} />
5859
<PaymentMethodStatusBadge paymentMethodStatus={user.paymentMethodStatus} />
60+
<SignOutBrowserSessionsButton userId={user.id} />
5961
<ResetAPIKeyButton userId={user.id} />
6062
{!user.is_sso_protected_domain && <ResetToMagicLinkLoginButton userId={user.id} />}
6163
<Button variant="outline" size="sm" asChild>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { successResult } from '@/lib/maybe-result';
2+
import { getUserFromAuth } from '@/lib/user.server';
3+
import { revokeWebSessions } from '@/lib/web-session-revocation';
4+
import { NextResponse } from 'next/server';
5+
6+
export async function POST() {
7+
const { user } = await getUserFromAuth({
8+
adminOnly: false,
9+
DANGEROUS_allowBlockedUsers: true,
10+
});
11+
12+
if (user) {
13+
await revokeWebSessions(user.id);
14+
}
15+
16+
return NextResponse.json(successResult());
17+
}

apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ function createMockUser(overrides: Partial<User> = {}): User {
8484
blocked_at: null,
8585
blocked_by_kilo_user_id: null,
8686
api_token_pepper: 'test-pepper',
87+
web_session_pepper: null,
8788
auto_top_up_enabled: false,
8889
stripe_customer_id: 'cus_test123',
8990
microdollars_used: 0,

apps/web/src/app/auto-signout/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ export default function AutoSignOutPage() {
1010
}
1111

1212
useEffect(() => {
13-
void signOut({ callbackUrl: '/profile' });
13+
void (async () => {
14+
try {
15+
await fetch('/api/auth/revoke-web-session', { method: 'POST' });
16+
} finally {
17+
await signOut({ callbackUrl: '/profile' });
18+
}
19+
})();
1420
}, []);
1521

1622
return (

apps/web/src/components/dev/actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
44
import { findUserById, softDeleteUser } from '@/lib/user';
55
import { deleteStripeCustomer } from '@/lib/stripe-client';
66
import { captureException } from '@sentry/nextjs';
7+
import { revokeWebSessions } from '@/lib/web-session-revocation';
78

89
export async function nuke(kiloUserId: string) {
910
try {
@@ -12,6 +13,7 @@ export async function nuke(kiloUserId: string) {
1213
throw new Error(`User not found: ${kiloUserId}`);
1314
}
1415

16+
await revokeWebSessions(kiloUserId);
1517
await softDeleteUser(kiloUserId);
1618
await deleteStripeCustomer(user.stripe_customer_id);
1719
} catch (error) {

apps/web/src/components/profile/ResetAPITokenDialog.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export function ResetAPITokenDialog() {
2424
const resetAPIKeyMutation = useMutation(
2525
trpc.user.resetAPIKey.mutationOptions({
2626
onSuccess: () => {
27-
toast.success('API token reset successfully. Redirecting to sign-in page...');
28-
router.push('/users/sign_in');
27+
toast.success('API token reset successfully. Refreshing token...');
28+
router.refresh();
2929
},
3030
})
3131
);
@@ -35,22 +35,21 @@ export function ResetAPITokenDialog() {
3535
<DialogTrigger asChild>
3636
<Button variant="outline" size="sm" className="text-destructive hover:bg-destructive">
3737
<RotateCcw className="mr-2 h-4 w-4" />
38-
Reset Token and Sign Out
38+
Reset API Token
3939
</Button>
4040
</DialogTrigger>
4141
<DialogContent className="sm:max-w-[425px]">
4242
<DialogHeader>
4343
<DialogTitle className="flex items-center gap-2 text-orange-600">
4444
<AlertTriangle className="h-5 w-5" />
45-
Reset all API tokens and sign out everywhere.
45+
Reset all API tokens?
4646
</DialogTitle>
4747
<DialogDescription className="text-muted-foreground pt-3">
4848
<strong className="text-foreground">This action cannot be undone.</strong>
4949
<br />
5050
<br />
51-
Resetting your API token will invalidate all existing tokens. You will also be signed
52-
out of all sessions. If you procees, you will need to sign in again and then re-open in
53-
VS Code and/or other Kilo Code extensions.
51+
Resetting your API token will invalidate all existing CLI, VS Code, JetBrains, and other
52+
API tokens. Browser sessions will stay signed in.
5453
</DialogDescription>
5554
</DialogHeader>
5655
<DialogFooter className="gap-2 sm:gap-0">
@@ -69,7 +68,7 @@ export function ResetAPITokenDialog() {
6968
onClick={() => resetAPIKeyMutation.mutate()}
7069
disabled={resetAPIKeyMutation.isPending}
7170
>
72-
{resetAPIKeyMutation.isPending ? 'Resetting...' : 'Reset API Token and Sign Out'}
71+
{resetAPIKeyMutation.isPending ? 'Resetting...' : 'Reset API Token'}
7372
</Button>
7473
</DialogFooter>
7574
</DialogContent>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client';
2+
3+
import { Button } from '@/components/ui/button';
4+
import {
5+
Dialog,
6+
DialogClose,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
DialogTrigger,
13+
} from '@/components/ui/dialog';
14+
import { AlertTriangle, LogOut } from 'lucide-react';
15+
import { useTRPC } from '@/lib/trpc/utils';
16+
import { useMutation } from '@tanstack/react-query';
17+
import { toast } from 'sonner';
18+
import { signOut } from 'next-auth/react';
19+
20+
export function SignOutBrowserSessionsDialog() {
21+
const trpc = useTRPC();
22+
23+
const signOutBrowserSessionsMutation = useMutation(
24+
trpc.user.signOutBrowserSessions.mutationOptions({
25+
onSuccess: async () => {
26+
toast.success('Browser sessions signed out. Redirecting to sign-in page...');
27+
await signOut({ callbackUrl: '/users/sign_in' });
28+
},
29+
})
30+
);
31+
32+
return (
33+
<Dialog>
34+
<DialogTrigger asChild>
35+
<Button variant="outline" size="sm">
36+
<LogOut className="mr-2 h-4 w-4" />
37+
Sign Out Browser Sessions
38+
</Button>
39+
</DialogTrigger>
40+
<DialogContent className="sm:max-w-[425px]">
41+
<DialogHeader>
42+
<DialogTitle className="flex items-center gap-2 text-orange-600">
43+
<AlertTriangle className="h-5 w-5" />
44+
Sign out all browser sessions?
45+
</DialogTitle>
46+
<DialogDescription className="text-muted-foreground pt-3">
47+
This will sign you out of Kilo Code in every browser, including this one. CLI, VS Code,
48+
JetBrains, and other API tokens will continue to work.
49+
</DialogDescription>
50+
</DialogHeader>
51+
<DialogFooter className="gap-2 sm:gap-0">
52+
<DialogClose asChild>
53+
<Button
54+
variant="secondary"
55+
className="w-full sm:w-auto"
56+
disabled={signOutBrowserSessionsMutation.isPending}
57+
>
58+
Cancel
59+
</Button>
60+
</DialogClose>
61+
<Button
62+
variant="destructive"
63+
className="w-full sm:w-auto"
64+
onClick={() => signOutBrowserSessionsMutation.mutate()}
65+
disabled={signOutBrowserSessionsMutation.isPending}
66+
>
67+
{signOutBrowserSessionsMutation.isPending
68+
? 'Signing out...'
69+
: 'Sign Out Browser Sessions'}
70+
</Button>
71+
</DialogFooter>
72+
</DialogContent>
73+
</Dialog>
74+
);
75+
}

0 commit comments

Comments
 (0)