convex-zen 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/cli/generate.d.ts +14 -0
  2. package/dist/cli/generate.d.ts.map +1 -0
  3. package/dist/cli/generate.js +297 -0
  4. package/dist/cli/generate.js.map +1 -0
  5. package/dist/cli/index.d.ts +3 -0
  6. package/dist/cli/index.d.ts.map +1 -0
  7. package/dist/cli/index.js +111 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/client/index.d.ts +300 -0
  10. package/dist/client/index.d.ts.map +1 -0
  11. package/dist/client/index.js +434 -0
  12. package/dist/client/index.js.map +1 -0
  13. package/dist/client/plugins/admin.d.ts +92 -0
  14. package/dist/client/plugins/admin.d.ts.map +1 -0
  15. package/dist/client/plugins/admin.js +165 -0
  16. package/dist/client/plugins/admin.js.map +1 -0
  17. package/dist/client/primitives.d.ts +57 -0
  18. package/dist/client/primitives.d.ts.map +1 -0
  19. package/dist/client/primitives.js +64 -0
  20. package/dist/client/primitives.js.map +1 -0
  21. package/dist/client/providers.d.ts +14 -0
  22. package/dist/client/providers.d.ts.map +1 -0
  23. package/dist/client/providers.js +25 -0
  24. package/dist/client/providers.js.map +1 -0
  25. package/dist/client/react.d.ts +23 -0
  26. package/dist/client/react.d.ts.map +1 -0
  27. package/dist/client/react.js +48 -0
  28. package/dist/client/react.js.map +1 -0
  29. package/dist/client/tanstack-start-client-plugins.d.ts +34 -0
  30. package/dist/client/tanstack-start-client-plugins.d.ts.map +1 -0
  31. package/dist/client/tanstack-start-client-plugins.js +32 -0
  32. package/dist/client/tanstack-start-client-plugins.js.map +1 -0
  33. package/dist/client/tanstack-start-client.d.ts +52 -0
  34. package/dist/client/tanstack-start-client.d.ts.map +1 -0
  35. package/dist/client/tanstack-start-client.js +130 -0
  36. package/dist/client/tanstack-start-client.js.map +1 -0
  37. package/dist/client/tanstack-start-plugins.d.ts +27 -0
  38. package/dist/client/tanstack-start-plugins.d.ts.map +1 -0
  39. package/dist/client/tanstack-start-plugins.js +145 -0
  40. package/dist/client/tanstack-start-plugins.js.map +1 -0
  41. package/dist/client/tanstack-start.d.ts +130 -0
  42. package/dist/client/tanstack-start.d.ts.map +1 -0
  43. package/dist/client/tanstack-start.js +331 -0
  44. package/dist/client/tanstack-start.js.map +1 -0
  45. package/dist/component/_generated/api.d.ts +50 -0
  46. package/dist/component/_generated/api.d.ts.map +1 -0
  47. package/dist/component/_generated/api.js +31 -0
  48. package/dist/component/_generated/api.js.map +1 -0
  49. package/dist/component/_generated/component.d.ts +92 -0
  50. package/dist/component/_generated/component.d.ts.map +1 -0
  51. package/dist/component/_generated/component.js +11 -0
  52. package/dist/component/_generated/component.js.map +1 -0
  53. package/dist/component/_generated/dataModel.d.ts +46 -0
  54. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  55. package/dist/component/_generated/dataModel.js +11 -0
  56. package/dist/component/_generated/dataModel.js.map +1 -0
  57. package/dist/component/_generated/server.d.ts +121 -0
  58. package/dist/component/_generated/server.d.ts.map +1 -0
  59. package/dist/component/_generated/server.js +78 -0
  60. package/dist/component/_generated/server.js.map +1 -0
  61. package/dist/component/convex.config.d.ts +3 -0
  62. package/dist/component/convex.config.d.ts.map +1 -0
  63. package/dist/component/convex.config.js +4 -0
  64. package/dist/component/convex.config.js.map +1 -0
  65. package/dist/component/core/sessions.d.ts +33 -0
  66. package/dist/component/core/sessions.d.ts.map +1 -0
  67. package/dist/component/core/sessions.js +186 -0
  68. package/dist/component/core/sessions.js.map +1 -0
  69. package/dist/component/core/users.d.ts +19 -0
  70. package/dist/component/core/users.d.ts.map +1 -0
  71. package/dist/component/core/users.js +154 -0
  72. package/dist/component/core/users.js.map +1 -0
  73. package/dist/component/core/verifications.d.ts +34 -0
  74. package/dist/component/core/verifications.d.ts.map +1 -0
  75. package/dist/component/core/verifications.js +135 -0
  76. package/dist/component/core/verifications.js.map +1 -0
  77. package/dist/component/gateway.d.ts +16 -0
  78. package/dist/component/gateway.d.ts.map +1 -0
  79. package/dist/component/gateway.js +229 -0
  80. package/dist/component/gateway.js.map +1 -0
  81. package/dist/component/lib/crypto.d.ts +24 -0
  82. package/dist/component/lib/crypto.d.ts.map +1 -0
  83. package/dist/component/lib/crypto.js +57 -0
  84. package/dist/component/lib/crypto.js.map +1 -0
  85. package/dist/component/lib/rateLimit.d.ts +26 -0
  86. package/dist/component/lib/rateLimit.d.ts.map +1 -0
  87. package/dist/component/lib/rateLimit.js +96 -0
  88. package/dist/component/lib/rateLimit.js.map +1 -0
  89. package/dist/component/lib/validators.d.ts +19 -0
  90. package/dist/component/lib/validators.d.ts.map +1 -0
  91. package/dist/component/lib/validators.js +12 -0
  92. package/dist/component/lib/validators.js.map +1 -0
  93. package/dist/component/plugins/admin.d.ts +72 -0
  94. package/dist/component/plugins/admin.d.ts.map +1 -0
  95. package/dist/component/plugins/admin.js +152 -0
  96. package/dist/component/plugins/admin.js.map +1 -0
  97. package/dist/component/providers/emailPassword.d.ts +49 -0
  98. package/dist/component/providers/emailPassword.d.ts.map +1 -0
  99. package/dist/component/providers/emailPassword.js +316 -0
  100. package/dist/component/providers/emailPassword.js.map +1 -0
  101. package/dist/component/providers/oauth.d.ts +33 -0
  102. package/dist/component/providers/oauth.d.ts.map +1 -0
  103. package/dist/component/providers/oauth.js +256 -0
  104. package/dist/component/providers/oauth.js.map +1 -0
  105. package/dist/component/schema.d.ts +132 -0
  106. package/dist/component/schema.d.ts.map +1 -0
  107. package/dist/component/schema.js +82 -0
  108. package/dist/component/schema.js.map +1 -0
  109. package/dist/types.d.ts +67 -0
  110. package/dist/types.d.ts.map +1 -0
  111. package/dist/types.js +5 -0
  112. package/dist/types.js.map +1 -0
  113. package/package.json +121 -0
  114. package/src/cli/generate.ts +360 -0
  115. package/src/cli/index.ts +133 -0
  116. package/src/client/index.ts +707 -0
  117. package/src/client/plugins/admin.ts +205 -0
  118. package/src/client/primitives.ts +100 -0
  119. package/src/client/providers.ts +35 -0
  120. package/src/client/react.ts +97 -0
  121. package/src/client/tanstack-start-client-plugins.ts +113 -0
  122. package/src/client/tanstack-start-client.ts +259 -0
  123. package/src/client/tanstack-start-plugins.ts +203 -0
  124. package/src/client/tanstack-start.ts +535 -0
  125. package/src/component/_generated/api.ts +70 -0
  126. package/src/component/_generated/component.ts +184 -0
  127. package/src/component/_generated/dataModel.ts +60 -0
  128. package/src/component/_generated/server.ts +156 -0
  129. package/src/component/convex.config.ts +5 -0
  130. package/src/component/core/sessions.ts +228 -0
  131. package/src/component/core/users.ts +199 -0
  132. package/src/component/core/verifications.ts +173 -0
  133. package/src/component/gateway.ts +321 -0
  134. package/src/component/lib/crypto.ts +63 -0
  135. package/src/component/lib/internalApi.ts +66 -0
  136. package/src/component/lib/rateLimit.ts +111 -0
  137. package/src/component/lib/validators.ts +12 -0
  138. package/src/component/plugins/admin.ts +178 -0
  139. package/src/component/providers/emailPassword.ts +374 -0
  140. package/src/component/providers/oauth.ts +324 -0
  141. package/src/component/schema.ts +88 -0
  142. package/src/types.ts +68 -0
@@ -0,0 +1,374 @@
1
+ import { argon2id, argon2Verify } from "hash-wasm";
2
+ import { v } from "convex/values";
3
+ import { internalAction, internalMutation } from "../_generated/server";
4
+ import { internal } from "../lib/internalApi";
5
+
6
+ async function hashPassword(password: string): Promise<string> {
7
+ return argon2id({
8
+ password,
9
+ salt: crypto.getRandomValues(new Uint8Array(16)),
10
+ parallelism: 1,
11
+ iterations: 2,
12
+ memorySize: 19456, // 19 MB in KB
13
+ hashLength: 32,
14
+ outputType: "encoded", // PHC string — includes salt + params
15
+ });
16
+ }
17
+
18
+ /** Simple email validation — no ReDoS-prone regex. */
19
+ function isValidEmail(email: string): boolean {
20
+ if (email.length > 255) return false;
21
+ const atIndex = email.indexOf("@");
22
+ if (atIndex < 1) return false;
23
+ const domain = email.slice(atIndex + 1);
24
+ if (domain.length < 3) return false;
25
+ if (!domain.includes(".")) return false;
26
+ return true;
27
+ }
28
+
29
+ /**
30
+ * Sign up with email and password.
31
+ *
32
+ * Returns the verification code so the host app (via ConvexAuth client)
33
+ * can send the email. Functions cannot be passed as Convex args.
34
+ *
35
+ * Flow:
36
+ * 1. Validate email
37
+ * 2. Check IP rate limit
38
+ * 3. Check email not already registered
39
+ * 4. Hash password with Argon2id
40
+ * 5. Create user + account
41
+ * 6. Generate verification code
42
+ * 7. Return { status: "verification_required", verificationCode }
43
+ */
44
+ export const signUp = internalAction({
45
+ args: {
46
+ email: v.string(),
47
+ password: v.string(),
48
+ name: v.optional(v.string()),
49
+ ipAddress: v.optional(v.string()),
50
+ },
51
+ handler: async (ctx, args) => {
52
+ const { email, password, name, ipAddress } = args;
53
+
54
+ // 1. Validate email
55
+ if (!isValidEmail(email)) {
56
+ throw new Error("Invalid email address");
57
+ }
58
+
59
+ // 2. Check IP rate limit
60
+ if (ipAddress) {
61
+ const key = `signup:ip:${ipAddress}`;
62
+ const rateCheck = await ctx.runQuery(internal.lib.rateLimit.check, {
63
+ key,
64
+ });
65
+ if (rateCheck.limited) {
66
+ throw new Error("Too many requests. Please try again later.");
67
+ }
68
+ }
69
+
70
+ // 3. Check email not already registered
71
+ const existingUser = await ctx.runQuery(internal.core.users.getByEmail, {
72
+ email: email.toLowerCase(),
73
+ });
74
+ if (existingUser) {
75
+ // Increment rate limit to avoid timing-based email enumeration
76
+ if (ipAddress) {
77
+ await ctx.runMutation(internal.lib.rateLimit.increment, {
78
+ key: `signup:ip:${ipAddress}`,
79
+ });
80
+ }
81
+ throw new Error("Email already registered");
82
+ }
83
+
84
+ // 4. Hash password with Argon2id
85
+ const passwordHash = await hashPassword(password);
86
+
87
+ // 5. Create user + account
88
+ const userId = await ctx.runMutation(internal.core.users.create, {
89
+ email: email.toLowerCase(),
90
+ emailVerified: false,
91
+ name,
92
+ });
93
+ await ctx.runMutation(internal.core.users.createAccount, {
94
+ userId,
95
+ providerId: "credential",
96
+ accountId: email.toLowerCase(),
97
+ passwordHash,
98
+ });
99
+
100
+ // 6. Generate verification code (returned to caller for email sending)
101
+ const { code, expiresAt } = await ctx.runMutation(
102
+ internal.core.verifications.create,
103
+ {
104
+ identifier: email.toLowerCase(),
105
+ type: "email-verification",
106
+ }
107
+ );
108
+
109
+ // Schedule cleanup at expiry
110
+ await ctx.scheduler.runAt(
111
+ expiresAt,
112
+ internal.core.verifications.cleanup,
113
+ {}
114
+ );
115
+
116
+ return { status: "verification_required" as const, verificationCode: code };
117
+ },
118
+ });
119
+
120
+ /**
121
+ * Sign in with email and password.
122
+ *
123
+ * Flow:
124
+ * 1. Check rate limits (IP + email)
125
+ * 2. Look up account by email
126
+ * 3. Verify Argon2id hash
127
+ * 4. Check banned status
128
+ * 5. Create session
129
+ * 6. Return { sessionToken, userId }
130
+ */
131
+ export const signIn = internalAction({
132
+ args: {
133
+ email: v.string(),
134
+ password: v.string(),
135
+ ipAddress: v.optional(v.string()),
136
+ userAgent: v.optional(v.string()),
137
+ requireEmailVerified: v.optional(v.boolean()),
138
+ },
139
+ handler: async (ctx, args) => {
140
+ const { email, password, ipAddress, userAgent, requireEmailVerified } =
141
+ args;
142
+ const normalizedEmail = email.toLowerCase();
143
+
144
+ // 1. Check rate limits
145
+ const rateLimitKeys: string[] = [];
146
+ if (ipAddress) rateLimitKeys.push(`signin:ip:${ipAddress}`);
147
+ rateLimitKeys.push(`signin:email:${normalizedEmail}`);
148
+
149
+ for (const key of rateLimitKeys) {
150
+ const rateCheck = await ctx.runQuery(internal.lib.rateLimit.check, {
151
+ key,
152
+ });
153
+ if (rateCheck.limited) {
154
+ throw new Error("Too many failed attempts. Please try again later.");
155
+ }
156
+ }
157
+
158
+ // Helper to record failure
159
+ const recordFailure = async () => {
160
+ for (const key of rateLimitKeys) {
161
+ await ctx.runMutation(internal.lib.rateLimit.increment, { key });
162
+ }
163
+ };
164
+
165
+ // 2. Look up account by email
166
+ const account = await ctx.runQuery(internal.core.users.getAccount, {
167
+ providerId: "credential",
168
+ accountId: normalizedEmail,
169
+ });
170
+
171
+ if (!account || !account.passwordHash) {
172
+ await recordFailure();
173
+ throw new Error("Invalid email or password");
174
+ }
175
+
176
+ // 3. Verify Argon2id hash
177
+ const isValid = await argon2Verify({
178
+ password,
179
+ hash: account.passwordHash,
180
+ });
181
+
182
+ if (!isValid) {
183
+ await recordFailure();
184
+ throw new Error("Invalid email or password");
185
+ }
186
+
187
+ // 4. Check email verified if required
188
+ const user = await ctx.runQuery(internal.core.users.getById, {
189
+ userId: account.userId,
190
+ });
191
+
192
+ if (requireEmailVerified && !user?.emailVerified) {
193
+ throw new Error("Email address not verified");
194
+ }
195
+
196
+ // 4b. Check banned status
197
+ if (user?.banned) {
198
+ const now = Date.now();
199
+ if (user.banExpires === undefined || user.banExpires > now) {
200
+ throw new Error(
201
+ `Account banned${user.banReason ? ": " + user.banReason : ""}`
202
+ );
203
+ }
204
+ }
205
+
206
+ // 5. Reset rate limits on success
207
+ for (const key of rateLimitKeys) {
208
+ await ctx.runMutation(internal.lib.rateLimit.reset, { key });
209
+ }
210
+
211
+ // 6. Create session
212
+ const sessionToken = await ctx.runMutation(internal.core.sessions.create, {
213
+ userId: account.userId,
214
+ ipAddress,
215
+ userAgent,
216
+ });
217
+
218
+ return { sessionToken, userId: account.userId };
219
+ },
220
+ });
221
+
222
+ /**
223
+ * Verify email address with a verification code.
224
+ */
225
+ export const verifyEmail = internalAction({
226
+ args: {
227
+ email: v.string(),
228
+ code: v.string(),
229
+ },
230
+ handler: async (ctx, { email, code }) => {
231
+ const normalizedEmail = email.toLowerCase();
232
+
233
+ const result = await ctx.runMutation(internal.core.verifications.verify, {
234
+ identifier: normalizedEmail,
235
+ type: "email-verification",
236
+ code,
237
+ });
238
+
239
+ if (result.status !== "valid") {
240
+ return result;
241
+ }
242
+
243
+ // Mark user as verified
244
+ const user = await ctx.runQuery(internal.core.users.getByEmail, {
245
+ email: normalizedEmail,
246
+ });
247
+ if (user) {
248
+ await ctx.runMutation(internal.core.users.update, {
249
+ userId: user._id,
250
+ emailVerified: true,
251
+ });
252
+ }
253
+
254
+ return { status: "valid" as const };
255
+ },
256
+ });
257
+
258
+ /**
259
+ * Request a password reset code.
260
+ * Returns the reset code so the host app can send the email.
261
+ * Always returns { status: "sent" } to prevent email enumeration.
262
+ * Returns resetCode only when a real user was found.
263
+ */
264
+ export const requestPasswordReset = internalAction({
265
+ args: {
266
+ email: v.string(),
267
+ ipAddress: v.optional(v.string()),
268
+ },
269
+ handler: async (ctx, args) => {
270
+ const { email, ipAddress } = args;
271
+ const normalizedEmail = email.toLowerCase();
272
+
273
+ // Rate limit
274
+ if (ipAddress) {
275
+ const key = `reset:ip:${ipAddress}`;
276
+ const rateCheck = await ctx.runQuery(internal.lib.rateLimit.check, {
277
+ key,
278
+ });
279
+ if (rateCheck.limited) {
280
+ // Don't reveal if email exists; return success silently
281
+ return { status: "sent" as const, resetCode: null };
282
+ }
283
+ await ctx.runMutation(internal.lib.rateLimit.increment, { key });
284
+ }
285
+
286
+ const user = await ctx.runQuery(internal.core.users.getByEmail, {
287
+ email: normalizedEmail,
288
+ });
289
+
290
+ if (!user) {
291
+ // Always return success to prevent email enumeration
292
+ return { status: "sent" as const, resetCode: null };
293
+ }
294
+
295
+ const { code, expiresAt } = await ctx.runMutation(
296
+ internal.core.verifications.create,
297
+ {
298
+ identifier: normalizedEmail,
299
+ type: "password-reset",
300
+ }
301
+ );
302
+
303
+ await ctx.scheduler.runAt(
304
+ expiresAt,
305
+ internal.core.verifications.cleanup,
306
+ {}
307
+ );
308
+
309
+ return { status: "sent" as const, resetCode: code };
310
+ },
311
+ });
312
+
313
+ /**
314
+ * Reset password using a verification code.
315
+ */
316
+ export const resetPassword = internalAction({
317
+ args: {
318
+ email: v.string(),
319
+ code: v.string(),
320
+ newPassword: v.string(),
321
+ },
322
+ handler: async (ctx, { email, code, newPassword }) => {
323
+ const normalizedEmail = email.toLowerCase();
324
+
325
+ const result = await ctx.runMutation(internal.core.verifications.verify, {
326
+ identifier: normalizedEmail,
327
+ type: "password-reset",
328
+ code,
329
+ });
330
+
331
+ if (result.status !== "valid") {
332
+ return result;
333
+ }
334
+
335
+ // Hash new password
336
+ const passwordHash = await hashPassword(newPassword);
337
+
338
+ // Update account password hash
339
+ const account = await ctx.runQuery(internal.core.users.getAccount, {
340
+ providerId: "credential",
341
+ accountId: normalizedEmail,
342
+ });
343
+
344
+ if (!account) {
345
+ throw new Error("Account not found");
346
+ }
347
+
348
+ await ctx.runMutation(internal.providers.emailPassword.updatePasswordHash, {
349
+ accountId: account._id,
350
+ passwordHash,
351
+ });
352
+
353
+ // Invalidate all sessions (force re-login after password reset)
354
+ await ctx.runMutation(internal.core.sessions.invalidateAll, {
355
+ userId: account.userId,
356
+ });
357
+
358
+ return { status: "valid" as const };
359
+ },
360
+ });
361
+
362
+ /** Internal mutation to update password hash on an account. */
363
+ export const updatePasswordHash = internalMutation({
364
+ args: {
365
+ accountId: v.id("accounts"),
366
+ passwordHash: v.string(),
367
+ },
368
+ handler: async (ctx, { accountId, passwordHash }) => {
369
+ await ctx.db.patch(accountId, {
370
+ passwordHash,
371
+ updatedAt: Date.now(),
372
+ });
373
+ },
374
+ });
@@ -0,0 +1,324 @@
1
+ import { v, type GenericId } from "convex/values";
2
+ import { internalAction, internalMutation, internalQuery } from "../_generated/server";
3
+ import {
4
+ generateState,
5
+ generateCodeVerifier,
6
+ generateCodeChallenge,
7
+ hashToken,
8
+ } from "../lib/crypto";
9
+ import { oauthProviderConfigValidator } from "../lib/validators";
10
+ import { internal } from "../lib/internalApi";
11
+ import type { OAuthProviderConfig } from "../../types";
12
+
13
+ /** OAuth state TTL: 10 minutes. */
14
+ const STATE_TTL_MS = 10 * 60 * 1000;
15
+
16
+ function assertValidProviderConfig(provider: OAuthProviderConfig): void {
17
+ const required = [
18
+ provider.id,
19
+ provider.clientId,
20
+ provider.clientSecret,
21
+ provider.authorizationUrl,
22
+ provider.tokenUrl,
23
+ provider.userInfoUrl,
24
+ ];
25
+ if (required.some((value) => value.trim().length === 0)) {
26
+ throw new Error("Invalid OAuth provider configuration");
27
+ }
28
+ if (provider.scopes.length === 0) {
29
+ throw new Error("OAuth provider scopes must not be empty");
30
+ }
31
+
32
+ const urls = [provider.authorizationUrl, provider.tokenUrl, provider.userInfoUrl];
33
+ for (const rawUrl of urls) {
34
+ let parsed: URL;
35
+ try {
36
+ parsed = new URL(rawUrl);
37
+ } catch {
38
+ throw new Error("Invalid OAuth provider URL");
39
+ }
40
+ if (parsed.protocol !== "https:") {
41
+ throw new Error("OAuth provider URLs must use HTTPS");
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Store OAuth state and code verifier for PKCE flow. */
47
+ export const storeOAuthState = internalMutation({
48
+ args: {
49
+ stateHash: v.string(),
50
+ codeVerifier: v.string(),
51
+ provider: v.string(),
52
+ redirectUrl: v.optional(v.string()),
53
+ expiresAt: v.number(),
54
+ },
55
+ handler: async (ctx, args) => {
56
+ const now = Date.now();
57
+ const oauthStateDoc: {
58
+ stateHash: string;
59
+ codeVerifier: string;
60
+ provider: string;
61
+ expiresAt: number;
62
+ createdAt: number;
63
+ redirectUrl?: string;
64
+ } = {
65
+ stateHash: args.stateHash,
66
+ codeVerifier: args.codeVerifier,
67
+ provider: args.provider,
68
+ expiresAt: args.expiresAt,
69
+ createdAt: now,
70
+ };
71
+ if (args.redirectUrl !== undefined) {
72
+ oauthStateDoc.redirectUrl = args.redirectUrl;
73
+ }
74
+ await ctx.db.insert("oauthStates", oauthStateDoc);
75
+ },
76
+ });
77
+
78
+ /** Retrieve and delete an OAuth state record by state hash (single-use). */
79
+ export const consumeOAuthState = internalMutation({
80
+ args: { stateHash: v.string() },
81
+ handler: async (ctx, { stateHash }) => {
82
+ const record = await ctx.db
83
+ .query("oauthStates")
84
+ .withIndex("by_stateHash", (q) => q.eq("stateHash", stateHash))
85
+ .unique();
86
+
87
+ if (!record) return null;
88
+
89
+ // Delete immediately (single-use)
90
+ await ctx.db.delete(record._id);
91
+
92
+ if (record.expiresAt < Date.now()) {
93
+ return null; // Expired
94
+ }
95
+
96
+ return record;
97
+ },
98
+ });
99
+
100
+ /**
101
+ * Initiate an OAuth authorization flow.
102
+ * Returns the authorization URL with PKCE + state parameters.
103
+ */
104
+ export const getAuthorizationUrl = internalAction({
105
+ args: {
106
+ provider: oauthProviderConfigValidator,
107
+ redirectUrl: v.optional(v.string()),
108
+ },
109
+ handler: async (ctx, args) => {
110
+ const provider = args.provider as OAuthProviderConfig;
111
+ assertValidProviderConfig(provider);
112
+
113
+ // Generate state (32 bytes = 256 bit)
114
+ const state = generateState();
115
+ const stateHash = await hashToken(state);
116
+
117
+ // Generate PKCE code verifier and challenge
118
+ const codeVerifier = generateCodeVerifier();
119
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
120
+
121
+ const expiresAt = Date.now() + STATE_TTL_MS;
122
+
123
+ await ctx.runMutation(internal.providers.oauth.storeOAuthState, {
124
+ stateHash,
125
+ codeVerifier,
126
+ provider: provider.id,
127
+ redirectUrl: args.redirectUrl,
128
+ expiresAt,
129
+ });
130
+
131
+ // Build authorization URL
132
+ const url = new URL(provider.authorizationUrl);
133
+ url.searchParams.set("client_id", provider.clientId);
134
+ url.searchParams.set("response_type", "code");
135
+ url.searchParams.set("scope", provider.scopes.join(" "));
136
+ url.searchParams.set("state", state);
137
+ url.searchParams.set("code_challenge", codeChallenge);
138
+ url.searchParams.set("code_challenge_method", "S256");
139
+ if (args.redirectUrl) {
140
+ url.searchParams.set("redirect_uri", args.redirectUrl);
141
+ }
142
+
143
+ return { authorizationUrl: url.toString() };
144
+ },
145
+ });
146
+
147
+ /**
148
+ * Handle the OAuth callback.
149
+ * Validates state, exchanges code for tokens, upserts user, creates session.
150
+ */
151
+ export const handleCallback = internalAction({
152
+ args: {
153
+ provider: oauthProviderConfigValidator,
154
+ code: v.string(),
155
+ state: v.string(),
156
+ redirectUrl: v.optional(v.string()),
157
+ ipAddress: v.optional(v.string()),
158
+ userAgent: v.optional(v.string()),
159
+ },
160
+ handler: async (ctx, args) => {
161
+ const provider = args.provider as OAuthProviderConfig;
162
+ assertValidProviderConfig(provider);
163
+ // Use global fetch; tests can mock it via vi.spyOn(globalThis, 'fetch')
164
+ const fetchFn = fetch;
165
+
166
+ // 1. Validate state against stored stateHash
167
+ const stateHash = await hashToken(args.state);
168
+ const stateRecord = await ctx.runMutation(
169
+ internal.providers.oauth.consumeOAuthState,
170
+ { stateHash }
171
+ );
172
+
173
+ if (!stateRecord) {
174
+ throw new Error("Invalid or expired OAuth state");
175
+ }
176
+
177
+ if (stateRecord.provider !== provider.id) {
178
+ throw new Error("Provider mismatch in OAuth state");
179
+ }
180
+
181
+ // 2. Exchange code + code_verifier for tokens
182
+ const tokenParams = new URLSearchParams({
183
+ grant_type: "authorization_code",
184
+ client_id: provider.clientId,
185
+ client_secret: provider.clientSecret,
186
+ code: args.code,
187
+ code_verifier: stateRecord.codeVerifier,
188
+ });
189
+
190
+ if (args.redirectUrl ?? stateRecord.redirectUrl) {
191
+ tokenParams.set(
192
+ "redirect_uri",
193
+ (args.redirectUrl ?? stateRecord.redirectUrl)!
194
+ );
195
+ }
196
+
197
+ const tokenRes = await fetchFn(provider.tokenUrl, {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
200
+ body: tokenParams.toString(),
201
+ });
202
+
203
+ if (!tokenRes.ok) {
204
+ throw new Error(`Token exchange failed: ${tokenRes.status}`);
205
+ }
206
+
207
+ const tokens = await tokenRes.json() as {
208
+ access_token: string;
209
+ refresh_token?: string;
210
+ expires_in?: number;
211
+ };
212
+
213
+ // 3. Fetch user profile from provider
214
+ const userRes = await fetchFn(provider.userInfoUrl, {
215
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
216
+ });
217
+
218
+ if (!userRes.ok) {
219
+ throw new Error(`User info fetch failed: ${userRes.status}`);
220
+ }
221
+
222
+ const profile = await userRes.json() as {
223
+ id?: string;
224
+ sub?: string;
225
+ email?: string;
226
+ name?: string;
227
+ picture?: string;
228
+ avatar_url?: string;
229
+ login?: string;
230
+ };
231
+
232
+ const providerId = provider.id;
233
+ const accountId = (profile.id ?? profile.sub)?.toString();
234
+ if (!accountId) {
235
+ throw new Error("Could not determine provider user ID");
236
+ }
237
+
238
+ const email = profile.email?.toLowerCase();
239
+ const name = profile.name ?? profile.login;
240
+ const image = profile.picture ?? profile.avatar_url;
241
+ const accessTokenExpiresAt = tokens.expires_in
242
+ ? Date.now() + tokens.expires_in * 1000
243
+ : undefined;
244
+
245
+ // 4. Find existing account or create user + account (account linking by email)
246
+ const existingAccount = await ctx.runQuery(internal.core.users.getAccount, {
247
+ providerId,
248
+ accountId,
249
+ });
250
+
251
+ let userId: GenericId<"users">;
252
+
253
+ if (existingAccount) {
254
+ // Update tokens on existing account
255
+ await ctx.runMutation(internal.core.users.updateAccount, {
256
+ accountId: existingAccount._id,
257
+ accessToken: tokens.access_token,
258
+ refreshToken: tokens.refresh_token,
259
+ accessTokenExpiresAt,
260
+ });
261
+ userId = existingAccount.userId;
262
+ } else {
263
+ // Try to link by email
264
+ const existingUser = email
265
+ ? await ctx.runQuery(internal.core.users.getByEmail, { email })
266
+ : null;
267
+
268
+ if (!existingUser && email) {
269
+ // Create new user
270
+ userId = await ctx.runMutation(internal.core.users.create, {
271
+ email,
272
+ emailVerified: true, // OAuth providers verify email
273
+ name,
274
+ image,
275
+ });
276
+ } else if (existingUser) {
277
+ userId = existingUser._id;
278
+ // Update profile info from OAuth
279
+ await ctx.runMutation(internal.core.users.update, {
280
+ userId: existingUser._id,
281
+ emailVerified: true,
282
+ name: name ?? existingUser.name,
283
+ image: image ?? existingUser.image,
284
+ });
285
+ } else {
286
+ throw new Error("OAuth provider did not return an email address");
287
+ }
288
+
289
+ // Create account entry
290
+ await ctx.runMutation(internal.core.users.createAccount, {
291
+ userId,
292
+ providerId,
293
+ accountId,
294
+ accessToken: tokens.access_token,
295
+ refreshToken: tokens.refresh_token,
296
+ accessTokenExpiresAt,
297
+ });
298
+ }
299
+
300
+ // 5. Check banned status
301
+ const user = await ctx.runQuery(internal.core.users.getById, { userId });
302
+ if (user?.banned) {
303
+ const now = Date.now();
304
+ if (user.banExpires === undefined || user.banExpires > now) {
305
+ throw new Error(
306
+ `Account banned${user.banReason ? ": " + user.banReason : ""}`
307
+ );
308
+ }
309
+ }
310
+
311
+ // 6. Create session
312
+ const sessionToken = await ctx.runMutation(internal.core.sessions.create, {
313
+ userId,
314
+ ipAddress: args.ipAddress,
315
+ userAgent: args.userAgent,
316
+ });
317
+
318
+ return {
319
+ sessionToken,
320
+ userId,
321
+ redirectUrl: stateRecord.redirectUrl,
322
+ };
323
+ },
324
+ });