Skip to content

Commit e4884a7

Browse files
committed
fix: store encrypted tokens
1 parent 41ad6ef commit e4884a7

File tree

5 files changed

+395
-11
lines changed

5 files changed

+395
-11
lines changed

apps/api/src/prisma.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
11
import { PrismaClient } from "@prisma/client";
22
import dotenv from "dotenv";
3+
import {
4+
encryptAccountTokens,
5+
decryptAccountTokens,
6+
} from "./utils/encryption.js";
37

48
dotenv.config();
59

6-
const prisma = new PrismaClient({
10+
const basePrisma = new PrismaClient({
711
datasources: {
812
db: {
913
url: process.env.DATABASE_URL!,
1014
},
1115
},
12-
log: ['error', 'warn'],
16+
log: ["error", "warn"],
17+
});
18+
19+
/**
20+
* Prisma Client Extension for automatic token encryption/decryption
21+
* Encrypts sensitive OAuth tokens before storing and decrypts when reading
22+
*/
23+
const prisma = basePrisma.$extends({
24+
query: {
25+
account: {
26+
async create({ args, query }) {
27+
args.data = encryptAccountTokens(args.data);
28+
const result = await query(args);
29+
return decryptAccountTokens(result);
30+
},
31+
async update({ args, query }) {
32+
args.data = encryptAccountTokens(args.data);
33+
const result = await query(args);
34+
return decryptAccountTokens(result);
35+
},
36+
async upsert({ args, query }) {
37+
args.create = encryptAccountTokens(args.create);
38+
args.update = encryptAccountTokens(args.update);
39+
const result = await query(args);
40+
return decryptAccountTokens(result);
41+
},
42+
async findUnique({ args, query }) {
43+
const result = await query(args);
44+
return decryptAccountTokens(result);
45+
},
46+
async findFirst({ args, query }) {
47+
const result = await query(args);
48+
return decryptAccountTokens(result);
49+
},
50+
async findMany({ args, query }) {
51+
const result = await query(args);
52+
return result?.map((account: any) => decryptAccountTokens(account));
53+
},
54+
},
55+
user: {
56+
// Decrypt nested accounts in user queries
57+
async findUnique({ args, query }) {
58+
const result = await query(args);
59+
if (result?.accounts) {
60+
result.accounts = Array.isArray(result.accounts)
61+
? result.accounts.map((account: any) =>
62+
decryptAccountTokens(account)
63+
)
64+
: decryptAccountTokens(result.accounts);
65+
}
66+
return result;
67+
},
68+
async findFirst({ args, query }) {
69+
const result = await query(args);
70+
if (result?.accounts) {
71+
result.accounts = Array.isArray(result.accounts)
72+
? result.accounts.map((account: any) =>
73+
decryptAccountTokens(account)
74+
)
75+
: decryptAccountTokens(result.accounts);
76+
}
77+
return result;
78+
},
79+
async findMany({ args, query }) {
80+
const result = await query(args);
81+
return result?.map((user: any) => {
82+
if (user?.accounts) {
83+
user.accounts = user.accounts.map((account: any) =>
84+
decryptAccountTokens(account)
85+
);
86+
}
87+
return user;
88+
});
89+
},
90+
},
91+
},
1392
});
1493

1594
const withTimeout = async <T>(
1695
operation: Promise<T>,
1796
timeoutMs: number = 5000
1897
): Promise<T> => {
1998
const timeoutPromise = new Promise<never>((_, reject) => {
20-
setTimeout(() => reject(new Error('Database operation timed out')), timeoutMs);
99+
setTimeout(
100+
() => reject(new Error("Database operation timed out")),
101+
timeoutMs
102+
);
21103
});
22104

23105
return Promise.race([operation, timeoutPromise]);
@@ -33,6 +115,5 @@ async function connectDB() {
33115
}
34116
}
35117

36-
37118
export { withTimeout };
38119
export default { prisma, connectDB };

apps/api/src/routers/auth.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ import { router, publicProcedure, protectedProcedure } from "../trpc.js";
22
import { z } from "zod";
33
import { TRPCError } from "@trpc/server";
44
import { authService } from "../services/auth.service.js";
5+
import { generateToken } from "../utils/auth.js";
56

67
const googleAuthSchema = z.object({
7-
email: z.string().email("Invalid email format"),
8+
email: z.email("Invalid email format"),
89
firstName: z.string().optional(),
910
authMethod: z.string().optional(),
11+
providerAccountId: z.string().optional(),
12+
access_token: z.string().optional(),
13+
refresh_token: z.string().optional(),
14+
id_token: z.string().optional(),
15+
expires_at: z.number().optional(),
16+
token_type: z.string().optional(),
17+
scope: z.string().optional(),
1018
});
1119

1220
export const authRouter = router({
@@ -19,7 +27,35 @@ export const authRouter = router({
1927
firstName: input.firstName,
2028
authMethod: input.authMethod,
2129
};
22-
return await authService.handleGoogleAuth(ctx.db.prisma, authInput);
30+
const authResult = await authService.handleGoogleAuth(
31+
ctx.db.prisma,
32+
authInput
33+
);
34+
35+
// Store OAuth tokens (encrypted automatically) if present
36+
if (input.providerAccountId) {
37+
const oauthInput: any = {
38+
userId: authResult.user.id,
39+
provider: "google",
40+
providerAccountId: input.providerAccountId,
41+
};
42+
43+
// Only include defined values
44+
if (input.access_token) oauthInput.access_token = input.access_token;
45+
if (input.refresh_token)
46+
oauthInput.refresh_token = input.refresh_token;
47+
if (input.id_token) oauthInput.id_token = input.id_token;
48+
if (input.expires_at) oauthInput.expires_at = input.expires_at;
49+
if (input.token_type) oauthInput.token_type = input.token_type;
50+
if (input.scope) oauthInput.scope = input.scope;
51+
52+
await authService.createOrUpdateOAuthAccount(
53+
ctx.db.prisma,
54+
oauthInput
55+
);
56+
}
57+
58+
return authResult;
2359
} catch (error) {
2460
if (process.env.NODE_ENV !== "production") {
2561
console.error("Google auth error:", error);
@@ -35,4 +71,9 @@ export const authRouter = router({
3571
return authService.getSession(ctx.user);
3672
}
3773
),
74+
generateJWT: publicProcedure
75+
.input(z.object({ email: z.string() }))
76+
.mutation(({ input }) => {
77+
return { token: generateToken(input.email) };
78+
}),
3879
});

apps/api/src/services/auth.service.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
import { generateToken } from "../utils/auth.js";
2-
import { PrismaClient } from "@prisma/client";
2+
import type { PrismaClient } from "@prisma/client";
33

44
interface GoogleAuthInput {
55
email: string;
66
firstName?: string | undefined;
77
authMethod?: string | undefined;
88
}
99

10+
interface OAuthAccountInput {
11+
userId: string;
12+
provider: string;
13+
providerAccountId: string;
14+
access_token?: string;
15+
refresh_token?: string;
16+
id_token?: string;
17+
expires_at?: number;
18+
token_type?: string;
19+
scope?: string;
20+
session_state?: string;
21+
}
22+
1023
export const authService = {
1124
/**
1225
* Handle Google authentication
1326
* Creates or updates user and generates JWT token
1427
*/
15-
async handleGoogleAuth(prisma: PrismaClient, input: GoogleAuthInput) {
28+
async handleGoogleAuth(prisma: any, input: GoogleAuthInput) {
1629
const { email, firstName, authMethod } = input;
1730

1831
const user = await prisma.user.upsert({
@@ -36,6 +49,129 @@ export const authService = {
3649
};
3750
},
3851

52+
/**
53+
* Create or update OAuth account with encrypted tokens
54+
* Tokens (refresh_token, access_token, id_token) are automatically encrypted
55+
* by Prisma Client Extension before storage
56+
*/
57+
async createOrUpdateOAuthAccount(prisma: any, input: OAuthAccountInput) {
58+
const {
59+
userId,
60+
provider,
61+
providerAccountId,
62+
access_token,
63+
refresh_token,
64+
id_token,
65+
expires_at,
66+
token_type,
67+
scope,
68+
session_state,
69+
} = input;
70+
71+
// Tokens are automatically encrypted by Prisma Client Extension
72+
// Build update/create objects dynamically to avoid undefined values
73+
const updateData: any = {};
74+
const createData: any = {
75+
userId,
76+
type: "oauth",
77+
provider,
78+
providerAccountId,
79+
};
80+
81+
// Only include defined values to satisfy exactOptionalPropertyTypes
82+
if (access_token !== undefined) {
83+
updateData.access_token = access_token;
84+
createData.access_token = access_token;
85+
}
86+
if (refresh_token !== undefined) {
87+
updateData.refresh_token = refresh_token;
88+
createData.refresh_token = refresh_token;
89+
}
90+
if (id_token !== undefined) {
91+
updateData.id_token = id_token;
92+
createData.id_token = id_token;
93+
}
94+
if (expires_at !== undefined) {
95+
updateData.expires_at = expires_at;
96+
createData.expires_at = expires_at;
97+
}
98+
if (token_type !== undefined) {
99+
updateData.token_type = token_type;
100+
createData.token_type = token_type;
101+
}
102+
if (scope !== undefined) {
103+
updateData.scope = scope;
104+
createData.scope = scope;
105+
}
106+
if (session_state !== undefined) {
107+
updateData.session_state = session_state;
108+
createData.session_state = session_state;
109+
}
110+
111+
const account = await prisma.account.upsert({
112+
where: {
113+
provider_providerAccountId: {
114+
provider,
115+
providerAccountId,
116+
},
117+
},
118+
update: updateData,
119+
create: createData,
120+
});
121+
122+
return account;
123+
},
124+
125+
/**
126+
* Get OAuth account with decrypted tokens
127+
* Tokens are automatically decrypted by Prisma Client Extension when reading
128+
*/
129+
async getOAuthAccount(
130+
prisma: any,
131+
provider: string,
132+
providerAccountId: string
133+
) {
134+
const account = await prisma.account.findUnique({
135+
where: {
136+
provider_providerAccountId: {
137+
provider,
138+
providerAccountId,
139+
},
140+
},
141+
});
142+
143+
return account;
144+
},
145+
146+
/**
147+
* Get all OAuth accounts for a user with decrypted tokens
148+
*/
149+
async getUserOAuthAccounts(prisma: any, userId: string) {
150+
const accounts = await prisma.account.findMany({
151+
where: { userId },
152+
});
153+
154+
return accounts;
155+
},
156+
157+
/**
158+
* Delete OAuth account (e.g., when user disconnects provider)
159+
*/
160+
async deleteOAuthAccount(
161+
prisma: any,
162+
provider: string,
163+
providerAccountId: string
164+
) {
165+
await prisma.account.delete({
166+
where: {
167+
provider_providerAccountId: {
168+
provider,
169+
providerAccountId,
170+
},
171+
},
172+
});
173+
},
174+
39175
/**
40176
* Get user session information
41177
*/

0 commit comments

Comments
 (0)