@robelest/convex-auth 0.0.2-preview.1 → 0.0.2
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 +466 -63
- package/dist/client/index.d.ts +211 -30
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +673 -59
- 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 +93 -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 -3
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +5 -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 +193 -9
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +204 -33
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +89 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +68 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/{Anonymous.d.ts → anonymous.d.ts} +8 -8
- package/dist/providers/{Anonymous.d.ts.map → anonymous.d.ts.map} +1 -1
- package/dist/providers/{Anonymous.js → anonymous.js} +9 -10
- package/dist/providers/anonymous.js.map +1 -0
- package/dist/providers/{ConvexCredentials.d.ts → credentials.d.ts} +11 -11
- package/dist/providers/credentials.d.ts.map +1 -0
- package/dist/providers/{ConvexCredentials.js → credentials.js} +8 -8
- package/dist/providers/credentials.js.map +1 -0
- package/dist/providers/{Email.d.ts → email.d.ts} +6 -6
- package/dist/providers/email.d.ts.map +1 -0
- package/dist/providers/{Email.js → email.js} +6 -6
- package/dist/providers/email.js.map +1 -0
- 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/{Password.d.ts → password.d.ts} +10 -10
- package/dist/providers/{Password.d.ts.map → password.d.ts.map} +1 -1
- package/dist/providers/{Password.js → password.js} +19 -20
- package/dist/providers/password.js.map +1 -0
- package/dist/providers/{Phone.d.ts → phone.d.ts} +3 -3
- package/dist/providers/{Phone.d.ts.map → phone.d.ts.map} +1 -1
- package/dist/providers/{Phone.js → phone.js} +3 -3
- package/dist/providers/{Phone.js.map → phone.js.map} +1 -1
- 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 +243 -0
- package/dist/server/convex-auth.d.ts.map +1 -0
- package/dist/server/convex-auth.js +365 -0
- package/dist/server/convex-auth.js.map +1 -0
- package/dist/server/implementation/index.d.ts +153 -166
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +162 -105
- 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/sessions.d.ts +2 -20
- package/dist/server/implementation/sessions.d.ts.map +1 -1
- package/dist/server/implementation/sessions.js +2 -20
- package/dist/server/implementation/sessions.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 +26 -1
- 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 +18 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +255 -0
- 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/portal.d.ts +116 -0
- package/dist/server/portal.d.ts.map +1 -0
- package/dist/server/portal.js +294 -0
- package/dist/server/portal.js.map +1 -0
- package/dist/server/provider_utils.d.ts +1 -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 +128 -11
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/cli/index.ts +48 -6
- package/src/cli/portal-link.ts +112 -0
- package/src/cli/portal-upload.ts +411 -0
- package/src/client/index.ts +823 -109
- package/src/component/_generated/api.ts +72 -1
- package/src/component/_generated/component.ts +180 -4
- package/src/component/convex.config.ts +3 -0
- package/src/component/index.ts +5 -10
- package/src/component/portalBridge.ts +116 -0
- package/src/component/public.ts +231 -37
- package/src/component/schema.ts +70 -7
- package/src/providers/{Anonymous.ts → anonymous.ts} +10 -11
- package/src/providers/{ConvexCredentials.ts → credentials.ts} +11 -11
- package/src/providers/{Email.ts → email.ts} +5 -5
- package/src/providers/passkey.ts +35 -0
- package/src/providers/{Password.ts → password.ts} +22 -27
- package/src/providers/{Phone.ts → phone.ts} +2 -2
- package/src/providers/totp.ts +26 -0
- package/src/server/convex-auth.ts +470 -0
- package/src/server/implementation/index.ts +228 -239
- package/src/server/implementation/passkey.ts +650 -0
- package/src/server/implementation/redirects.ts +4 -11
- package/src/server/implementation/sessions.ts +2 -20
- package/src/server/implementation/signIn.ts +39 -1
- package/src/server/implementation/totp.ts +366 -0
- package/src/server/index.ts +373 -0
- package/src/server/portal-email.ts +95 -0
- package/src/server/portal.ts +375 -0
- package/src/server/provider_utils.ts +42 -1
- package/src/server/types.ts +161 -10
- package/dist/providers/Anonymous.js.map +0 -1
- package/dist/providers/ConvexCredentials.d.ts.map +0 -1
- package/dist/providers/ConvexCredentials.js.map +0 -1
- package/dist/providers/Email.d.ts.map +0 -1
- package/dist/providers/Email.js.map +0 -1
- package/dist/providers/Password.js.map +0 -1
- package/providers/Anonymous/package.json +0 -6
- package/providers/ConvexCredentials/package.json +0 -6
- package/providers/Email/package.json +0 -6
- package/providers/Password/package.json +0 -6
- package/providers/Phone/package.json +0 -6
- package/server/package.json +0 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Configure {@link
|
|
2
|
+
* Configure {@link password} provider given a {@link PasswordConfig}.
|
|
3
3
|
*
|
|
4
|
-
* The `
|
|
4
|
+
* The `password` provider supports the following flows, determined
|
|
5
5
|
* by the `flow` parameter:
|
|
6
6
|
*
|
|
7
7
|
* - `"signUp"`: Create a new account with a password.
|
|
@@ -12,29 +12,24 @@
|
|
|
12
12
|
* included in params, verify an OTP.
|
|
13
13
|
*
|
|
14
14
|
* ```ts
|
|
15
|
-
* import
|
|
16
|
-
* import {
|
|
15
|
+
* import password from "@robelest/convex-auth/providers/password";
|
|
16
|
+
* import { Auth } from "@robelest/convex-auth/component";
|
|
17
17
|
*
|
|
18
|
-
* export const { auth, signIn, signOut, store } =
|
|
19
|
-
* providers: [
|
|
18
|
+
* export const { auth, signIn, signOut, store } = Auth({
|
|
19
|
+
* providers: [password],
|
|
20
20
|
* });
|
|
21
21
|
* ```
|
|
22
22
|
*
|
|
23
23
|
* @module
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
} from "@robelest/convex-auth/providers/
|
|
26
|
+
import credentials, {
|
|
27
|
+
CredentialsUserConfig,
|
|
28
|
+
} from "@robelest/convex-auth/providers/credentials";
|
|
29
29
|
import {
|
|
30
30
|
EmailConfig,
|
|
31
31
|
GenericActionCtxWithAuthConfig,
|
|
32
32
|
GenericDoc,
|
|
33
|
-
createAccount,
|
|
34
|
-
invalidateSessions,
|
|
35
|
-
modifyAccountCredentials,
|
|
36
|
-
retrieveAccount,
|
|
37
|
-
signInViaProvider,
|
|
38
33
|
} from "@robelest/convex-auth/component";
|
|
39
34
|
import {
|
|
40
35
|
DocumentByName,
|
|
@@ -45,7 +40,7 @@ import { Value } from "convex/values";
|
|
|
45
40
|
import { Scrypt } from "lucia";
|
|
46
41
|
|
|
47
42
|
/**
|
|
48
|
-
* The available options to a {@link
|
|
43
|
+
* The available options to a {@link password} provider for Convex Auth.
|
|
49
44
|
*/
|
|
50
45
|
export interface PasswordConfig<DataModel extends GenericDataModel> {
|
|
51
46
|
/**
|
|
@@ -89,7 +84,7 @@ export interface PasswordConfig<DataModel extends GenericDataModel> {
|
|
|
89
84
|
* Provide hashing and verification functions if you want to control
|
|
90
85
|
* how passwords are hashed.
|
|
91
86
|
*/
|
|
92
|
-
crypto?:
|
|
87
|
+
crypto?: CredentialsUserConfig["crypto"];
|
|
93
88
|
/**
|
|
94
89
|
* An Auth.js email provider used to require verification
|
|
95
90
|
* before password reset.
|
|
@@ -115,7 +110,7 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
115
110
|
config: PasswordConfig<DataModel> = {},
|
|
116
111
|
) {
|
|
117
112
|
const provider = config.id ?? "password";
|
|
118
|
-
return
|
|
113
|
+
return credentials<DataModel>({
|
|
119
114
|
id: "password",
|
|
120
115
|
authorize: async (params, ctx) => {
|
|
121
116
|
const flow = params.flow as string;
|
|
@@ -141,7 +136,7 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
141
136
|
if (secret === undefined) {
|
|
142
137
|
throw new Error("Missing `password` param for `signUp` flow");
|
|
143
138
|
}
|
|
144
|
-
const created = await
|
|
139
|
+
const created = await ctx.auth.account.create(ctx, {
|
|
145
140
|
provider,
|
|
146
141
|
account: { id: email, secret },
|
|
147
142
|
profile: profile as any,
|
|
@@ -153,7 +148,7 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
153
148
|
if (secret === undefined) {
|
|
154
149
|
throw new Error("Missing `password` param for `signIn` flow");
|
|
155
150
|
}
|
|
156
|
-
const retrieved = await
|
|
151
|
+
const retrieved = await ctx.auth.account.get(ctx, {
|
|
157
152
|
provider,
|
|
158
153
|
account: { id: email, secret },
|
|
159
154
|
});
|
|
@@ -166,11 +161,11 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
166
161
|
if (!config.reset) {
|
|
167
162
|
throw new Error(`Password reset is not enabled for ${provider}`);
|
|
168
163
|
}
|
|
169
|
-
const { account } = await
|
|
164
|
+
const { account } = await ctx.auth.account.get(ctx, {
|
|
170
165
|
provider,
|
|
171
166
|
account: { id: email },
|
|
172
167
|
});
|
|
173
|
-
return await
|
|
168
|
+
return await ctx.auth.provider.signIn(ctx, config.reset, {
|
|
174
169
|
accountId: account._id,
|
|
175
170
|
params,
|
|
176
171
|
});
|
|
@@ -183,17 +178,17 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
183
178
|
"Missing `newPassword` param for `reset-verification` flow",
|
|
184
179
|
);
|
|
185
180
|
}
|
|
186
|
-
const result = await
|
|
181
|
+
const result = await ctx.auth.provider.signIn(ctx, config.reset, { params });
|
|
187
182
|
if (result === null) {
|
|
188
183
|
throw new Error("Invalid code");
|
|
189
184
|
}
|
|
190
185
|
const { userId, sessionId } = result;
|
|
191
186
|
const secret = params.newPassword as string;
|
|
192
|
-
await
|
|
187
|
+
await ctx.auth.account.updateCredentials(ctx, {
|
|
193
188
|
provider,
|
|
194
189
|
account: { id: email, secret },
|
|
195
190
|
});
|
|
196
|
-
await
|
|
191
|
+
await ctx.auth.session.invalidate(ctx, { userId, except: [sessionId] });
|
|
197
192
|
return { userId, sessionId };
|
|
198
193
|
// END
|
|
199
194
|
// START: Optional, email verification during sign in
|
|
@@ -201,11 +196,11 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
201
196
|
if (!config.verify) {
|
|
202
197
|
throw new Error(`Email verification is not enabled for ${provider}`);
|
|
203
198
|
}
|
|
204
|
-
const { account } = await
|
|
199
|
+
const { account } = await ctx.auth.account.get(ctx, {
|
|
205
200
|
provider,
|
|
206
201
|
account: { id: email },
|
|
207
202
|
});
|
|
208
|
-
return await
|
|
203
|
+
return await ctx.auth.provider.signIn(ctx, config.verify, {
|
|
209
204
|
accountId: account._id,
|
|
210
205
|
params,
|
|
211
206
|
});
|
|
@@ -219,7 +214,7 @@ export default function password<DataModel extends GenericDataModel>(
|
|
|
219
214
|
}
|
|
220
215
|
// START: Optional, email verification during sign in
|
|
221
216
|
if (config.verify && !account.emailVerified) {
|
|
222
|
-
return await
|
|
217
|
+
return await ctx.auth.provider.signIn(ctx, config.verify, {
|
|
223
218
|
accountId: account._id,
|
|
224
219
|
params,
|
|
225
220
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Configure {@link
|
|
2
|
+
* Configure {@link phone} provider given a {@link PhoneUserConfig}.
|
|
3
3
|
*
|
|
4
4
|
* Simplifies creating phone providers.
|
|
5
5
|
*
|
|
6
|
-
* By default checks that there is
|
|
6
|
+
* By default checks that there is a `phone` field during token verification
|
|
7
7
|
* that matches the `phone` used during the initial `signIn` call.
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TotpProviderConfig } from "../server/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Add TOTP (Time-based One-Time Password) authentication.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import TOTP from "@robelest/convex-auth/providers/totp";
|
|
8
|
+
*
|
|
9
|
+
* export const { auth, signIn, signOut, store } = Auth({
|
|
10
|
+
* providers: [TOTP({ issuer: "My App" })],
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export default function totp(
|
|
15
|
+
config?: Partial<TotpProviderConfig["options"]>,
|
|
16
|
+
): TotpProviderConfig {
|
|
17
|
+
return {
|
|
18
|
+
id: "totp",
|
|
19
|
+
type: "totp",
|
|
20
|
+
options: {
|
|
21
|
+
issuer: config?.issuer ?? "ConvexAuth",
|
|
22
|
+
digits: config?.digits ?? 6,
|
|
23
|
+
period: config?.period ?? 30,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Auth` class — the main entry point for Convex Auth.
|
|
3
|
+
*
|
|
4
|
+
* Combines authentication and portal admin functionality:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // convex/auth.ts
|
|
8
|
+
* import { Auth, Portal } from "@robelest/convex-auth/component";
|
|
9
|
+
* import github from "@auth/core/providers/github";
|
|
10
|
+
* import { components } from "./_generated/api";
|
|
11
|
+
*
|
|
12
|
+
* export const auth = new Auth(components.auth, {
|
|
13
|
+
* providers: [github],
|
|
14
|
+
* });
|
|
15
|
+
* export const { signIn, signOut, store } = auth;
|
|
16
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
queryGeneric,
|
|
24
|
+
mutationGeneric,
|
|
25
|
+
internalMutationGeneric,
|
|
26
|
+
} from "convex/server";
|
|
27
|
+
import type { HttpRouter } from "convex/server";
|
|
28
|
+
import { v } from "convex/values";
|
|
29
|
+
import type { ComponentApi as AuthComponentApi } from "../component/_generated/component.js";
|
|
30
|
+
import { Auth as AuthFactory } from "./implementation/index.js";
|
|
31
|
+
import type { ConvexAuthConfig } from "./types.js";
|
|
32
|
+
import { registerStaticRoutes } from "@convex-dev/self-hosting";
|
|
33
|
+
import { portalMagicLinkEmail } from "./portal-email.js";
|
|
34
|
+
import email from "../providers/email.js";
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Config for the ConvexAuth class. Extends the standard auth config.
|
|
42
|
+
*
|
|
43
|
+
* Portal functionality (admin dashboard, magic link provider, static hosting)
|
|
44
|
+
* is always available — no configuration flag needed. The portal UI works
|
|
45
|
+
* when you export `portalQuery`, `portalMutation`, `portalInternal` from
|
|
46
|
+
* your `convex/auth.ts` and upload the portal static files via CLI.
|
|
47
|
+
*/
|
|
48
|
+
export type AuthClassConfig = Omit<ConvexAuthConfig, "component">;
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Helpers
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if the authenticated user is a portal admin.
|
|
56
|
+
* Uses the new index `roleAndStatusAndAcceptedByUserId` for efficient lookup.
|
|
57
|
+
*/
|
|
58
|
+
async function requirePortalAdmin(
|
|
59
|
+
ctx: any,
|
|
60
|
+
authComponent: AuthComponentApi,
|
|
61
|
+
userId: string,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
// Use inviteList with status filter, then check role + userId in-memory.
|
|
64
|
+
// The new index makes the status filter efficient.
|
|
65
|
+
const invites = await ctx.runQuery(authComponent.public.inviteList, {
|
|
66
|
+
status: "accepted",
|
|
67
|
+
});
|
|
68
|
+
const isAdmin = invites.some(
|
|
69
|
+
(invite: any) =>
|
|
70
|
+
invite.role === "portalAdmin" && invite.acceptedByUserId === userId,
|
|
71
|
+
);
|
|
72
|
+
if (!isAdmin) {
|
|
73
|
+
throw new Error("Not authorized: portal admin access required");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Auth class
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Main entry point for Convex Auth. Instantiate with your component
|
|
83
|
+
* reference and config to get all the exports you need.
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* export const auth = new Auth(components.auth, {
|
|
87
|
+
* providers: [github, resend({ ... })],
|
|
88
|
+
* });
|
|
89
|
+
* export const { signIn, signOut, store } = auth;
|
|
90
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export class Auth {
|
|
94
|
+
/** The inner `auth` helper object from AuthFactory() */
|
|
95
|
+
private readonly _auth: ReturnType<typeof AuthFactory>["auth"];
|
|
96
|
+
/** The signIn action — export this from your convex/auth.ts */
|
|
97
|
+
public readonly signIn: ReturnType<typeof AuthFactory>["signIn"];
|
|
98
|
+
/** The signOut action — export this from your convex/auth.ts */
|
|
99
|
+
public readonly signOut: ReturnType<typeof AuthFactory>["signOut"];
|
|
100
|
+
/** The store internal mutation — export this from your convex/auth.ts */
|
|
101
|
+
public readonly store: ReturnType<typeof AuthFactory>["store"];
|
|
102
|
+
|
|
103
|
+
/** @internal */
|
|
104
|
+
readonly component: AuthComponentApi;
|
|
105
|
+
/** @internal */
|
|
106
|
+
readonly portalUrl: string;
|
|
107
|
+
|
|
108
|
+
// ---- Proxied auth helper sub-objects ----
|
|
109
|
+
/** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.viewer(ctx)` */
|
|
110
|
+
get user() { return this._auth.user; }
|
|
111
|
+
/** Session helpers */
|
|
112
|
+
get session() { return this._auth.session; }
|
|
113
|
+
/** Provider helpers */
|
|
114
|
+
get provider() { return this._auth.provider; }
|
|
115
|
+
/** Account helpers */
|
|
116
|
+
get account() { return this._auth.account; }
|
|
117
|
+
/** Group helpers */
|
|
118
|
+
get group() { return this._auth.group; }
|
|
119
|
+
/** Invite helpers */
|
|
120
|
+
get invite() { return this._auth.invite; }
|
|
121
|
+
/** Passkey helpers */
|
|
122
|
+
get passkey() { return this._auth.passkey; }
|
|
123
|
+
/** TOTP helpers */
|
|
124
|
+
get totp() { return this._auth.totp; }
|
|
125
|
+
|
|
126
|
+
constructor(component: AuthComponentApi, config: AuthClassConfig) {
|
|
127
|
+
this.component = component;
|
|
128
|
+
|
|
129
|
+
// Derive portal URL from CONVEX_SITE_URL
|
|
130
|
+
this.portalUrl = process.env.CONVEX_SITE_URL
|
|
131
|
+
? `${process.env.CONVEX_SITE_URL.replace(/\/$/, "")}/auth`
|
|
132
|
+
: "/auth";
|
|
133
|
+
|
|
134
|
+
// Auto-register the `portal` email provider for magic link sign-in
|
|
135
|
+
const providers = [...config.providers];
|
|
136
|
+
providers.push(
|
|
137
|
+
email({
|
|
138
|
+
id: "portal",
|
|
139
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
140
|
+
authorize: undefined, // Magic link — no email check needed
|
|
141
|
+
async sendVerificationRequest({ identifier, url, expires }) {
|
|
142
|
+
const hours = Math.max(
|
|
143
|
+
1,
|
|
144
|
+
Math.floor((+expires - Date.now()) / (60 * 60 * 1000)),
|
|
145
|
+
);
|
|
146
|
+
const html = portalMagicLinkEmail(url, hours);
|
|
147
|
+
const siteUrl = process.env.CONVEX_SITE_URL;
|
|
148
|
+
if (!siteUrl) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"CONVEX_SITE_URL is required to send portal magic link email",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const response = await fetch(`${siteUrl}/auth-email-dispatch`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
...(process.env.AUTH_EMAIL_DISPATCH_SECRET
|
|
158
|
+
? {
|
|
159
|
+
"x-auth-email-dispatch-secret":
|
|
160
|
+
process.env.AUTH_EMAIL_DISPATCH_SECRET,
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
to: identifier,
|
|
166
|
+
subject: "Sign in to Convex Auth Portal",
|
|
167
|
+
html,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Could not send portal magic link email: ${response.status}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Initialize the core AuthFactory()
|
|
180
|
+
const authResult = AuthFactory({
|
|
181
|
+
...config,
|
|
182
|
+
component,
|
|
183
|
+
providers,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this._auth = authResult.auth;
|
|
187
|
+
this.signIn = authResult.signIn;
|
|
188
|
+
this.signOut = authResult.signOut;
|
|
189
|
+
this.store = authResult.store;
|
|
190
|
+
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Register HTTP routes for OAuth, JWT well-known endpoints, and portal
|
|
195
|
+
* static file serving.
|
|
196
|
+
*
|
|
197
|
+
* ```ts
|
|
198
|
+
* // convex/http.ts
|
|
199
|
+
* import { httpRouter } from "convex/server";
|
|
200
|
+
* import { auth } from "./auth";
|
|
201
|
+
*
|
|
202
|
+
* const http = httpRouter();
|
|
203
|
+
* auth.addHttpRoutes(http);
|
|
204
|
+
* export default http;
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
addHttpRoutes(
|
|
208
|
+
http: HttpRouter,
|
|
209
|
+
opts?: { pathPrefix?: string; spaFallback?: boolean },
|
|
210
|
+
): void {
|
|
211
|
+
// Core auth routes (OAuth, JWKS, etc.)
|
|
212
|
+
this._auth.addHttpRoutes(http);
|
|
213
|
+
|
|
214
|
+
// Portal static file serving
|
|
215
|
+
const prefix = opts?.pathPrefix ?? "/auth";
|
|
216
|
+
|
|
217
|
+
// Create a shim that maps the self-hosting ComponentApi shape
|
|
218
|
+
// to the auth component's portalBridge functions
|
|
219
|
+
const selfHostingShim = {
|
|
220
|
+
lib: {
|
|
221
|
+
getByPath: this.component.portalBridge.getByPath,
|
|
222
|
+
getCurrentDeployment: this.component.portalBridge.getCurrentDeployment,
|
|
223
|
+
listAssets: this.component.portalBridge.listAssets,
|
|
224
|
+
recordAsset: this.component.portalBridge.recordAsset,
|
|
225
|
+
gcOldAssets: this.component.portalBridge.gcOldAssets,
|
|
226
|
+
setCurrentDeployment: this.component.portalBridge.setCurrentDeployment,
|
|
227
|
+
// generateUploadUrl is not needed — we use app storage directly
|
|
228
|
+
generateUploadUrl: undefined as any,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
registerStaticRoutes(http, selfHostingShim as any, {
|
|
233
|
+
pathPrefix: prefix,
|
|
234
|
+
spaFallback: opts?.spaFallback ?? true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Portal exports (standalone function)
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Create portal function definitions from a ConvexAuth instance.
|
|
245
|
+
*
|
|
246
|
+
* This is a standalone function (not a class method) because Convex's
|
|
247
|
+
* bundler can trace through `export const { x } = fn(instance)` but
|
|
248
|
+
* cannot trace through `instance.method()`.
|
|
249
|
+
*
|
|
250
|
+
* ```ts
|
|
251
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
export function Portal(auth: Auth) {
|
|
255
|
+
const authComponent = auth.component;
|
|
256
|
+
const authHelper = (auth as any)._auth;
|
|
257
|
+
const portalUrl = auth.portalUrl;
|
|
258
|
+
|
|
259
|
+
const portalQuery = queryGeneric({
|
|
260
|
+
args: {
|
|
261
|
+
action: v.string(),
|
|
262
|
+
userId: v.optional(v.string()),
|
|
263
|
+
},
|
|
264
|
+
handler: async (
|
|
265
|
+
ctx: any,
|
|
266
|
+
{ action, userId }: { action: string; userId?: string },
|
|
267
|
+
) => {
|
|
268
|
+
const currentUserId = await authHelper.user.require(ctx);
|
|
269
|
+
|
|
270
|
+
// Allow isAdmin check without admin requirement
|
|
271
|
+
if (action === "isAdmin") {
|
|
272
|
+
try {
|
|
273
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
281
|
+
|
|
282
|
+
switch (action) {
|
|
283
|
+
case "listUsers":
|
|
284
|
+
return await ctx.runQuery(authComponent.public.userList);
|
|
285
|
+
|
|
286
|
+
case "listSessions":
|
|
287
|
+
return await ctx.runQuery(authComponent.public.sessionList);
|
|
288
|
+
|
|
289
|
+
case "getUser":
|
|
290
|
+
return await ctx.runQuery(authComponent.public.userGetById, {
|
|
291
|
+
userId: userId!,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
case "getUserSessions":
|
|
295
|
+
return await ctx.runQuery(authComponent.public.sessionListByUser, {
|
|
296
|
+
userId: userId!,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
case "getUserAccounts": {
|
|
300
|
+
const accounts = await ctx.runQuery(
|
|
301
|
+
authComponent.public.accountListByUser,
|
|
302
|
+
{ userId: userId! },
|
|
303
|
+
);
|
|
304
|
+
// Strip secrets — never send password hashes to the frontend
|
|
305
|
+
return accounts.map(({ secret: _, ...rest }: any) => rest);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Invite validation (public within portal context)
|
|
309
|
+
case "validateInvite": {
|
|
310
|
+
// userId param repurposed as tokenHash for this action
|
|
311
|
+
const tokenHash = userId;
|
|
312
|
+
if (!tokenHash) throw new Error("tokenHash required");
|
|
313
|
+
const invite = await ctx.runQuery(
|
|
314
|
+
authComponent.public.inviteGetByTokenHash,
|
|
315
|
+
{ tokenHash },
|
|
316
|
+
);
|
|
317
|
+
if (!invite || invite.status !== "pending") {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
if (invite.expiresTime && invite.expiresTime < Date.now()) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return { _id: invite._id, role: invite.role };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case "getCurrentDeployment":
|
|
327
|
+
return await ctx.runQuery(
|
|
328
|
+
authComponent.portalBridge.getCurrentDeployment,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
default:
|
|
332
|
+
throw new Error(`Unknown portal query action: ${action}`);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const portalMutation = mutationGeneric({
|
|
338
|
+
args: {
|
|
339
|
+
action: v.string(),
|
|
340
|
+
sessionId: v.optional(v.string()),
|
|
341
|
+
tokenHash: v.optional(v.string()),
|
|
342
|
+
},
|
|
343
|
+
handler: async (
|
|
344
|
+
ctx: any,
|
|
345
|
+
{
|
|
346
|
+
action,
|
|
347
|
+
sessionId,
|
|
348
|
+
tokenHash,
|
|
349
|
+
}: { action: string; sessionId?: string; tokenHash?: string },
|
|
350
|
+
) => {
|
|
351
|
+
const currentUserId = await authHelper.user.require(ctx);
|
|
352
|
+
|
|
353
|
+
switch (action) {
|
|
354
|
+
case "acceptInvite": {
|
|
355
|
+
if (!tokenHash) throw new Error("tokenHash required");
|
|
356
|
+
const invite = await ctx.runQuery(
|
|
357
|
+
authComponent.public.inviteGetByTokenHash,
|
|
358
|
+
{ tokenHash },
|
|
359
|
+
);
|
|
360
|
+
if (!invite) throw new Error("Invalid invite token");
|
|
361
|
+
if (invite.status !== "pending") {
|
|
362
|
+
throw new Error(`Invite already ${invite.status}`);
|
|
363
|
+
}
|
|
364
|
+
if (invite.expiresTime && invite.expiresTime < Date.now()) {
|
|
365
|
+
throw new Error("Invite has expired");
|
|
366
|
+
}
|
|
367
|
+
await ctx.runMutation(authComponent.public.inviteAccept, {
|
|
368
|
+
inviteId: invite._id,
|
|
369
|
+
acceptedByUserId: currentUserId,
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case "revokeSession": {
|
|
375
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
376
|
+
await ctx.runMutation(authComponent.public.sessionDelete, {
|
|
377
|
+
sessionId: sessionId!,
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
default:
|
|
383
|
+
throw new Error(`Unknown portal mutation action: ${action}`);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const portalInternal = internalMutationGeneric({
|
|
389
|
+
args: {
|
|
390
|
+
action: v.string(),
|
|
391
|
+
tokenHash: v.optional(v.string()),
|
|
392
|
+
path: v.optional(v.string()),
|
|
393
|
+
storageId: v.optional(v.string()),
|
|
394
|
+
blobId: v.optional(v.string()),
|
|
395
|
+
contentType: v.optional(v.string()),
|
|
396
|
+
deploymentId: v.optional(v.string()),
|
|
397
|
+
currentDeploymentId: v.optional(v.string()),
|
|
398
|
+
limit: v.optional(v.number()),
|
|
399
|
+
},
|
|
400
|
+
handler: async (ctx: any, args: any) => {
|
|
401
|
+
switch (args.action) {
|
|
402
|
+
// ---- Invite management (CLI) ----
|
|
403
|
+
case "createPortalInvite": {
|
|
404
|
+
await ctx.runMutation(authComponent.public.inviteCreate, {
|
|
405
|
+
tokenHash: args.tokenHash,
|
|
406
|
+
role: "portalAdmin",
|
|
407
|
+
status: "pending" as const,
|
|
408
|
+
});
|
|
409
|
+
return { portalUrl };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- Static hosting (CLI upload) ----
|
|
413
|
+
case "generateUploadUrl": {
|
|
414
|
+
return await ctx.storage.generateUploadUrl();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case "recordAsset": {
|
|
418
|
+
const { oldStorageId, oldBlobId } = await ctx.runMutation(
|
|
419
|
+
authComponent.portalBridge.recordAsset,
|
|
420
|
+
{
|
|
421
|
+
path: args.path,
|
|
422
|
+
...(args.storageId ? { storageId: args.storageId } : {}),
|
|
423
|
+
...(args.blobId ? { blobId: args.blobId } : {}),
|
|
424
|
+
contentType: args.contentType,
|
|
425
|
+
deploymentId: args.deploymentId,
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
if (oldStorageId) {
|
|
429
|
+
try {
|
|
430
|
+
await ctx.storage.delete(oldStorageId);
|
|
431
|
+
} catch {
|
|
432
|
+
// Ignore — old file may have been in different storage
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return oldBlobId ?? null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
case "gcOldAssets": {
|
|
439
|
+
const { storageIds, blobIds } = await ctx.runMutation(
|
|
440
|
+
authComponent.portalBridge.gcOldAssets,
|
|
441
|
+
{ currentDeploymentId: args.currentDeploymentId },
|
|
442
|
+
);
|
|
443
|
+
for (const storageId of storageIds) {
|
|
444
|
+
try {
|
|
445
|
+
await ctx.storage.delete(storageId);
|
|
446
|
+
} catch {
|
|
447
|
+
// Ignore
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
await ctx.runMutation(
|
|
451
|
+
authComponent.portalBridge.setCurrentDeployment,
|
|
452
|
+
{ deploymentId: args.currentDeploymentId },
|
|
453
|
+
);
|
|
454
|
+
return { deleted: storageIds.length, blobIds };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
case "listAssets": {
|
|
458
|
+
return await ctx.runQuery(authComponent.portalBridge.listAssets, {
|
|
459
|
+
limit: args.limit,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
default:
|
|
464
|
+
throw new Error(`Unknown portalInternal action: ${args.action}`);
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return { portalQuery, portalMutation, portalInternal };
|
|
470
|
+
}
|