Skip to content

Commit e20bad2

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp): make CLI login codes single use and mint a token per login
CLI and MCP login codes now work once, every login mints its own personal access token, and you get an email whenever a new login is authorized on your account. The consent page shows when and from which IP address the login was started. Mono-RevId: 3f906ca868b4568d9695ccee660a6681c4e13bd4
1 parent 5f8171c commit e20bad2

19 files changed

Lines changed: 821 additions & 78 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The CLI and MCP login consent page now shows when and from which IP address the login was started, and you get an email whenever a new CLI or MCP login is authorized on your account.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
CLI and MCP login codes now work once, and every login mints its own personal access token instead of reusing an existing CLI token.

‎apps/webapp/app/env.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,8 @@ const EnvironmentSchema = z
10451045
WORKLOAD_CREATED_AT_GATE_ENABLED: z.string().default("0"),
10461046
WORKLOAD_TOKEN_CUTOFF: z.string().datetime().optional(),
10471047

1048+
CLI_LOGIN_NETWORK_CHECK: z.enum(["off", "log", "confirm"]).default("log"),
1049+
10481050
// Development OTEL environment variables
10491051
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
10501052
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),

‎apps/webapp/app/routes/account.authorization-code.$authorizationCode/route.tsx‎

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import { CheckCircleIcon } from "@heroicons/react/24/solid";
22
import { Form } from "@remix-run/react";
3+
import { useState } from "react";
34
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
45
import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
56
import { z } from "zod";
67
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
78
import { Button } from "~/components/primitives/Buttons";
89
import { Callout } from "~/components/primitives/Callout";
10+
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
911
import { Header1 } from "~/components/primitives/Headers";
1012
import { Icon } from "~/components/primitives/Icon";
1113
import { Paragraph } from "~/components/primitives/Paragraph";
14+
import { TextLink } from "~/components/primitives/TextLink";
1215
import { prisma } from "~/db.server";
16+
import { env } from "~/env.server";
17+
import {
18+
compareCliLoginNetworks,
19+
describeIpLocation,
20+
needsNetworkConfirmation,
21+
} from "~/services/cliLoginNetwork.server";
1322
import { logger } from "~/services/logger.server";
1423
import {
1524
createPersonalAccessTokenFromAuthorizationCode,
16-
isAuthorizationCodeMintable,
25+
getMintableAuthorizationCode,
1726
} from "~/services/personalAccessToken.server";
27+
import { getIpLocation } from "~/services/platform.v3.server";
28+
import { scheduleEmail } from "~/services/scheduleEmail.server";
1829
import { requireUser } from "~/services/session.server";
30+
import { extractClientIp } from "~/utils/extractClientIp.server";
1931
import { pageMeta } from "~/utils/pageTitle";
2032

2133
export const meta = pageMeta("Authorize login");
@@ -57,28 +69,42 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5769
const { authorizationCode } = parseParams(params);
5870
const { source, clientName } = parseSearch(request);
5971

60-
const mintable = await isAuthorizationCodeMintable(authorizationCode);
72+
const mintable = await getMintableAuthorizationCode(authorizationCode);
6173

6274
if (!mintable) {
6375
return typedjson({ status: "invalid" as const, source, clientName });
6476
}
6577

6678
// The token acts as the user, so it can reach every organization they belong to.
6779
const organizationWhere = { deletedAt: null, members: { some: { userId: user.id } } };
68-
const [organizations, organizationCount] = await Promise.all([
80+
const [organizations, organizationCount, requesterLocation] = await Promise.all([
6981
prisma.organization.findMany({
7082
select: { id: true, title: true },
7183
where: organizationWhere,
7284
orderBy: { title: "asc" },
7385
take: ORGANIZATION_DISPLAY_LIMIT,
7486
}),
7587
prisma.organization.count({ where: organizationWhere }),
88+
getIpLocation(mintable.requesterIp),
7689
]);
7790

91+
const networkMatch = compareCliLoginNetworks(
92+
mintable.requesterIp,
93+
extractClientIp(request.headers.get("x-forwarded-for"))
94+
);
95+
7896
return typedjson({
7997
status: "consent" as const,
8098
source,
8199
clientName,
100+
requesterIp: mintable.requesterIp,
101+
requesterLocation: describeIpLocation(requesterLocation) ?? null,
102+
startedMinutesAgo: Math.max(
103+
0,
104+
Math.floor((Date.now() - mintable.createdAt.getTime()) / 60_000)
105+
),
106+
requiresNetworkConfirmation:
107+
env.CLI_LOGIN_NETWORK_CHECK === "confirm" && needsNetworkConfirmation(networkMatch),
82108
account: {
83109
email: user.email,
84110
name: user.displayName ?? user.name,
@@ -89,13 +115,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
89115
};
90116

91117
export const action = async ({ request, params }: ActionFunctionArgs) => {
92-
const { id: userId } = await requireUser(request);
118+
const user = await requireUser(request);
119+
const userId = user.id;
93120

94121
const { authorizationCode } = parseParams(params);
95122
const { source, clientName } = parseSearch(request);
123+
const formData = await request.formData();
96124

97125
try {
98-
await createPersonalAccessTokenFromAuthorizationCode(authorizationCode, userId);
126+
const token = await createPersonalAccessTokenFromAuthorizationCode(authorizationCode, userId, {
127+
approverIp: extractClientIp(request.headers.get("x-forwarded-for")),
128+
confirmedDifferentNetwork: formData.get("confirmDifferentNetwork") === "on",
129+
});
130+
131+
await notifyLoginAuthorized({
132+
userEmail: user.email,
133+
clientDescription: describeClient(source, clientName),
134+
obfuscatedToken: token.obfuscatedToken,
135+
requesterIp: token.requesterIp,
136+
});
137+
99138
return typedjson({ success: true as const, source, clientName });
100139
} catch (error) {
101140
if (error instanceof Response) {
@@ -169,14 +208,24 @@ export default function Page() {
169208
<div className="flex flex-col gap-4">
170209
<Header1>Authorize login</Header1>
171210
<Paragraph>{getConsentPrompt(loaderData.source, loaderData.clientName)}</Paragraph>
172-
<Form method="post">
173-
<Button type="submit" variant="primary/medium" fullWidth>
174-
Authorize
175-
</Button>
176-
</Form>
211+
<Paragraph variant="small">
212+
{describeLoginStart(
213+
loaderData.startedMinutesAgo,
214+
loaderData.requesterIp,
215+
loaderData.requesterLocation
216+
)}
217+
</Paragraph>
218+
<ConsentForm requiresNetworkConfirmation={loaderData.requiresNetworkConfirmation} />
177219
<Paragraph variant="extra-small">
178220
Only authorize if you started this login yourself. If you didn't, close this page.
179221
</Paragraph>
222+
{loaderData.requesterLocation && (
223+
<Paragraph variant="extra-small">
224+
<TextLink href="https://ipinfo.io" target="_blank">
225+
IP address data powered by IPinfo
226+
</TextLink>
227+
</Paragraph>
228+
)}
180229
<div className="flex flex-col gap-3 rounded-md border border-grid-bright p-3">
181230
<div>
182231
<Paragraph variant="base/bright" className="font-medium">
@@ -209,6 +258,88 @@ export default function Page() {
209258
);
210259
}
211260

261+
function ConsentForm({ requiresNetworkConfirmation }: { requiresNetworkConfirmation: boolean }) {
262+
const [confirmed, setConfirmed] = useState(false);
263+
264+
return (
265+
<Form method="post" className="flex flex-col gap-3">
266+
{requiresNetworkConfirmation && (
267+
<>
268+
<Callout variant="warning">
269+
This login was started from a different network than the one you're on now. If someone
270+
sent you this link, close this page.
271+
</Callout>
272+
<CheckboxWithLabel
273+
name="confirmDifferentNetwork"
274+
value="on"
275+
variant="simple/small"
276+
label="I started this login myself, on another machine"
277+
onChange={setConfirmed}
278+
/>
279+
</>
280+
)}
281+
<Button
282+
type="submit"
283+
variant="primary/medium"
284+
fullWidth
285+
disabled={requiresNetworkConfirmation && !confirmed}
286+
>
287+
Authorize
288+
</Button>
289+
</Form>
290+
);
291+
}
292+
293+
function describeLoginStart(
294+
startedMinutesAgo: number,
295+
requesterIp: string | null,
296+
requesterLocation: string | null
297+
) {
298+
const when =
299+
startedMinutesAgo === 0
300+
? "just now"
301+
: `${startedMinutesAgo} minute${startedMinutesAgo === 1 ? "" : "s"} ago`;
302+
if (!requesterIp) {
303+
return `This login was started ${when}.`;
304+
}
305+
return requesterLocation
306+
? `This login was started ${when} from IP address ${requesterIp} (${requesterLocation}).`
307+
: `This login was started ${when} from IP address ${requesterIp}.`;
308+
}
309+
310+
function describeClient(source: string, clientName: string) {
311+
if (source === "mcp") {
312+
return knownClientName(clientName) ?? "an MCP client";
313+
}
314+
return "the Trigger.dev CLI";
315+
}
316+
317+
async function notifyLoginAuthorized(details: {
318+
userEmail: string;
319+
clientDescription: string;
320+
obfuscatedToken: string;
321+
requesterIp: string | null;
322+
}) {
323+
try {
324+
const requesterLocation = describeIpLocation(await getIpLocation(details.requesterIp));
325+
await scheduleEmail({
326+
email: "cli-login-authorized",
327+
to: details.userEmail,
328+
userEmail: details.userEmail,
329+
clientDescription: details.clientDescription,
330+
obfuscatedToken: details.obfuscatedToken,
331+
requesterIp: details.requesterIp ?? undefined,
332+
requesterLocation,
333+
authorizedAt: new Date().toUTCString(),
334+
tokensUrl: `${env.APP_ORIGIN}/account/tokens`,
335+
});
336+
} catch (error) {
337+
logger.error("Failed to schedule the CLI login authorized email", {
338+
error: error instanceof Error ? error.message : String(error),
339+
});
340+
}
341+
}
342+
212343
function AuthShell({ children }: { children: React.ReactNode }) {
213344
return (
214345
<AppContainer>
@@ -227,10 +358,14 @@ const prettyClientNames: Record<string, string> = {
227358
"claude-ai": "Claude Desktop",
228359
};
229360

361+
function knownClientName(clientName: string): string | undefined {
362+
return Object.hasOwn(prettyClientNames, clientName) ? prettyClientNames[clientName] : undefined;
363+
}
364+
230365
function getConsentPrompt(source: string, clientName: string) {
231366
if (source === "mcp") {
232-
const pretty = prettyClientNames[clientName] ?? clientName;
233-
if (pretty && pretty !== "unknown") {
367+
const pretty = knownClientName(clientName);
368+
if (pretty) {
234369
return `Authorize ${pretty} to access your Trigger.dev account?`;
235370
}
236371
return `Authorize this MCP client to access your Trigger.dev account?`;
@@ -241,9 +376,8 @@ function getConsentPrompt(source: string, clientName: string) {
241376

242377
function getInstructionsForSource(source: string, clientName: string) {
243378
if (source === "mcp") {
244-
if (clientName) {
245-
return `Return to your ${prettyClientNames[clientName] ?? clientName} to continue.`;
246-
}
379+
const pretty = knownClientName(clientName);
380+
return pretty ? `Return to ${pretty} to continue.` : `Return to your MCP client to continue.`;
247381
}
248382

249383
return `Return to your terminal to continue.`;

‎apps/webapp/app/routes/api.v1.authorization-code.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function action({ request }: ActionFunctionArgs) {
3939
}
4040

4141
try {
42-
const authorizationCode = await createAuthorizationCode();
42+
const authorizationCode = await createAuthorizationCode({ requesterIp: clientIp });
4343
const responseJson: CreateAuthorizationCodeResponse = {
4444
authorizationCode: authorizationCode.code,
4545
url: `${env.APP_ORIGIN}/account/authorization-code/${authorizationCode.code}`,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { isIPv4, isIPv6 } from "node:net";
2+
3+
/**
4+
* How the network that started a CLI login compares with the network of the browser approving it.
5+
* A real login starts and is approved on the same machine, so the two usually share a public IP.
6+
* `mixed-family` means one side used IPv4 and the other IPv6, which dual-stack networks do too.
7+
*/
8+
export type CliLoginNetworkMatch = "match" | "mismatch" | "mixed-family" | "unknown";
9+
10+
type Network = { family: 4 | 6; prefix: string };
11+
12+
/** IPv4 addresses compare whole, IPv6 addresses by their /64 prefix (one site or device). */
13+
export function compareCliLoginNetworks(
14+
requesterIp: string | null | undefined,
15+
approverIp: string | null | undefined
16+
): CliLoginNetworkMatch {
17+
const requester = networkOf(requesterIp);
18+
const approver = networkOf(approverIp);
19+
20+
if (!requester || !approver) {
21+
return "unknown";
22+
}
23+
24+
if (requester.family !== approver.family) {
25+
return "mixed-family";
26+
}
27+
28+
return requester.prefix === approver.prefix ? "match" : "mismatch";
29+
}
30+
31+
/** Whether the consent screen should ask the approver to confirm a login from another network. */
32+
export function needsNetworkConfirmation(match: CliLoginNetworkMatch) {
33+
return match === "mismatch" || match === "mixed-family";
34+
}
35+
36+
/**
37+
* Where a login IP is, for the consent page and the authorized-login email: "United States, on
38+
* Google LLC". Returns `undefined` when neither the country nor the network owner is known.
39+
*/
40+
export function describeIpLocation(
41+
location: { country: string | null; asName: string | null } | undefined
42+
): string | undefined {
43+
const parts = [location?.country, location?.asName ? `on ${location.asName}` : undefined].filter(
44+
Boolean
45+
);
46+
return parts.length > 0 ? parts.join(", ") : undefined;
47+
}
48+
49+
function networkOf(ip: string | null | undefined): Network | undefined {
50+
if (!ip) {
51+
return undefined;
52+
}
53+
54+
const address = ip.trim().replace(/%.*$/, "");
55+
const mappedIpv4 = address.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)?.[1];
56+
57+
if (mappedIpv4 && isIPv4(mappedIpv4)) {
58+
return { family: 4, prefix: mappedIpv4 };
59+
}
60+
61+
if (isIPv4(address)) {
62+
return { family: 4, prefix: address };
63+
}
64+
65+
if (isIPv6(address)) {
66+
return { family: 6, prefix: ipv6Prefix64(address) };
67+
}
68+
69+
return undefined;
70+
}
71+
72+
function ipv6Prefix64(address: string) {
73+
const [head, tail] = address.split("::");
74+
const headGroups = head ? head.split(":") : [];
75+
const tailGroups = tail ? tail.split(":") : [];
76+
const groups =
77+
tail === undefined
78+
? headGroups
79+
: [
80+
...headGroups,
81+
...Array<string>(Math.max(0, 8 - headGroups.length - tailGroups.length)).fill("0"),
82+
...tailGroups,
83+
];
84+
85+
return groups
86+
.slice(0, 4)
87+
.map((group) => parseInt(group, 16).toString(16))
88+
.join(":");
89+
}

0 commit comments

Comments
 (0)