@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.
- package/dist/bin.cjs +467 -64
- package/dist/client/index.d.ts +127 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +424 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +56 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +141 -3
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/index.d.ts +5 -4
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +4 -3
- package/dist/component/index.js.map +1 -1
- package/dist/component/portalBridge.d.ts +80 -0
- package/dist/component/portalBridge.d.ts.map +1 -0
- package/dist/component/portalBridge.js +102 -0
- package/dist/component/portalBridge.js.map +1 -0
- package/dist/component/public.d.ts +353 -9
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +328 -33
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +168 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +113 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/passkey.d.ts +20 -0
- package/dist/providers/passkey.d.ts.map +1 -0
- package/dist/providers/passkey.js +32 -0
- package/dist/providers/passkey.js.map +1 -0
- package/dist/providers/totp.d.ts +14 -0
- package/dist/providers/totp.d.ts.map +1 -0
- package/dist/providers/totp.js +23 -0
- package/dist/providers/totp.js.map +1 -0
- package/dist/server/convex-auth.d.ts +296 -0
- package/dist/server/convex-auth.d.ts.map +1 -0
- package/dist/server/convex-auth.js +480 -0
- package/dist/server/convex-auth.js.map +1 -0
- package/dist/server/email-templates.d.ts +18 -0
- package/dist/server/email-templates.d.ts.map +1 -0
- package/dist/server/email-templates.js +74 -0
- package/dist/server/email-templates.js.map +1 -0
- package/dist/server/implementation/apiKey.d.ts +74 -0
- package/dist/server/implementation/apiKey.d.ts.map +1 -0
- package/dist/server/implementation/apiKey.js +140 -0
- package/dist/server/implementation/apiKey.js.map +1 -0
- package/dist/server/implementation/index.d.ts +169 -7
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +220 -5
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/passkey.d.ts +33 -0
- package/dist/server/implementation/passkey.d.ts.map +1 -0
- package/dist/server/implementation/passkey.js +450 -0
- package/dist/server/implementation/passkey.js.map +1 -0
- package/dist/server/implementation/redirects.d.ts.map +1 -1
- package/dist/server/implementation/redirects.js +4 -9
- package/dist/server/implementation/redirects.js.map +1 -1
- package/dist/server/implementation/signIn.d.ts +13 -0
- package/dist/server/implementation/signIn.d.ts.map +1 -1
- package/dist/server/implementation/signIn.js +29 -15
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/implementation/totp.d.ts +40 -0
- package/dist/server/implementation/totp.d.ts.map +1 -0
- package/dist/server/implementation/totp.js +211 -0
- package/dist/server/implementation/totp.js.map +1 -0
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +63 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/portal-email.d.ts +19 -0
- package/dist/server/portal-email.d.ts.map +1 -0
- package/dist/server/portal-email.js +89 -0
- package/dist/server/portal-email.js.map +1 -0
- package/dist/server/provider_utils.d.ts +3 -1
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/provider_utils.js +39 -1
- package/dist/server/provider_utils.js.map +1 -1
- package/dist/server/types.d.ts +263 -4
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.d.ts.map +1 -0
- package/dist/server/version.js +3 -0
- package/dist/server/version.js.map +1 -0
- package/package.json +7 -3
- package/src/cli/index.ts +49 -7
- package/src/cli/portal-link.ts +112 -0
- package/src/cli/portal-upload.ts +411 -0
- package/src/cli/utils.ts +248 -0
- package/src/client/index.ts +489 -1
- package/src/component/_generated/api.ts +72 -1
- package/src/component/_generated/component.ts +241 -4
- package/src/component/convex.config.ts +3 -0
- package/src/component/index.ts +8 -3
- package/src/component/portalBridge.ts +116 -0
- package/src/component/public.ts +373 -37
- package/src/component/schema.ts +122 -7
- package/src/providers/passkey.ts +35 -0
- package/src/providers/totp.ts +26 -0
- package/src/server/convex-auth.ts +602 -0
- package/src/server/email-templates.ts +77 -0
- package/src/server/implementation/apiKey.ts +185 -0
- package/src/server/implementation/index.ts +301 -8
- package/src/server/implementation/passkey.ts +650 -0
- package/src/server/implementation/redirects.ts +4 -11
- package/src/server/implementation/signIn.ts +41 -13
- package/src/server/implementation/totp.ts +366 -0
- package/src/server/index.ts +98 -34
- package/src/server/portal-email.ts +95 -0
- package/src/server/provider_utils.ts +42 -1
- package/src/server/types.ts +285 -4
- 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
|
-
|
|
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<
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
31
|
-
cookies
|
|
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:
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
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
|
};
|