176 lines
4.7 KiB
TypeScript
176 lines
4.7 KiB
TypeScript
import { customAlphabet } from "nanoid";
|
|
import { z } from "zod";
|
|
import { prisma } from "~/db.server";
|
|
import { logger } from "./logger.server";
|
|
import { hashToken } from "~/utils/tokens.server";
|
|
|
|
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
|
|
// settings UI reads this field at human granularity so a few-minute
|
|
// staleness is fine.
|
|
export const OAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
|
|
|
|
type CreateOrganizationAccessTokenOptions = {
|
|
name: string;
|
|
organizationId: string;
|
|
expiresAt?: Date;
|
|
};
|
|
|
|
export async function getValidOrganizationAccessTokens(organizationId: string) {
|
|
const organizationAccessTokens = await prisma.organizationAccessToken.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
createdAt: true,
|
|
lastAccessedAt: true,
|
|
expiresAt: true,
|
|
},
|
|
where: {
|
|
organizationId,
|
|
revokedAt: null,
|
|
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
|
|
},
|
|
});
|
|
|
|
return organizationAccessTokens.map((oat) => ({
|
|
id: oat.id,
|
|
name: oat.name,
|
|
createdAt: oat.createdAt,
|
|
lastAccessedAt: oat.lastAccessedAt,
|
|
expiresAt: oat.expiresAt,
|
|
}));
|
|
}
|
|
|
|
export type ObfuscatedOrganizationAccessToken = Awaited<
|
|
ReturnType<typeof getValidOrganizationAccessTokens>
|
|
>[number];
|
|
|
|
export async function revokeOrganizationAccessToken(tokenId: string) {
|
|
await prisma.organizationAccessToken.update({
|
|
where: {
|
|
id: tokenId,
|
|
},
|
|
data: {
|
|
revokedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
export type OrganizationAccessTokenAuthenticationResult = {
|
|
organizationId: string;
|
|
};
|
|
|
|
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
|
|
|
|
export async function authenticateApiRequestWithOrganizationAccessToken(
|
|
request: Request
|
|
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
|
|
const token = getOrganizationAccessTokenFromRequest(request);
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
return authenticateOrganizationAccessToken(token);
|
|
}
|
|
|
|
function getOrganizationAccessTokenFromRequest(request: Request) {
|
|
const rawAuthorization = request.headers.get("Authorization");
|
|
|
|
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
|
|
if (!authorization.success) {
|
|
return;
|
|
}
|
|
|
|
const organizationAccessToken = authorization.data.replace(/^Bearer /, "");
|
|
return organizationAccessToken;
|
|
}
|
|
|
|
export async function authenticateOrganizationAccessToken(
|
|
token: string
|
|
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
|
|
if (!token.startsWith(tokenPrefix)) {
|
|
logger.warn(`OAT doesn't start with ${tokenPrefix}`);
|
|
return;
|
|
}
|
|
|
|
const hashedToken = hashToken(token);
|
|
|
|
const organizationAccessToken = await prisma.organizationAccessToken.findFirst({
|
|
where: {
|
|
hashedToken,
|
|
revokedAt: null,
|
|
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
|
|
},
|
|
});
|
|
|
|
if (!organizationAccessToken) {
|
|
return;
|
|
}
|
|
|
|
// Conditional updateMany — only writes if the existing lastAccessedAt is
|
|
// null or older than the throttle window. The WHERE runs inside the UPDATE
|
|
// so concurrent auths don't race into a double-write. `revokedAt: null`
|
|
// matches the findFirst guard above so a token revoked between the read
|
|
// and write doesn't get a stale lastAccessedAt update.
|
|
await prisma.organizationAccessToken.updateMany({
|
|
where: {
|
|
id: organizationAccessToken.id,
|
|
revokedAt: null,
|
|
OR: [
|
|
{ lastAccessedAt: null },
|
|
{ lastAccessedAt: { lt: new Date(Date.now() - OAT_LAST_ACCESSED_THROTTLE_MS) } },
|
|
],
|
|
},
|
|
data: {
|
|
lastAccessedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
organizationId: organizationAccessToken.organizationId,
|
|
};
|
|
}
|
|
|
|
export function isOrganizationAccessToken(token: string) {
|
|
return token.startsWith(tokenPrefix);
|
|
}
|
|
|
|
export async function createOrganizationAccessToken({
|
|
name,
|
|
organizationId,
|
|
expiresAt,
|
|
}: CreateOrganizationAccessTokenOptions) {
|
|
const token = createToken();
|
|
|
|
const organizationAccessToken = await prisma.organizationAccessToken.create({
|
|
data: {
|
|
name,
|
|
organizationId,
|
|
hashedToken: hashToken(token),
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: organizationAccessToken.id,
|
|
name,
|
|
organizationId,
|
|
token,
|
|
expiresAt: organizationAccessToken.expiresAt,
|
|
};
|
|
}
|
|
|
|
export type CreatedOrganizationAccessToken = Awaited<
|
|
ReturnType<typeof createOrganizationAccessToken>
|
|
>;
|
|
|
|
const tokenPrefix = "tr_oat_";
|
|
|
|
function createToken() {
|
|
return `${tokenPrefix}${tokenGenerator()}`;
|
|
}
|