@robelest/convex-auth 0.0.2-preview.2 → 0.0.3-preview

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 (114) hide show
  1. package/dist/bin.cjs +467 -64
  2. package/dist/client/index.d.ts +127 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +424 -1
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +56 -1
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +141 -3
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/convex.config.d.ts.map +1 -1
  12. package/dist/component/convex.config.js +2 -0
  13. package/dist/component/convex.config.js.map +1 -1
  14. package/dist/component/index.d.ts +5 -4
  15. package/dist/component/index.d.ts.map +1 -1
  16. package/dist/component/index.js +4 -3
  17. package/dist/component/index.js.map +1 -1
  18. package/dist/component/portalBridge.d.ts +80 -0
  19. package/dist/component/portalBridge.d.ts.map +1 -0
  20. package/dist/component/portalBridge.js +102 -0
  21. package/dist/component/portalBridge.js.map +1 -0
  22. package/dist/component/public.d.ts +353 -9
  23. package/dist/component/public.d.ts.map +1 -1
  24. package/dist/component/public.js +328 -33
  25. package/dist/component/public.js.map +1 -1
  26. package/dist/component/schema.d.ts +168 -9
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +113 -7
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/providers/passkey.d.ts +20 -0
  31. package/dist/providers/passkey.d.ts.map +1 -0
  32. package/dist/providers/passkey.js +32 -0
  33. package/dist/providers/passkey.js.map +1 -0
  34. package/dist/providers/totp.d.ts +14 -0
  35. package/dist/providers/totp.d.ts.map +1 -0
  36. package/dist/providers/totp.js +23 -0
  37. package/dist/providers/totp.js.map +1 -0
  38. package/dist/server/convex-auth.d.ts +296 -0
  39. package/dist/server/convex-auth.d.ts.map +1 -0
  40. package/dist/server/convex-auth.js +480 -0
  41. package/dist/server/convex-auth.js.map +1 -0
  42. package/dist/server/email-templates.d.ts +18 -0
  43. package/dist/server/email-templates.d.ts.map +1 -0
  44. package/dist/server/email-templates.js +74 -0
  45. package/dist/server/email-templates.js.map +1 -0
  46. package/dist/server/implementation/apiKey.d.ts +74 -0
  47. package/dist/server/implementation/apiKey.d.ts.map +1 -0
  48. package/dist/server/implementation/apiKey.js +140 -0
  49. package/dist/server/implementation/apiKey.js.map +1 -0
  50. package/dist/server/implementation/index.d.ts +169 -7
  51. package/dist/server/implementation/index.d.ts.map +1 -1
  52. package/dist/server/implementation/index.js +220 -5
  53. package/dist/server/implementation/index.js.map +1 -1
  54. package/dist/server/implementation/passkey.d.ts +33 -0
  55. package/dist/server/implementation/passkey.d.ts.map +1 -0
  56. package/dist/server/implementation/passkey.js +450 -0
  57. package/dist/server/implementation/passkey.js.map +1 -0
  58. package/dist/server/implementation/redirects.d.ts.map +1 -1
  59. package/dist/server/implementation/redirects.js +4 -9
  60. package/dist/server/implementation/redirects.js.map +1 -1
  61. package/dist/server/implementation/signIn.d.ts +13 -0
  62. package/dist/server/implementation/signIn.d.ts.map +1 -1
  63. package/dist/server/implementation/signIn.js +29 -15
  64. package/dist/server/implementation/signIn.js.map +1 -1
  65. package/dist/server/implementation/totp.d.ts +40 -0
  66. package/dist/server/implementation/totp.d.ts.map +1 -0
  67. package/dist/server/implementation/totp.js +211 -0
  68. package/dist/server/implementation/totp.js.map +1 -0
  69. package/dist/server/index.d.ts +26 -2
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +63 -16
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/portal-email.d.ts +19 -0
  74. package/dist/server/portal-email.d.ts.map +1 -0
  75. package/dist/server/portal-email.js +89 -0
  76. package/dist/server/portal-email.js.map +1 -0
  77. package/dist/server/provider_utils.d.ts +3 -1
  78. package/dist/server/provider_utils.d.ts.map +1 -1
  79. package/dist/server/provider_utils.js +39 -1
  80. package/dist/server/provider_utils.js.map +1 -1
  81. package/dist/server/types.d.ts +263 -4
  82. package/dist/server/types.d.ts.map +1 -1
  83. package/dist/server/version.d.ts +2 -0
  84. package/dist/server/version.d.ts.map +1 -0
  85. package/dist/server/version.js +3 -0
  86. package/dist/server/version.js.map +1 -0
  87. package/package.json +7 -3
  88. package/src/cli/index.ts +49 -7
  89. package/src/cli/portal-link.ts +112 -0
  90. package/src/cli/portal-upload.ts +411 -0
  91. package/src/cli/utils.ts +248 -0
  92. package/src/client/index.ts +489 -1
  93. package/src/component/_generated/api.ts +72 -1
  94. package/src/component/_generated/component.ts +241 -4
  95. package/src/component/convex.config.ts +3 -0
  96. package/src/component/index.ts +8 -3
  97. package/src/component/portalBridge.ts +116 -0
  98. package/src/component/public.ts +373 -37
  99. package/src/component/schema.ts +122 -7
  100. package/src/providers/passkey.ts +35 -0
  101. package/src/providers/totp.ts +26 -0
  102. package/src/server/convex-auth.ts +602 -0
  103. package/src/server/email-templates.ts +77 -0
  104. package/src/server/implementation/apiKey.ts +185 -0
  105. package/src/server/implementation/index.ts +301 -8
  106. package/src/server/implementation/passkey.ts +650 -0
  107. package/src/server/implementation/redirects.ts +4 -11
  108. package/src/server/implementation/signIn.ts +41 -13
  109. package/src/server/implementation/totp.ts +366 -0
  110. package/src/server/index.ts +98 -34
  111. package/src/server/portal-email.ts +95 -0
  112. package/src/server/provider_utils.ts +42 -1
  113. package/src/server/types.ts +285 -4
  114. package/src/server/version.ts +2 -0
@@ -4,7 +4,9 @@ import {
4
4
  ConvexCredentialsConfig,
5
5
  EmailConfig,
6
6
  GenericActionCtxWithAuthConfig,
7
+ PasskeyProviderConfig,
7
8
  PhoneConfig,
9
+ TotpProviderConfig,
8
10
  } from "../types.js";
9
11
  import {
10
12
  AuthDataModel,
@@ -17,12 +19,15 @@ import {
17
19
  callRefreshSession,
18
20
  callSignIn,
19
21
  callVerifier,
22
+ callVerifierSignature,
20
23
  callVerifyCodeAndSignIn,
21
24
  } from "./mutations/index.js";
22
25
  import { redirectAbsoluteUrl, setURLSearchParam } from "./redirects.js";
23
26
  import { requireEnv } from "../utils.js";
24
27
  import { OAuth2Config, OIDCConfig } from "@auth/core/providers/oauth.js";
25
28
  import { generateRandomString } from "./utils.js";
29
+ import { handlePasskey } from "./passkey.js";
30
+ import { handleTotp, checkTotpRequired } from "./totp.js";
26
31
 
27
32
  const DEFAULT_EMAIL_VERIFICATION_CODE_DURATION_S = 60 * 60 * 24; // 24 hours
28
33
 
@@ -50,6 +55,12 @@ export async function signInImpl(
50
55
  | { kind: "started"; started: true }
51
56
  // OAuth2 and OIDC flows
52
57
  | { kind: "redirect"; redirect: string; verifier: string }
58
+ // Passkey options (challenge + credential options)
59
+ | { kind: "passkeyOptions"; options: Record<string, any>; verifier: string }
60
+ // TOTP 2FA required after credentials sign-in
61
+ | { kind: "totpRequired"; verifier: string }
62
+ // TOTP setup response (enrollment)
63
+ | { kind: "totpSetup"; uri: string; secret: string; verifier: string; totpId: string }
53
64
  > {
54
65
  if (provider === null && args.refreshToken) {
55
66
  const tokens: Tokens = (await callRefreshSession(ctx, {
@@ -84,6 +95,12 @@ export async function signInImpl(
84
95
  if (provider.type === "oauth" || provider.type === "oidc") {
85
96
  return handleOAuthProvider(ctx, provider, args, options);
86
97
  }
98
+ if (provider.type === "passkey") {
99
+ return handlePasskey(ctx, provider, args);
100
+ }
101
+ if (provider.type === "totp") {
102
+ return handleTotp(ctx, provider, args);
103
+ }
87
104
  const _typecheck: never = provider;
88
105
  throw new Error(
89
106
  `Provider type ${(provider as any).type} is not supported yet`,
@@ -153,20 +170,10 @@ async function handleEmailAndPhoneProvider(
153
170
  await provider.sendVerificationRequest(
154
171
  {
155
172
  ...verificationArgs,
156
- provider: {
157
- ...provider,
158
- from:
159
- // Simplifies demo configuration of Resend
160
- provider.from === "Auth.js <no-reply@authjs.dev>" &&
161
- provider.id === "resend"
162
- ? "My App <onboarding@resend.dev>"
163
- : provider.from,
164
- },
165
- request: new Request("http://localhost"), // TODO: Document
173
+ provider,
174
+ request: new Request("http://localhost"),
166
175
  theme: ctx.auth.config.theme,
167
176
  },
168
- // @ts-expect-error Figure out typing for email providers so they can
169
- // access ctx.
170
177
  ctx,
171
178
  );
172
179
  } else if (provider.type === "phone") {
@@ -187,11 +194,32 @@ async function handleCredentials(
187
194
  options: {
188
195
  generateTokens: boolean;
189
196
  },
190
- ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
197
+ ): Promise<
198
+ | { kind: "signedIn"; signedIn: SessionInfo | null }
199
+ | { kind: "totpRequired"; verifier: string }
200
+ > {
191
201
  const result = await provider.authorize(args.params ?? {}, ctx);
192
202
  if (result === null) {
193
203
  return { kind: "signedIn", signedIn: null };
194
204
  }
205
+ // Check if user has TOTP 2FA enrolled before issuing tokens
206
+ const hasTotpEnrolled = await checkTotpRequired(ctx, result.userId);
207
+ if (hasTotpEnrolled) {
208
+ // Create session but withhold tokens — TOTP verification needed
209
+ const idsWithoutTokens = await callSignIn(ctx, {
210
+ userId: result.userId,
211
+ sessionId: result.sessionId,
212
+ generateTokens: false,
213
+ });
214
+ // Store userId in verifier so the TOTP verify flow can complete sign-in
215
+ const verifier = await callVerifier(ctx);
216
+ await callVerifierSignature(ctx, {
217
+ verifier,
218
+ signature: JSON.stringify({ userId: result.userId }),
219
+ });
220
+ return { kind: "totpRequired", verifier };
221
+ }
222
+
195
223
  const idsAndTokens = await callSignIn(ctx, {
196
224
  userId: result.userId,
197
225
  sessionId: result.sessionId,
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Server-side TOTP ceremony logic for two-factor authentication.
3
+ *
4
+ * Handles the three phases of the TOTP flow:
5
+ * 1. setup — generate a TOTP secret and `otpauth://` URI for enrollment
6
+ * 2. confirm — verify the first code from the authenticator app and mark
7
+ * the enrollment as verified
8
+ * 3. verify — verify a TOTP code during sign-in (2FA challenge)
9
+ *
10
+ * Uses `@oslojs/otp` for TOTP generation / verification and
11
+ * `@oslojs/encoding` for base-32 secret encoding.
12
+ */
13
+
14
+ import { GenericId } from "convex/values";
15
+ import {
16
+ generateTOTP,
17
+ verifyTOTPWithGracePeriod,
18
+ createTOTPKeyURI,
19
+ } from "@oslojs/otp";
20
+ import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
21
+ import {
22
+ TotpProviderConfig,
23
+ GenericActionCtxWithAuthConfig,
24
+ } from "../types.js";
25
+ import { AuthDataModel, SessionInfo } from "./types.js";
26
+ import { callSignIn, callVerifier } from "./mutations/index.js";
27
+ import { callVerifierSignature } from "./mutations/verifierSignature.js";
28
+
29
+ type EnrichedActionCtx = GenericActionCtxWithAuthConfig<AuthDataModel>;
30
+
31
+ // ============================================================================
32
+ // Setup flow
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Phase 1: Generate a TOTP secret and enrollment URI.
37
+ *
38
+ * Requires an authenticated user — TOTP enrollment always adds a second
39
+ * factor to an existing account. The userId is taken from the current
40
+ * session identity.
41
+ */
42
+ async function handleSetup(
43
+ ctx: EnrichedActionCtx,
44
+ provider: TotpProviderConfig,
45
+ params: Record<string, any>,
46
+ ): Promise<{
47
+ kind: "totpSetup";
48
+ uri: string;
49
+ secret: string;
50
+ verifier: string;
51
+ totpId: string;
52
+ }> {
53
+ // TOTP enrollment requires an authenticated user
54
+ const identity = await ctx.auth.getUserIdentity();
55
+ if (identity === null) {
56
+ throw new Error(
57
+ "TOTP enrollment requires an authenticated user. " +
58
+ "Sign in first, then add TOTP to your account.",
59
+ );
60
+ }
61
+ const [userId] = identity.subject.split("|");
62
+
63
+ // Generate a 20-byte random secret (160 bits, per RFC 4226 recommendation)
64
+ const secret = new Uint8Array(20);
65
+ crypto.getRandomValues(secret);
66
+
67
+ // Resolve the account name for the otpauth:// URI
68
+ let accountName: string = params.accountName as string;
69
+ if (!accountName) {
70
+ const user = await ctx.runQuery(
71
+ ctx.auth.config.component.public.userGetById,
72
+ { userId: userId! },
73
+ );
74
+ accountName = (user as any)?.email ?? "user";
75
+ }
76
+
77
+ // Build the otpauth:// URI for QR code scanning
78
+ const uri = createTOTPKeyURI(
79
+ provider.options.issuer,
80
+ accountName,
81
+ secret,
82
+ provider.options.period,
83
+ provider.options.digits,
84
+ );
85
+
86
+ // Encode the secret as base-32 for manual entry
87
+ const base32Secret = encodeBase32LowerCaseNoPadding(secret);
88
+
89
+ // Store enrolment metadata in a verifier so we can correlate the confirm step
90
+ const verifier = await callVerifier(ctx);
91
+ await callVerifierSignature(ctx, {
92
+ verifier,
93
+ signature: JSON.stringify({
94
+ secret: Array.from(secret),
95
+ userId,
96
+ digits: provider.options.digits,
97
+ period: provider.options.period,
98
+ }),
99
+ });
100
+
101
+ // Insert an UNVERIFIED TOTP record in the DB
102
+ const totpId = await ctx.runMutation(
103
+ ctx.auth.config.component.public.totpInsert,
104
+ {
105
+ userId: userId as any,
106
+ secret: secret.buffer.slice(
107
+ secret.byteOffset,
108
+ secret.byteOffset + secret.byteLength,
109
+ ),
110
+ digits: provider.options.digits,
111
+ period: provider.options.period,
112
+ verified: false,
113
+ name: params.name,
114
+ createdAt: Date.now(),
115
+ },
116
+ );
117
+
118
+ return {
119
+ kind: "totpSetup" as const,
120
+ uri,
121
+ secret: base32Secret,
122
+ verifier,
123
+ totpId: totpId as string,
124
+ };
125
+ }
126
+
127
+ // ============================================================================
128
+ // Confirm flow
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Phase 2: Verify the first code from the authenticator app.
133
+ *
134
+ * Requires an authenticated user. Marks the TOTP enrollment as verified
135
+ * after confirming the code is correct.
136
+ */
137
+ async function handleConfirm(
138
+ ctx: EnrichedActionCtx,
139
+ provider: TotpProviderConfig,
140
+ params: Record<string, any>,
141
+ verifierValue: string | undefined,
142
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
143
+ // TOTP confirmation requires an authenticated user
144
+ const identity = await ctx.auth.getUserIdentity();
145
+ if (identity === null) {
146
+ throw new Error(
147
+ "TOTP confirmation requires an authenticated user. " +
148
+ "Sign in first, then confirm your TOTP enrollment.",
149
+ );
150
+ }
151
+ const [userId] = identity.subject.split("|");
152
+
153
+ if (!verifierValue) {
154
+ throw new Error("Missing verifier");
155
+ }
156
+ if (!params.code) {
157
+ throw new Error("Missing `code` parameter");
158
+ }
159
+ if (!params.totpId) {
160
+ throw new Error("Missing `totpId` parameter");
161
+ }
162
+
163
+ // Look up the TOTP record
164
+ const totpDoc = await ctx.runQuery(
165
+ ctx.auth.config.component.public.totpGetById,
166
+ { totpId: params.totpId },
167
+ );
168
+ if (!totpDoc) {
169
+ throw new Error("TOTP enrollment not found");
170
+ }
171
+ if ((totpDoc as any).verified) {
172
+ throw new Error("TOTP enrollment is already verified");
173
+ }
174
+
175
+ // Extract the secret from the TOTP record
176
+ const secret = new Uint8Array((totpDoc as any).secret);
177
+
178
+ // Verify the code with a 30-second grace period
179
+ const valid = verifyTOTPWithGracePeriod(
180
+ secret,
181
+ provider.options.period,
182
+ provider.options.digits,
183
+ params.code,
184
+ 30,
185
+ );
186
+ if (!valid) {
187
+ throw new Error("Invalid TOTP code");
188
+ }
189
+
190
+ // Mark the enrollment as verified
191
+ await ctx.runMutation(
192
+ ctx.auth.config.component.public.totpMarkVerified,
193
+ { totpId: params.totpId as any, lastUsedAt: Date.now() },
194
+ );
195
+
196
+ // Clean up the verifier
197
+ await ctx.runMutation(
198
+ ctx.auth.config.component.public.verifierDelete,
199
+ { verifierId: verifierValue },
200
+ );
201
+
202
+ // Return tokens for the existing session
203
+ const signInResult = await callSignIn(ctx, {
204
+ userId: userId!,
205
+ generateTokens: true,
206
+ });
207
+
208
+ return { kind: "signedIn", signedIn: signInResult };
209
+ }
210
+
211
+ // ============================================================================
212
+ // Verify flow (2FA during sign-in)
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Phase 3: Verify a TOTP code during sign-in.
217
+ *
218
+ * Does NOT require an authenticated user — this runs mid-sign-in as a
219
+ * second-factor challenge. The userId is retrieved from the stored verifier.
220
+ */
221
+ async function handleVerify(
222
+ ctx: EnrichedActionCtx,
223
+ provider: TotpProviderConfig,
224
+ params: Record<string, any>,
225
+ verifierValue: string | undefined,
226
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
227
+ if (!verifierValue) {
228
+ throw new Error("Missing verifier");
229
+ }
230
+ if (!params.code) {
231
+ throw new Error("Missing `code` parameter");
232
+ }
233
+
234
+ // Look up the verifier to retrieve the stored userId
235
+ const verifierDoc = await ctx.runQuery(
236
+ ctx.auth.config.component.public.verifierGetById,
237
+ { verifierId: verifierValue },
238
+ );
239
+ if (!verifierDoc) {
240
+ throw new Error("Invalid or expired verifier");
241
+ }
242
+
243
+ // Parse the signature to extract userId
244
+ const signatureData = JSON.parse((verifierDoc as any).signature);
245
+ const userId = signatureData.userId as string;
246
+
247
+ // Look up the user's verified TOTP enrollment
248
+ const totpDoc = await ctx.runQuery(
249
+ ctx.auth.config.component.public.totpGetVerifiedByUserId,
250
+ { userId: userId as any },
251
+ );
252
+ if (!totpDoc) {
253
+ throw new Error("No TOTP enrollment found");
254
+ }
255
+
256
+ // Extract the secret from the TOTP record
257
+ const secret = new Uint8Array((totpDoc as any).secret);
258
+
259
+ // Verify the code with a 30-second grace period
260
+ const valid = verifyTOTPWithGracePeriod(
261
+ secret,
262
+ (totpDoc as any).period,
263
+ (totpDoc as any).digits,
264
+ params.code,
265
+ 30,
266
+ );
267
+ if (!valid) {
268
+ throw new Error("Invalid TOTP code");
269
+ }
270
+
271
+ // Update last used timestamp
272
+ await ctx.runMutation(
273
+ ctx.auth.config.component.public.totpUpdateLastUsed,
274
+ { totpId: (totpDoc as any)._id, lastUsedAt: Date.now() },
275
+ );
276
+
277
+ // Clean up the verifier
278
+ await ctx.runMutation(
279
+ ctx.auth.config.component.public.verifierDelete,
280
+ { verifierId: verifierValue },
281
+ );
282
+
283
+ // Sign in the user with tokens
284
+ const signInResult = await callSignIn(ctx, {
285
+ userId,
286
+ generateTokens: true,
287
+ });
288
+
289
+ return { kind: "signedIn", signedIn: signInResult };
290
+ }
291
+
292
+ // ============================================================================
293
+ // Main dispatch
294
+ // ============================================================================
295
+
296
+ /**
297
+ * Main TOTP handler dispatched from signIn.ts.
298
+ *
299
+ * Routes to the appropriate phase based on `params.flow`.
300
+ */
301
+ export async function handleTotp(
302
+ ctx: EnrichedActionCtx,
303
+ provider: TotpProviderConfig,
304
+ args: {
305
+ params?: Record<string, any>;
306
+ verifier?: string;
307
+ },
308
+ ): Promise<
309
+ | { kind: "signedIn"; signedIn: SessionInfo | null }
310
+ | {
311
+ kind: "totpSetup";
312
+ uri: string;
313
+ secret: string;
314
+ verifier: string;
315
+ totpId: string;
316
+ }
317
+ > {
318
+ const flow = args.params?.flow;
319
+ if (!flow) {
320
+ throw new Error(
321
+ "Missing `flow` parameter. Expected one of: setup, confirm, verify",
322
+ );
323
+ }
324
+
325
+ switch (flow) {
326
+ case "setup":
327
+ return handleSetup(ctx, provider, args.params ?? {});
328
+ case "confirm":
329
+ return handleConfirm(
330
+ ctx,
331
+ provider,
332
+ args.params ?? {},
333
+ args.verifier,
334
+ );
335
+ case "verify":
336
+ return handleVerify(
337
+ ctx,
338
+ provider,
339
+ args.params ?? {},
340
+ args.verifier,
341
+ );
342
+ default:
343
+ throw new Error(
344
+ `Unknown TOTP flow: ${flow}. Expected one of: setup, confirm, verify`,
345
+ );
346
+ }
347
+ }
348
+
349
+ // ============================================================================
350
+ // Helpers
351
+ // ============================================================================
352
+
353
+ /**
354
+ * Check if a user has a verified TOTP enrollment.
355
+ * Called after credentials sign-in to determine if 2FA is needed.
356
+ */
357
+ export async function checkTotpRequired(
358
+ ctx: EnrichedActionCtx,
359
+ userId: string,
360
+ ): Promise<boolean> {
361
+ const totpDoc = await ctx.runQuery(
362
+ ctx.auth.config.component.public.totpGetVerifiedByUserId,
363
+ { userId: userId as any },
364
+ );
365
+ return totpDoc !== null;
366
+ }
@@ -17,6 +17,20 @@ export type AuthCookies = {
17
17
  verifier: string | null;
18
18
  };
19
19
 
20
+ /** A structured cookie ready to be set via any framework's cookie API. */
21
+ export type AuthCookie = {
22
+ name: string;
23
+ value: string;
24
+ options: {
25
+ path: string;
26
+ httpOnly: boolean;
27
+ secure: boolean;
28
+ sameSite: "lax" | "strict" | "none";
29
+ maxAge?: number;
30
+ expires?: Date;
31
+ };
32
+ };
33
+
20
34
  export type ServerOptions = {
21
35
  /** Convex deployment URL. */
22
36
  url: string;
@@ -27,8 +41,12 @@ export type ServerOptions = {
27
41
  };
28
42
 
29
43
  export type RefreshResult = {
30
- response?: Response;
31
- cookies?: string[];
44
+ /** Structured cookies to set on the response. */
45
+ cookies: AuthCookie[];
46
+ /** URL to redirect to (set after OAuth code exchange). */
47
+ redirect?: string;
48
+ /** JWT for SSR hydration, or `null` if not authenticated. */
49
+ token: string | null;
32
50
  };
33
51
 
34
52
  export function authCookieNames(host?: string) {
@@ -86,6 +104,57 @@ export function serializeAuthCookies(
86
104
  ];
87
105
  }
88
106
 
107
+ /**
108
+ * Build structured cookie objects for any SSR framework.
109
+ *
110
+ * Use with SvelteKit's `event.cookies.set()`, TanStack Start's `setCookie()`,
111
+ * Next.js's `cookies().set()`, or any other framework cookie API.
112
+ */
113
+ export function structuredAuthCookies(
114
+ cookies: AuthCookies,
115
+ host?: string,
116
+ config: AuthCookieConfig = { maxAge: null },
117
+ ): AuthCookie[] {
118
+ const names = authCookieNames(host);
119
+ const secure = !isLocalHost(host);
120
+ const base = {
121
+ path: "/" as const,
122
+ httpOnly: true as const,
123
+ secure,
124
+ sameSite: "lax" as const,
125
+ };
126
+ const maxAge = config.maxAge ?? undefined;
127
+ return [
128
+ {
129
+ name: names.token,
130
+ value: cookies.token ?? "",
131
+ options: {
132
+ ...base,
133
+ maxAge: cookies.token === null ? 0 : maxAge,
134
+ expires: cookies.token === null ? new Date(0) : undefined,
135
+ },
136
+ },
137
+ {
138
+ name: names.refreshToken,
139
+ value: cookies.refreshToken ?? "",
140
+ options: {
141
+ ...base,
142
+ maxAge: cookies.refreshToken === null ? 0 : maxAge,
143
+ expires: cookies.refreshToken === null ? new Date(0) : undefined,
144
+ },
145
+ },
146
+ {
147
+ name: names.verifier,
148
+ value: cookies.verifier ?? "",
149
+ options: {
150
+ ...base,
151
+ maxAge: cookies.verifier === null ? 0 : maxAge,
152
+ expires: cookies.verifier === null ? new Date(0) : undefined,
153
+ },
154
+ },
155
+ ];
156
+ }
157
+
89
158
  export function shouldProxyAuthAction(pathname: string, apiRoute: string) {
90
159
  if (apiRoute.endsWith("/")) {
91
160
  return pathname === apiRoute || pathname === apiRoute.slice(0, -1);
@@ -348,21 +417,21 @@ export function server(options: ServerOptions) {
348
417
 
349
418
  async refresh(request: Request): Promise<RefreshResult> {
350
419
  const host = cookieHost(request);
420
+ const currentToken = parseRequestCookies(request).token;
351
421
 
422
+ // CORS request — clear all auth cookies.
352
423
  if (isCorsRequest(request)) {
353
424
  return {
354
- cookies: serializeAuthCookies(
355
- {
356
- token: null,
357
- refreshToken: null,
358
- verifier: null,
359
- },
425
+ cookies: structuredAuthCookies(
426
+ { token: null, refreshToken: null, verifier: null },
360
427
  host,
361
428
  cookieConfig,
362
429
  ),
430
+ token: null,
363
431
  };
364
432
  }
365
433
 
434
+ // OAuth code exchange — exchange code for tokens and redirect.
366
435
  const requestUrl = new URL(request.url);
367
436
  const code = requestUrl.searchParams.get("code");
368
437
  const shouldHandleCode =
@@ -392,47 +461,41 @@ export function server(options: ServerOptions) {
392
461
  if (result.tokens === undefined) {
393
462
  throw new Error("Invalid `auth:signIn` result for code exchange");
394
463
  }
395
- const response = Response.redirect(redirectUrl.toString(), 302);
396
464
  return {
397
- response: attachCookies(
398
- response,
399
- serializeAuthCookies(
400
- {
401
- token: result.tokens?.token ?? null,
402
- refreshToken: result.tokens?.refreshToken ?? null,
403
- verifier: null,
404
- },
405
- host,
406
- cookieConfig,
407
- ),
465
+ cookies: structuredAuthCookies(
466
+ {
467
+ token: result.tokens?.token ?? null,
468
+ refreshToken: result.tokens?.refreshToken ?? null,
469
+ verifier: null,
470
+ },
471
+ host,
472
+ cookieConfig,
408
473
  ),
474
+ redirect: redirectUrl.toString(),
475
+ token: result.tokens?.token ?? null,
409
476
  };
410
477
  } catch (error) {
411
478
  console.error(error);
412
- const response = Response.redirect(redirectUrl.toString(), 302);
413
479
  return {
414
- response: attachCookies(
415
- response,
416
- serializeAuthCookies(
417
- {
418
- token: null,
419
- refreshToken: null,
420
- verifier: null,
421
- },
422
- host,
423
- cookieConfig,
424
- ),
480
+ cookies: structuredAuthCookies(
481
+ { token: null, refreshToken: null, verifier: null },
482
+ host,
483
+ cookieConfig,
425
484
  ),
485
+ redirect: redirectUrl.toString(),
486
+ token: null,
426
487
  };
427
488
  }
428
489
  }
429
490
 
491
+ // Normal page load — refresh tokens if needed.
430
492
  const tokens = await refreshTokens(request);
431
493
  if (tokens === undefined) {
432
- return {};
494
+ // No refresh needed — return current token for hydration.
495
+ return { cookies: [], token: currentToken };
433
496
  }
434
497
  return {
435
- cookies: serializeAuthCookies(
498
+ cookies: structuredAuthCookies(
436
499
  {
437
500
  token: tokens?.token ?? null,
438
501
  refreshToken: tokens?.refreshToken ?? null,
@@ -441,6 +504,7 @@ export function server(options: ServerOptions) {
441
504
  host,
442
505
  cookieConfig,
443
506
  ),
507
+ token: tokens?.token ?? null,
444
508
  };
445
509
  },
446
510
  };