Skip to content

Commit 622b185

Browse files
committed
Add helpers for creating and authenticating OATs
1 parent db2d4b8 commit 622b185

File tree

4 files changed

+274
-42
lines changed

4 files changed

+274
-42
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { type OrganizationAccessToken } from "@trigger.dev/database";
2+
import { customAlphabet } from "nanoid";
3+
import { z } from "zod";
4+
import { prisma } from "~/db.server";
5+
import { logger } from "./logger.server";
6+
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens";
7+
8+
const tokenValueLength = 40;
9+
//lowercase only, removed 0 and l to avoid confusion
10+
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
11+
12+
type CreateOrganizationAccessTokenOptions = {
13+
name: string;
14+
organizationId: string;
15+
expiresAt?: Date;
16+
};
17+
18+
export async function getValidOrganizationAccessTokens(organizationId: string) {
19+
const organizationAccessTokens = await prisma.organizationAccessToken.findMany({
20+
select: {
21+
id: true,
22+
name: true,
23+
obfuscatedToken: true,
24+
createdAt: true,
25+
lastAccessedAt: true,
26+
expiresAt: true,
27+
},
28+
where: {
29+
organizationId,
30+
revokedAt: null,
31+
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
32+
},
33+
});
34+
35+
return organizationAccessTokens.map((oat) => ({
36+
id: oat.id,
37+
name: oat.name,
38+
obfuscatedToken: oat.obfuscatedToken,
39+
createdAt: oat.createdAt,
40+
lastAccessedAt: oat.lastAccessedAt,
41+
expiresAt: oat.expiresAt,
42+
}));
43+
}
44+
45+
export type ObfuscatedOrganizationAccessToken = Awaited<
46+
ReturnType<typeof getValidOrganizationAccessTokens>
47+
>[number];
48+
49+
export async function revokeOrganizationAccessToken(tokenId: string) {
50+
await prisma.organizationAccessToken.update({
51+
where: {
52+
id: tokenId,
53+
},
54+
data: {
55+
revokedAt: new Date(),
56+
},
57+
});
58+
}
59+
60+
export type OrganizationAccessTokenAuthenticationResult = {
61+
organizationId: string;
62+
};
63+
64+
const EncryptedSecretValueSchema = z.object({
65+
nonce: z.string(),
66+
ciphertext: z.string(),
67+
tag: z.string(),
68+
});
69+
70+
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
71+
72+
export async function authenticateApiRequestWithOrganizationAccessToken(
73+
request: Request
74+
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
75+
const token = getOrganizationAccessTokenFromRequest(request);
76+
if (!token) {
77+
return;
78+
}
79+
80+
return authenticateOrganizationAccessToken(token);
81+
}
82+
83+
function getOrganizationAccessTokenFromRequest(request: Request) {
84+
const rawAuthorization = request.headers.get("Authorization");
85+
86+
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
87+
if (!authorization.success) {
88+
return;
89+
}
90+
91+
const organizationAccessToken = authorization.data.replace(/^Bearer /, "");
92+
return organizationAccessToken;
93+
}
94+
95+
export async function authenticateOrganizationAccessToken(
96+
token: string
97+
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
98+
if (!token.startsWith(tokenPrefix)) {
99+
logger.warn(`OAT doesn't start with ${tokenPrefix}`);
100+
return;
101+
}
102+
103+
const hashedToken = hashToken(token);
104+
105+
const organizationAccessToken = await prisma.organizationAccessToken.findFirst({
106+
where: {
107+
hashedToken,
108+
revokedAt: null,
109+
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
110+
},
111+
});
112+
113+
if (!organizationAccessToken) {
114+
return;
115+
}
116+
117+
await prisma.organizationAccessToken.update({
118+
where: {
119+
id: organizationAccessToken.id,
120+
},
121+
data: {
122+
lastAccessedAt: new Date(),
123+
},
124+
});
125+
126+
const decryptedToken = decryptOrganizationAccessToken(organizationAccessToken);
127+
128+
if (decryptedToken !== token) {
129+
logger.error(
130+
`OrganizationAccessToken with id: ${organizationAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
131+
);
132+
return;
133+
}
134+
135+
return {
136+
organizationId: organizationAccessToken.organizationId,
137+
};
138+
}
139+
140+
export function isOrganizationAccessToken(token: string) {
141+
return token.startsWith(tokenPrefix);
142+
}
143+
144+
export async function createOrganizationAccessToken({
145+
name,
146+
organizationId,
147+
expiresAt,
148+
}: CreateOrganizationAccessTokenOptions) {
149+
const token = createToken();
150+
const encryptedToken = encryptToken(token);
151+
152+
const organizationAccessToken = await prisma.organizationAccessToken.create({
153+
data: {
154+
name,
155+
organizationId,
156+
encryptedToken,
157+
obfuscatedToken: obfuscateToken(token),
158+
hashedToken: hashToken(token),
159+
expiresAt,
160+
},
161+
});
162+
163+
return {
164+
id: organizationAccessToken.id,
165+
name,
166+
organizationId,
167+
token,
168+
obfuscatedToken: organizationAccessToken.obfuscatedToken,
169+
expiresAt: organizationAccessToken.expiresAt,
170+
};
171+
}
172+
173+
export type CreatedOrganizationAccessToken = Awaited<
174+
ReturnType<typeof createOrganizationAccessToken>
175+
>;
176+
177+
const tokenPrefix = "tr_oat_";
178+
179+
function createToken() {
180+
return `${tokenPrefix}${tokenGenerator()}`;
181+
}
182+
183+
function obfuscateToken(token: string) {
184+
const withoutPrefix = token.replace(tokenPrefix, "");
185+
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
186+
return `${tokenPrefix}${obfuscated}`;
187+
}
188+
189+
function decryptOrganizationAccessToken(organizationAccessToken: OrganizationAccessToken) {
190+
const encryptedData = EncryptedSecretValueSchema.safeParse(
191+
organizationAccessToken.encryptedToken
192+
);
193+
if (!encryptedData.success) {
194+
throw new Error(
195+
`Unable to parse encrypted OrganizationAccessToken with id: ${organizationAccessToken.id}: ${encryptedData.error.message}`
196+
);
197+
}
198+
199+
const decryptedToken = decryptToken(
200+
encryptedData.data.nonce,
201+
encryptedData.data.ciphertext,
202+
encryptedData.data.tag
203+
);
204+
return decryptedToken;
205+
}

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

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { PersonalAccessToken } from "@trigger.dev/database";
1+
import { type PersonalAccessToken } from "@trigger.dev/database";
22
import { customAlphabet, nanoid } from "nanoid";
3-
import nodeCrypto from "node:crypto";
43
import { z } from "zod";
54
import { prisma } from "~/db.server";
6-
import { env } from "~/env.server";
75
import { logger } from "./logger.server";
6+
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens";
87

98
const tokenValueLength = 40;
109
//lowercase only, removed 0 and l to avoid confusion
@@ -303,22 +302,6 @@ function obfuscateToken(token: string) {
303302
return `${tokenPrefix}${obfuscated}`;
304303
}
305304

306-
function encryptToken(value: string) {
307-
const nonce = nodeCrypto.randomBytes(12);
308-
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
309-
310-
let encrypted = cipher.update(value, "utf8", "hex");
311-
encrypted += cipher.final("hex");
312-
313-
const tag = cipher.getAuthTag().toString("hex");
314-
315-
return {
316-
nonce: nonce.toString("hex"),
317-
ciphertext: encrypted,
318-
tag,
319-
};
320-
}
321-
322305
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
323306
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
324307
if (!encryptedData.success) {
@@ -334,24 +317,3 @@ function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
334317
);
335318
return decryptedToken;
336319
}
337-
338-
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
339-
const decipher = nodeCrypto.createDecipheriv(
340-
"aes-256-gcm",
341-
env.ENCRYPTION_KEY,
342-
Buffer.from(nonce, "hex")
343-
);
344-
345-
decipher.setAuthTag(Buffer.from(tag, "hex"));
346-
347-
let decrypted = decipher.update(ciphertext, "hex", "utf8");
348-
decrypted += decipher.final("utf8");
349-
350-
return decrypted;
351-
}
352-
353-
function hashToken(token: string): string {
354-
const hash = nodeCrypto.createHash("sha256");
355-
hash.update(token);
356-
return hash.digest("hex");
357-
}

apps/webapp/app/utils/tokens.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import nodeCrypto from "node:crypto";
2+
import { env } from "~/env.server";
3+
4+
export function encryptToken(value: string) {
5+
const nonce = nodeCrypto.randomBytes(12);
6+
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
7+
8+
let encrypted = cipher.update(value, "utf8", "hex");
9+
encrypted += cipher.final("hex");
10+
11+
const tag = cipher.getAuthTag().toString("hex");
12+
13+
return {
14+
nonce: nonce.toString("hex"),
15+
ciphertext: encrypted,
16+
tag,
17+
};
18+
}
19+
20+
export function decryptToken(nonce: string, ciphertext: string, tag: string): string {
21+
const decipher = nodeCrypto.createDecipheriv(
22+
"aes-256-gcm",
23+
env.ENCRYPTION_KEY,
24+
Buffer.from(nonce, "hex")
25+
);
26+
27+
decipher.setAuthTag(Buffer.from(tag, "hex"));
28+
29+
let decrypted = decipher.update(ciphertext, "hex", "utf8");
30+
decrypted += decipher.final("utf8");
31+
32+
return decrypted;
33+
}
34+
35+
export function hashToken(token: string): string {
36+
const hash = nodeCrypto.createHash("sha256");
37+
hash.update(token);
38+
return hash.digest("hex");
39+
}
Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1-
const tokenPrefix = "tr_pat_";
1+
const personalTokenPrefix = "tr_pat_";
2+
const organizationTokenPrefix = "tr_oat_";
23

34
export function isPersonalAccessToken(token: string) {
4-
return token.startsWith(tokenPrefix);
5+
return token.startsWith(personalTokenPrefix);
6+
}
7+
8+
export function isOrganizationAccessToken(token: string) {
9+
return token.startsWith(organizationTokenPrefix);
10+
}
11+
12+
export function validateAccessToken(
13+
token: string
14+
): { success: true; type: "personal" | "organization" } | { success: false } {
15+
if (isPersonalAccessToken(token)) {
16+
return { success: true, type: "personal" };
17+
}
18+
19+
if (isOrganizationAccessToken(token)) {
20+
return { success: true, type: "organization" };
21+
}
22+
23+
return { success: false };
524
}
625

726
export class NotPersonalAccessTokenError extends Error {
@@ -10,3 +29,10 @@ export class NotPersonalAccessTokenError extends Error {
1029
this.name = "NotPersonalAccessTokenError";
1130
}
1231
}
32+
33+
export class NotAccessTokenError extends Error {
34+
constructor(message: string) {
35+
super(message);
36+
this.name = "NotAccessTokenError";
37+
}
38+
}

0 commit comments

Comments
 (0)