Skip to content

Commit 014a19c

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp,rbac): keep revoked personal access tokens revoked
Revoking a personal access token is now permanent. Logging in with the CLI reuses your existing token only while it's still live; if you've revoked it, the next login issues a new one and the revoked token stays rejected. Previously a CLI login brought the revoked token back with the same secret, so a leaked CLI token couldn't be rotated. To rotate one, revoke it on the tokens page, then run `trigger logout` and `trigger login`. Also in this change: - The endpoint the CLI polls during login no longer returns a token that was revoked after you approved the login. - Environment-scoped API access re-checks that the personal access token behind a delegated token is still live. - Approving a CLI login now respects your session duration setting: an expired session has to sign in again first. Mono-RevId: 22439c1ab597029283c471ba36fb40246d960531
1 parent 3c7056a commit 014a19c

10 files changed

Lines changed: 465 additions & 30 deletions
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+
Revoking a personal access token is now permanent. Logging in with the CLI again issues a new token instead of reinstating the revoked one, so you can rotate a leaked CLI token by revoking it and running `trigger logout` then `trigger login`.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
createPersonalAccessTokenFromAuthorizationCode,
1616
isAuthorizationCodeMintable,
1717
} from "~/services/personalAccessToken.server";
18-
import { requireUser, requireUserId } from "~/services/session.server";
18+
import { requireUser } from "~/services/session.server";
1919
import { pageMeta } from "~/utils/pageTitle";
2020

2121
export const meta = pageMeta("Authorize login");
@@ -89,7 +89,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8989
};
9090

9191
export const action = async ({ request, params }: ActionFunctionArgs) => {
92-
const userId = await requireUserId(request);
92+
const { id: userId } = await requireUser(request);
9393

9494
const { authorizationCode } = parseParams(params);
9595
const { source, clientName } = parseSearch(request);

‎apps/webapp/app/services/environmentVariableApiAccess.server.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type AuthenticationResult,
1010
type ScopedApiKeyAuthenticationDependencies,
1111
} from "~/services/apiAuth.server";
12+
import { resolveAndRecheckUserActorClaims } from "~/services/personalAccessToken.server";
1213
import { rbac } from "~/services/rbac.server";
1314

1415
type EnvironmentScopedResource = "envvars" | "apiKeys" | "deployments" | "branches";
@@ -127,6 +128,11 @@ const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
127128
* stops a restricted role deploying via the CLI (deploy needs the
128129
* environment's secret key).
129130
*
131+
* A user-actor token is stateless and lives for up to seven days, so its source
132+
* personal access token is re-checked here on every call: revoking that PAT is
133+
* the only way to retire the token, and this helper must not rely on a route
134+
* preamble having done the check already.
135+
*
130136
* Returns a `Response` to short-circuit with when access is denied, or
131137
* `undefined` when the request may proceed.
132138
*/
@@ -156,7 +162,7 @@ export async function authorizePatEnvironmentAccess({
156162
.get("Authorization")
157163
?.replace(/^Bearer /, "")
158164
.trim();
159-
const isUat = !!bearer && isUserActorToken(bearer);
165+
const userActorBearer = bearer && isUserActorToken(bearer) ? bearer : undefined;
160166

161167
// Machine API keys are authorized by their controller ability. Root keys and
162168
// ungranted additional keys are permissive; granted keys are restricted.
@@ -174,17 +180,27 @@ export async function authorizePatEnvironmentAccess({
174180

175181
// Org tokens carry no user role to enforce. A user-actor token carries a
176182
// user just like a PAT, so it's gated too.
177-
if (authType !== "personalAccessToken" && !isUat) {
183+
if (authType !== "personalAccessToken" && !userActorBearer) {
178184
return undefined;
179185
}
180186

181-
const userAuth = isUat
187+
const userAuth = userActorBearer
182188
? await rbac.authenticateUserActor(request, { organizationId, projectId })
183189
: await rbac.authenticatePat(request, { organizationId, projectId });
184190
if (!userAuth.ok) {
185191
return json({ error: userAuth.error }, { status: userAuth.status });
186192
}
187193

194+
if (
195+
userActorBearer &&
196+
!(await resolveAndRecheckUserActorClaims(
197+
"claims" in userAuth ? userAuth.claims : undefined,
198+
userActorBearer
199+
))
200+
) {
201+
return json({ error: "Invalid user-actor token" }, { status: 401 });
202+
}
203+
188204
if (!resources.some((candidate) => userAuth.ability.can(action, { type: candidate, envType }))) {
189205
return json(
190206
{

‎apps/webapp/app/services/personalAccessToken.server.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export async function getPersonalAccessTokenFromAuthorizationCode(authorizationC
7777
},
7878
},
7979
});
80-
if (!code) {
80+
if (!code || code.personalAccessToken?.revokedAt) {
8181
throw new Error("Invalid authorization code, or code expired");
8282
}
8383

@@ -379,6 +379,10 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
379379
where: {
380380
userId,
381381
name: "cli",
382+
revokedAt: null,
383+
},
384+
orderBy: {
385+
createdAt: "desc",
382386
},
383387
});
384388

@@ -394,18 +398,6 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
394398
},
395399
});
396400

397-
if (existingCliPersonalAccessToken.revokedAt) {
398-
// re-activate revoked CLI PAT so we can use it again
399-
await prisma.personalAccessToken.update({
400-
where: {
401-
id: existingCliPersonalAccessToken.id,
402-
},
403-
data: {
404-
revokedAt: null,
405-
},
406-
});
407-
}
408-
409401
//we don't return the decrypted token
410402
return {
411403
id: existingCliPersonalAccessToken.id,
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { beforeEach, describe, expect, vi } from "vitest";
2+
3+
/**
4+
* Revoking a CLI personal access token has to be permanent. Logging in again mints a new secret;
5+
* it never reinstates the revoked row, and the revoked string never authenticates or gets handed
6+
* back out over the unauthenticated token endpoint.
7+
*/
8+
9+
const db = vi.hoisted(() => ({ client: null as any }));
10+
11+
vi.mock("~/db.server", () => ({
12+
get prisma() {
13+
return db.client;
14+
},
15+
get $replica() {
16+
return db.client;
17+
},
18+
}));
19+
vi.mock("~/env.server", () => ({
20+
env: {
21+
SESSION_SECRET: "test-session-secret-for-cli-pat-revocation",
22+
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef",
23+
},
24+
}));
25+
vi.mock("~/services/logger.server", () => ({
26+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
27+
}));
28+
vi.mock("~/services/rbac.server", () => ({
29+
rbac: { isUsingPlugin: async () => false, setTokenRole: async () => ({ ok: true }) },
30+
}));
31+
32+
import { postgresTest } from "@internal/testcontainers";
33+
import {
34+
authenticatePersonalAccessToken,
35+
createAuthorizationCode,
36+
createPersonalAccessToken,
37+
createPersonalAccessTokenFromAuthorizationCode,
38+
getPersonalAccessTokenFromAuthorizationCode,
39+
revokePersonalAccessToken,
40+
} from "~/services/personalAccessToken.server";
41+
42+
vi.setConfig({ testTimeout: 60_000 });
43+
44+
let userCounter = 0;
45+
46+
async function createUser(prisma: any) {
47+
userCounter += 1;
48+
return prisma.user.create({
49+
data: {
50+
email: `cli-pat-revocation-${userCounter}-${Date.now()}@example.com`,
51+
authenticationMethod: "MAGIC_LINK",
52+
},
53+
});
54+
}
55+
56+
/** Runs the consent-screen mint the CLI login flow triggers, and returns what it minted. */
57+
async function cliLogin(userId: string) {
58+
const code = await createAuthorizationCode();
59+
const minted = await createPersonalAccessTokenFromAuthorizationCode(code.code, userId);
60+
return { code: code.code, minted };
61+
}
62+
63+
function secretOf(minted: unknown): string {
64+
return (minted as { token: string }).token;
65+
}
66+
67+
describe("CLI personal access token revocation", () => {
68+
beforeEach(() => {
69+
db.client = null;
70+
});
71+
72+
postgresTest("a later login reuses a live CLI token", async ({ prisma }) => {
73+
db.client = prisma;
74+
const user = await createUser(prisma);
75+
76+
const first = await cliLogin(user.id);
77+
const second = await cliLogin(user.id);
78+
79+
expect(second.minted.id).toBe(first.minted.id);
80+
expect(await prisma.personalAccessToken.count({ where: { userId: user.id } })).toBe(1);
81+
});
82+
83+
postgresTest(
84+
"a revoked CLI token is not revived by a later login, and its secret stays rejected",
85+
async ({ prisma }) => {
86+
db.client = prisma;
87+
const user = await createUser(prisma);
88+
89+
const first = await cliLogin(user.id);
90+
const revokedSecret = secretOf(first.minted);
91+
expect(await authenticatePersonalAccessToken(revokedSecret)).toMatchObject({
92+
userId: user.id,
93+
});
94+
95+
await revokePersonalAccessToken(first.minted.id, user.id);
96+
97+
const second = await cliLogin(user.id);
98+
const freshSecret = secretOf(second.minted);
99+
100+
expect(second.minted.id).not.toBe(first.minted.id);
101+
expect(freshSecret).not.toBe(revokedSecret);
102+
103+
const revokedRow = await prisma.personalAccessToken.findFirst({
104+
where: { id: first.minted.id },
105+
});
106+
expect(revokedRow?.revokedAt).toBeInstanceOf(Date);
107+
expect(revokedRow?.name).toBe("cli");
108+
109+
expect(await authenticatePersonalAccessToken(revokedSecret)).toBeUndefined();
110+
expect(await authenticatePersonalAccessToken(freshSecret)).toMatchObject({
111+
userId: user.id,
112+
});
113+
}
114+
);
115+
116+
postgresTest("revoking again after re-login leaves both secrets dead", async ({ prisma }) => {
117+
db.client = prisma;
118+
const user = await createUser(prisma);
119+
120+
const first = await cliLogin(user.id);
121+
await revokePersonalAccessToken(first.minted.id, user.id);
122+
123+
const second = await cliLogin(user.id);
124+
await revokePersonalAccessToken(second.minted.id, user.id);
125+
126+
const third = await cliLogin(user.id);
127+
128+
expect(third.minted.id).not.toBe(first.minted.id);
129+
expect(third.minted.id).not.toBe(second.minted.id);
130+
expect(await authenticatePersonalAccessToken(secretOf(first.minted))).toBeUndefined();
131+
expect(await authenticatePersonalAccessToken(secretOf(second.minted))).toBeUndefined();
132+
});
133+
134+
postgresTest(
135+
"a token revoked under a different name does not block a fresh CLI login",
136+
async ({ prisma }) => {
137+
db.client = prisma;
138+
const user = await createUser(prisma);
139+
140+
const dashboardToken = await createPersonalAccessToken({ name: "laptop", userId: user.id });
141+
await revokePersonalAccessToken(dashboardToken.id, user.id);
142+
143+
const login = await cliLogin(user.id);
144+
145+
expect(login.minted.id).not.toBe(dashboardToken.id);
146+
expect(await authenticatePersonalAccessToken(dashboardToken.token)).toBeUndefined();
147+
}
148+
);
149+
});
150+
151+
describe("the authorization code token endpoint", () => {
152+
beforeEach(() => {
153+
db.client = null;
154+
});
155+
156+
postgresTest("discloses the token it minted", async ({ prisma }) => {
157+
db.client = prisma;
158+
const user = await createUser(prisma);
159+
160+
const login = await cliLogin(user.id);
161+
162+
const disclosed = await getPersonalAccessTokenFromAuthorizationCode(login.code);
163+
expect(disclosed.token?.token).toBe(secretOf(login.minted));
164+
});
165+
166+
postgresTest(
167+
"stops disclosing a token that was revoked after the code was bound",
168+
async ({ prisma }) => {
169+
db.client = prisma;
170+
const user = await createUser(prisma);
171+
172+
const login = await cliLogin(user.id);
173+
await revokePersonalAccessToken(login.minted.id, user.id);
174+
175+
await expect(getPersonalAccessTokenFromAuthorizationCode(login.code)).rejects.toThrow(
176+
"Invalid authorization code, or code expired"
177+
);
178+
}
179+
);
180+
181+
postgresTest("discloses the live token a later login reused", async ({ prisma }) => {
182+
db.client = prisma;
183+
const user = await createUser(prisma);
184+
185+
const first = await cliLogin(user.id);
186+
const second = await cliLogin(user.id);
187+
188+
const disclosed = await getPersonalAccessTokenFromAuthorizationCode(second.code);
189+
expect(disclosed.token?.token).toBe(secretOf(first.minted));
190+
});
191+
192+
postgresTest("reports an unapproved code as not ready yet", async ({ prisma }) => {
193+
db.client = prisma;
194+
195+
const code = await createAuthorizationCode();
196+
197+
const disclosed = await getPersonalAccessTokenFromAuthorizationCode(code.code);
198+
expect(disclosed.token).toBeNull();
199+
});
200+
});

‎apps/webapp/test/dashboardAgentRoutes.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ vi.mock("~/services/rbac.server", () => ({
6262
authenticateUserActor: async () => ({
6363
ok: true,
6464
userId: "usr_1",
65+
claims: { userId: "usr_1" },
6566
ability: { can: () => true, canSuper: () => true },
6667
}),
6768
authenticatePat: async () => ({

0 commit comments

Comments
 (0)