468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
import { type PersonalAccessToken, type User } from "@trigger.dev/database";
|
|
import { customAlphabet, nanoid } from "nanoid";
|
|
import { z } from "zod";
|
|
import { prisma } from "~/db.server";
|
|
import { logger } from "./logger.server";
|
|
import { rbac } from "./rbac.server";
|
|
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
|
|
import { env } from "~/env.server";
|
|
import { isUserActorToken } from "@trigger.dev/rbac";
|
|
|
|
const tokenValueLength = 40;
|
|
//lowercase only, removed 0 and l to avoid confusion
|
|
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
|
|
|
// Skip the lastAccessedAt write if the existing value is already within this
|
|
// window. Eliminates per-auth UPDATE churn on a small narrow hot table; the
|
|
// /account/tokens UI reads this field at human granularity so a few-minute
|
|
// staleness is fine.
|
|
export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
|
|
|
|
type CreatePersonalAccessTokenOptions = {
|
|
name: string;
|
|
userId: string;
|
|
// Optional: when provided, persist a TokenRole row alongside the PAT
|
|
// so PAT-authenticated requests pick up that role's permissions
|
|
// (TRI-8749). The dashboard tokens page passes a chosen system role;
|
|
// the CLI auth-code path doesn't pass one (legacy behaviour
|
|
// preserved — those PATs run with no explicit role).
|
|
roleId?: string;
|
|
};
|
|
|
|
/** Returns obfuscated access tokens that aren't revoked */
|
|
export async function getValidPersonalAccessTokens(userId: string) {
|
|
const personalAccessTokens = await prisma.personalAccessToken.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
obfuscatedToken: true,
|
|
createdAt: true,
|
|
lastAccessedAt: true,
|
|
},
|
|
where: {
|
|
userId,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
|
|
return personalAccessTokens.map((pat) => ({
|
|
id: pat.id,
|
|
name: pat.name,
|
|
obfuscatedToken: pat.obfuscatedToken,
|
|
createdAt: pat.createdAt,
|
|
lastAccessedAt: pat.lastAccessedAt,
|
|
}));
|
|
}
|
|
|
|
export type ObfuscatedPersonalAccessToken = Awaited<
|
|
ReturnType<typeof getValidPersonalAccessTokens>
|
|
>[number];
|
|
|
|
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
|
|
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
|
|
//only allow authorization codes that were created less than 10 mins ago
|
|
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
|
const code = await prisma.authorizationCode.findUnique({
|
|
select: {
|
|
personalAccessToken: true,
|
|
},
|
|
where: {
|
|
code: authorizationCode,
|
|
createdAt: {
|
|
gte: tenMinutesAgo,
|
|
},
|
|
},
|
|
});
|
|
if (!code) {
|
|
throw new Error("Invalid authorization code, or code expired");
|
|
}
|
|
|
|
//there's no PersonalAccessToken associated with this code
|
|
if (!code.personalAccessToken) {
|
|
return {
|
|
token: null,
|
|
};
|
|
}
|
|
|
|
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
|
|
return {
|
|
token: {
|
|
token: decryptedToken,
|
|
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function revokePersonalAccessToken(tokenId: string, userId: string) {
|
|
const result = await prisma.personalAccessToken.updateMany({
|
|
where: {
|
|
id: tokenId,
|
|
userId,
|
|
},
|
|
data: {
|
|
revokedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
if (result.count === 0) {
|
|
throw new Error("PAT not found or already revoked");
|
|
}
|
|
}
|
|
|
|
export type PersonalAccessTokenAuthenticationResult = {
|
|
userId: string;
|
|
};
|
|
|
|
/**
|
|
* Smart-skip the `lastAccessedAt` write when the cached value is already
|
|
* within the throttle window. Saves one DB roundtrip per "fresh" auth.
|
|
*
|
|
* Two layers of throttling: JS-side (`Date.now() - lastAccessedAt.getTime()`)
|
|
* elides the SQL entirely when the caller already has a recent timestamp;
|
|
* SQL-side `WHERE` clause inside the `updateMany` guards against concurrent
|
|
* auths racing to a double-write when the JS check decides to fire.
|
|
*
|
|
* Called from both the legacy PAT flow (`authenticatePersonalAccessToken`)
|
|
* and the apiBuilder's RBAC-routed PAT flow (which gets `lastAccessedAt`
|
|
* from `rbac.authenticatePat`'s result). Keeps the throttle policy in one
|
|
* place rather than expecting every plugin implementer to re-implement it.
|
|
*/
|
|
export async function updateLastAccessedAtIfStale(
|
|
tokenId: string,
|
|
lastAccessedAt: Date | null
|
|
): Promise<void> {
|
|
if (lastAccessedAt && Date.now() - lastAccessedAt.getTime() <= PAT_LAST_ACCESSED_THROTTLE_MS) {
|
|
return; // fresh — no roundtrip
|
|
}
|
|
await prisma.personalAccessToken.updateMany({
|
|
where: {
|
|
id: tokenId,
|
|
revokedAt: null,
|
|
OR: [
|
|
{ lastAccessedAt: null },
|
|
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
|
|
],
|
|
},
|
|
data: { lastAccessedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
const EncryptedSecretValueSchema = z.object({
|
|
nonce: z.string(),
|
|
ciphertext: z.string(),
|
|
tag: z.string(),
|
|
});
|
|
|
|
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
|
|
|
|
export async function authenticateApiRequestWithPersonalAccessToken(
|
|
request: Request
|
|
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
|
|
const token = getPersonalAccessTokenFromRequest(request);
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
// A user-actor token authenticates as the user wherever a PAT does.
|
|
// The plugin verifies it (identity path → no org context to floor against).
|
|
if (isUserActorToken(token)) {
|
|
const result = await rbac.authenticateUserActor(request, {});
|
|
return result.ok ? { userId: result.userId } : undefined;
|
|
}
|
|
|
|
return authenticatePersonalAccessToken(token);
|
|
}
|
|
|
|
export type AdminAuthenticationResult =
|
|
| { ok: true; user: User }
|
|
| { ok: false; status: 401 | 403; message: string };
|
|
|
|
/**
|
|
* Authenticates a request via personal access token and checks the user is
|
|
* an admin. Returns a discriminated result so callers can shape the failure
|
|
* (throw a Response, wrap in neverthrow, return JSON, etc.) to fit their
|
|
* context. See `requireAdminApiRequest` for the Remix loader/action wrapper.
|
|
*/
|
|
export async function authenticateAdminRequest(
|
|
request: Request
|
|
): Promise<AdminAuthenticationResult> {
|
|
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
|
|
|
if (!authResult) {
|
|
return { ok: false, status: 401, message: "Invalid or Missing API key" };
|
|
}
|
|
|
|
const user = await prisma.user.findFirst({
|
|
where: { id: authResult.userId },
|
|
});
|
|
|
|
if (!user) {
|
|
return { ok: false, status: 401, message: "Invalid or Missing API key" };
|
|
}
|
|
|
|
if (!user.admin) {
|
|
return { ok: false, status: 403, message: "You must be an admin to perform this action" };
|
|
}
|
|
|
|
return { ok: true, user };
|
|
}
|
|
|
|
/**
|
|
* Remix loader/action wrapper around `authenticateAdminRequest` that throws
|
|
* a Response on failure so routes can `await` without handling the error
|
|
* branch. Uses `new Response` directly to avoid coupling this module to
|
|
* `@remix-run/server-runtime`.
|
|
*/
|
|
export async function requireAdminApiRequest(request: Request): Promise<User> {
|
|
const result = await authenticateAdminRequest(request);
|
|
|
|
if (!result.ok) {
|
|
throw new Response(JSON.stringify({ error: result.message }), {
|
|
status: result.status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
return result.user;
|
|
}
|
|
|
|
function getPersonalAccessTokenFromRequest(request: Request) {
|
|
const rawAuthorization = request.headers.get("Authorization");
|
|
|
|
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
|
|
if (!authorization.success) {
|
|
return;
|
|
}
|
|
|
|
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
|
|
return personalAccessToken;
|
|
}
|
|
|
|
export async function authenticatePersonalAccessToken(
|
|
token: string
|
|
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
|
|
if (!token.startsWith(tokenPrefix)) {
|
|
logger.warn(`PAT doesn't start with ${tokenPrefix}`);
|
|
return;
|
|
}
|
|
|
|
const hashedToken = hashToken(token);
|
|
|
|
const personalAccessToken = await prisma.personalAccessToken.findFirst({
|
|
where: {
|
|
hashedToken,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
|
|
if (!personalAccessToken) {
|
|
// The token may have been revoked or is entirely invalid
|
|
return;
|
|
}
|
|
|
|
await updateLastAccessedAtIfStale(personalAccessToken.id, personalAccessToken.lastAccessedAt);
|
|
|
|
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
|
|
|
|
if (decryptedToken !== token) {
|
|
logger.error(
|
|
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
return {
|
|
userId: personalAccessToken.userId,
|
|
};
|
|
}
|
|
|
|
export function isPersonalAccessToken(token: string) {
|
|
return token.startsWith(tokenPrefix);
|
|
}
|
|
|
|
export function createAuthorizationCode() {
|
|
return prisma.authorizationCode.create({
|
|
data: {
|
|
code: nanoid(64),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
|
|
export async function createPersonalAccessTokenFromAuthorizationCode(
|
|
authorizationCode: string,
|
|
userId: string
|
|
) {
|
|
//only allow authorization codes that were created less than 10 mins ago
|
|
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
|
const code = await prisma.authorizationCode.findUnique({
|
|
where: {
|
|
code: authorizationCode,
|
|
personalAccessTokenId: null,
|
|
createdAt: {
|
|
gte: tenMinutesAgo,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!code) {
|
|
throw new Error("Invalid authorization code, code already used, or code expired");
|
|
}
|
|
|
|
const existingCliPersonalAccessToken = await prisma.personalAccessToken.findFirst({
|
|
where: {
|
|
userId,
|
|
name: "cli",
|
|
},
|
|
});
|
|
|
|
//we only allow you to have one CLI PAT at a time, so return this
|
|
if (existingCliPersonalAccessToken) {
|
|
//associate this authorization code with the existing personal access token
|
|
await prisma.authorizationCode.update({
|
|
where: {
|
|
code: authorizationCode,
|
|
},
|
|
data: {
|
|
personalAccessTokenId: existingCliPersonalAccessToken.id,
|
|
},
|
|
});
|
|
|
|
if (existingCliPersonalAccessToken.revokedAt) {
|
|
// re-activate revoked CLI PAT so we can use it again
|
|
await prisma.personalAccessToken.update({
|
|
where: {
|
|
id: existingCliPersonalAccessToken.id,
|
|
},
|
|
data: {
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
//we don't return the decrypted token
|
|
return {
|
|
id: existingCliPersonalAccessToken.id,
|
|
name: existingCliPersonalAccessToken.name,
|
|
userId: existingCliPersonalAccessToken.userId,
|
|
obfuscateToken: existingCliPersonalAccessToken.obfuscatedToken,
|
|
};
|
|
}
|
|
|
|
const token = await createPersonalAccessToken({
|
|
name: "cli",
|
|
userId,
|
|
});
|
|
|
|
await prisma.authorizationCode.update({
|
|
where: {
|
|
code: authorizationCode,
|
|
},
|
|
data: {
|
|
personalAccessTokenId: token.id,
|
|
},
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
|
|
export async function createPersonalAccessToken({
|
|
name,
|
|
userId,
|
|
roleId,
|
|
}: CreatePersonalAccessTokenOptions) {
|
|
const token = createToken();
|
|
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
|
|
|
|
const personalAccessToken = await prisma.personalAccessToken.create({
|
|
data: {
|
|
name,
|
|
userId,
|
|
encryptedToken,
|
|
obfuscatedToken: obfuscateToken(token),
|
|
hashedToken: hashToken(token),
|
|
},
|
|
});
|
|
|
|
// Persist the role choice via the RBAC plugin's setTokenRole. The
|
|
// plugin may store this in a separate datastore from Prisma (e.g.
|
|
// Drizzle on a different schema), so co-transactional inserts are
|
|
// awkward — we use a compensating-delete pattern instead: if
|
|
// setTokenRole fails, roll back the PAT row by deleting it. The auth
|
|
// path treats "no role" as permissive (matches the default fallback)
|
|
// so a brief orphan window between the two writes is harmless. The
|
|
// compensating delete narrows that window from "until manual cleanup"
|
|
// to "until the request returns".
|
|
//
|
|
// Skip the call entirely when no RBAC plugin is loaded — the OSS
|
|
// fallback has no TokenRole table to write to. Gating on
|
|
// `rbac.isUsingPlugin()` (rather than parsing the fallback's error
|
|
// string) keeps the OSS-vs-cloud branch explicit and decoupled from
|
|
// any specific error message.
|
|
if (roleId && (await rbac.isUsingPlugin())) {
|
|
const roleResult = await rbac.setTokenRole({
|
|
tokenId: personalAccessToken.id,
|
|
roleId,
|
|
});
|
|
if (!roleResult.ok) {
|
|
await prisma.personalAccessToken
|
|
.delete({ where: { id: personalAccessToken.id } })
|
|
.catch((err) => {
|
|
logger.error("Failed to compensating-delete PAT after TokenRole insert failed", {
|
|
patId: personalAccessToken.id,
|
|
roleResultError: roleResult.error,
|
|
deleteError: err instanceof Error ? err.message : String(err),
|
|
});
|
|
});
|
|
throw new Error(`Failed to assign role to access token: ${roleResult.error}`);
|
|
}
|
|
} else if (roleId) {
|
|
logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
|
|
patId: personalAccessToken.id,
|
|
userId,
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: personalAccessToken.id,
|
|
name,
|
|
userId,
|
|
token,
|
|
obfuscatedToken: personalAccessToken.obfuscatedToken,
|
|
};
|
|
}
|
|
|
|
export type CreatedPersonalAccessToken = Awaited<ReturnType<typeof createPersonalAccessToken>>;
|
|
|
|
const tokenPrefix = "tr_pat_";
|
|
|
|
/** Creates a PersonalAccessToken that starts with tr_pat_ */
|
|
function createToken() {
|
|
return `${tokenPrefix}${tokenGenerator()}`;
|
|
}
|
|
|
|
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
|
|
function obfuscateToken(token: string) {
|
|
const withoutPrefix = token.replace(tokenPrefix, "");
|
|
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
|
|
return `${tokenPrefix}${obfuscated}`;
|
|
}
|
|
|
|
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
|
|
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
|
|
if (!encryptedData.success) {
|
|
throw new Error(
|
|
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`
|
|
);
|
|
}
|
|
|
|
const decryptedToken = decryptToken(
|
|
encryptedData.data.nonce,
|
|
encryptedData.data.ciphertext,
|
|
encryptedData.data.tag,
|
|
env.ENCRYPTION_KEY
|
|
);
|
|
return decryptedToken;
|
|
}
|