@robelest/convex-auth 0.0.2 → 0.0.3-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.cjs +1 -1
- package/dist/client/index.d.ts +33 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +79 -13
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +48 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/index.d.ts +10 -4
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +8 -3
- package/dist/component/index.js.map +1 -1
- package/dist/component/public.d.ts +163 -3
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +81 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +45 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/anonymous.d.ts +3 -0
- package/dist/providers/anonymous.d.ts.map +1 -1
- package/dist/providers/anonymous.js +3 -0
- package/dist/providers/anonymous.js.map +1 -1
- package/dist/providers/credentials.d.ts +3 -0
- package/dist/providers/credentials.d.ts.map +1 -1
- package/dist/providers/credentials.js +3 -0
- package/dist/providers/credentials.js.map +1 -1
- package/dist/providers/email.d.ts +3 -0
- package/dist/providers/email.d.ts.map +1 -1
- package/dist/providers/email.js +3 -0
- package/dist/providers/email.js.map +1 -1
- package/dist/providers/passkey.d.ts +7 -1
- package/dist/providers/passkey.d.ts.map +1 -1
- package/dist/providers/passkey.js +7 -1
- package/dist/providers/passkey.js.map +1 -1
- package/dist/providers/password.d.ts +3 -0
- package/dist/providers/password.d.ts.map +1 -1
- package/dist/providers/password.js +3 -0
- package/dist/providers/password.js.map +1 -1
- package/dist/providers/phone.d.ts +3 -0
- package/dist/providers/phone.d.ts.map +1 -1
- package/dist/providers/phone.js +3 -0
- package/dist/providers/phone.js.map +1 -1
- package/dist/providers/totp.d.ts +8 -0
- package/dist/providers/totp.d.ts.map +1 -1
- package/dist/providers/totp.js +8 -0
- package/dist/providers/totp.js.map +1 -1
- package/dist/server/convex-auth.d.ts +185 -25
- package/dist/server/convex-auth.d.ts.map +1 -1
- package/dist/server/convex-auth.js +317 -58
- package/dist/server/convex-auth.js.map +1 -1
- 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/errors.d.ts +146 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +176 -0
- package/dist/server/errors.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 +139 -0
- package/dist/server/implementation/apiKey.js.map +1 -0
- package/dist/server/implementation/index.d.ts +151 -14
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +216 -24
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/mutations/createAccountFromCredentials.d.ts.map +1 -1
- package/dist/server/implementation/mutations/createAccountFromCredentials.js +2 -1
- package/dist/server/implementation/mutations/createAccountFromCredentials.js.map +1 -1
- package/dist/server/implementation/mutations/createVerificationCode.d.ts +2 -2
- package/dist/server/implementation/mutations/index.d.ts +6 -6
- package/dist/server/implementation/mutations/modifyAccount.d.ts.map +1 -1
- package/dist/server/implementation/mutations/modifyAccount.js +2 -1
- package/dist/server/implementation/mutations/modifyAccount.js.map +1 -1
- package/dist/server/implementation/mutations/userOAuth.d.ts.map +1 -1
- package/dist/server/implementation/mutations/userOAuth.js +2 -1
- package/dist/server/implementation/mutations/userOAuth.js.map +1 -1
- package/dist/server/implementation/mutations/verifierSignature.d.ts.map +1 -1
- package/dist/server/implementation/mutations/verifierSignature.js +2 -1
- package/dist/server/implementation/mutations/verifierSignature.js.map +1 -1
- package/dist/server/implementation/passkey.d.ts.map +1 -1
- package/dist/server/implementation/passkey.js +28 -29
- package/dist/server/implementation/passkey.js.map +1 -1
- package/dist/server/implementation/provider.d.ts.map +1 -1
- package/dist/server/implementation/provider.js +5 -4
- package/dist/server/implementation/provider.js.map +1 -1
- package/dist/server/implementation/redirects.d.ts.map +1 -1
- package/dist/server/implementation/redirects.js +2 -1
- package/dist/server/implementation/redirects.js.map +1 -1
- package/dist/server/implementation/refreshTokens.d.ts.map +1 -1
- package/dist/server/implementation/refreshTokens.js +2 -1
- package/dist/server/implementation/refreshTokens.js.map +1 -1
- package/dist/server/implementation/signIn.d.ts.map +1 -1
- package/dist/server/implementation/signIn.js +8 -18
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/implementation/totp.d.ts.map +1 -1
- package/dist/server/implementation/totp.js +16 -17
- package/dist/server/implementation/totp.js.map +1 -1
- package/dist/server/implementation/users.d.ts.map +1 -1
- package/dist/server/implementation/users.js +3 -2
- package/dist/server/implementation/users.js.map +1 -1
- package/dist/server/index.d.ts +157 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +180 -17
- package/dist/server/index.js.map +1 -1
- package/dist/server/oauth/authorizationUrl.d.ts.map +1 -1
- package/dist/server/oauth/authorizationUrl.js +2 -1
- package/dist/server/oauth/authorizationUrl.js.map +1 -1
- package/dist/server/oauth/callback.d.ts.map +1 -1
- package/dist/server/oauth/callback.js +5 -4
- package/dist/server/oauth/callback.js.map +1 -1
- package/dist/server/oauth/checks.d.ts.map +1 -1
- package/dist/server/oauth/checks.js +2 -1
- package/dist/server/oauth/checks.js.map +1 -1
- package/dist/server/oauth/convexAuth.d.ts.map +1 -1
- package/dist/server/oauth/convexAuth.js +3 -2
- package/dist/server/oauth/convexAuth.js.map +1 -1
- package/dist/server/provider_utils.d.ts +2 -0
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/types.d.ts +240 -5
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils.d.ts.map +1 -1
- package/dist/server/utils.js +2 -1
- package/dist/server/utils.js.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 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/utils.ts +248 -0
- package/src/client/index.ts +105 -15
- package/src/component/_generated/component.ts +61 -0
- package/src/component/index.ts +11 -2
- package/src/component/public.ts +142 -0
- package/src/component/schema.ts +52 -0
- package/src/providers/anonymous.ts +3 -0
- package/src/providers/credentials.ts +3 -0
- package/src/providers/email.ts +3 -0
- package/src/providers/passkey.ts +8 -1
- package/src/providers/password.ts +3 -0
- package/src/providers/phone.ts +3 -0
- package/src/providers/totp.ts +9 -0
- package/src/server/convex-auth.ts +385 -73
- package/src/server/email-templates.ts +77 -0
- package/src/server/errors.ts +269 -0
- package/src/server/implementation/apiKey.ts +186 -0
- package/src/server/implementation/index.ts +288 -28
- package/src/server/implementation/mutations/createAccountFromCredentials.ts +2 -1
- package/src/server/implementation/mutations/modifyAccount.ts +2 -3
- package/src/server/implementation/mutations/userOAuth.ts +2 -1
- package/src/server/implementation/mutations/verifierSignature.ts +2 -1
- package/src/server/implementation/passkey.ts +33 -35
- package/src/server/implementation/provider.ts +5 -8
- package/src/server/implementation/redirects.ts +2 -3
- package/src/server/implementation/refreshTokens.ts +2 -1
- package/src/server/implementation/signIn.ts +9 -18
- package/src/server/implementation/totp.ts +18 -21
- package/src/server/implementation/users.ts +4 -7
- package/src/server/index.ts +240 -37
- package/src/server/oauth/authorizationUrl.ts +2 -1
- package/src/server/oauth/callback.ts +5 -4
- package/src/server/oauth/checks.ts +3 -1
- package/src/server/oauth/convexAuth.ts +6 -3
- package/src/server/types.ts +254 -5
- package/src/server/utils.ts +3 -1
- package/src/server/version.ts +2 -0
- package/dist/server/portal.d.ts +0 -116
- package/dist/server/portal.d.ts.map +0 -1
- package/dist/server/portal.js +0 -294
- package/dist/server/portal.js.map +0 -1
- package/src/server/portal.ts +0 -375
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
* ```ts
|
|
7
7
|
* // convex/auth.ts
|
|
8
8
|
* import { Auth, Portal } from "@robelest/convex-auth/component";
|
|
9
|
-
* import
|
|
9
|
+
* import google from "@auth/core/providers/google";
|
|
10
10
|
* import { components } from "./_generated/api";
|
|
11
11
|
*
|
|
12
12
|
* export const auth = new Auth(components.auth, {
|
|
13
|
-
* providers: [
|
|
13
|
+
* providers: [google],
|
|
14
|
+
* email: {
|
|
15
|
+
* from: "My App <noreply@example.com>",
|
|
16
|
+
* send: async (_ctx, { from, to, subject, html }) => {
|
|
17
|
+
* await fetch("https://api.resend.com/emails", {
|
|
18
|
+
* method: "POST",
|
|
19
|
+
* headers: {
|
|
20
|
+
* Authorization: `Bearer ${process.env.AUTH_RESEND_KEY}`,
|
|
21
|
+
* "Content-Type": "application/json",
|
|
22
|
+
* },
|
|
23
|
+
* body: JSON.stringify({ from, to, subject, html }),
|
|
24
|
+
* });
|
|
25
|
+
* },
|
|
26
|
+
* },
|
|
14
27
|
* });
|
|
15
28
|
* export const { signIn, signOut, store } = auth;
|
|
16
29
|
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
@@ -23,27 +36,36 @@ import {
|
|
|
23
36
|
queryGeneric,
|
|
24
37
|
mutationGeneric,
|
|
25
38
|
internalMutationGeneric,
|
|
39
|
+
httpActionGeneric,
|
|
26
40
|
} from "convex/server";
|
|
27
41
|
import type { HttpRouter } from "convex/server";
|
|
28
42
|
import { v } from "convex/values";
|
|
29
43
|
import type { ComponentApi as AuthComponentApi } from "../component/_generated/component.js";
|
|
30
44
|
import { Auth as AuthFactory } from "./implementation/index.js";
|
|
31
|
-
import type { ConvexAuthConfig } from "./types.js";
|
|
45
|
+
import type { ConvexAuthConfig, EmailTransport } from "./types.js";
|
|
32
46
|
import { registerStaticRoutes } from "@convex-dev/self-hosting";
|
|
33
47
|
import { portalMagicLinkEmail } from "./portal-email.js";
|
|
34
|
-
import
|
|
48
|
+
import { defaultMagicLinkEmail } from "./email-templates.js";
|
|
49
|
+
import emailProvider from "../providers/email.js";
|
|
50
|
+
import { AUTH_VERSION } from "./version.js";
|
|
51
|
+
import { throwAuthError } from "./errors.js";
|
|
35
52
|
|
|
36
53
|
// ============================================================================
|
|
37
54
|
// Types
|
|
38
55
|
// ============================================================================
|
|
39
56
|
|
|
40
57
|
/**
|
|
41
|
-
* Config for the
|
|
58
|
+
* Config for the Auth class. Extends the standard auth config
|
|
59
|
+
* minus `component` (which is passed as the first constructor argument).
|
|
42
60
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
61
|
+
* When `email` is configured, the library auto-registers:
|
|
62
|
+
* - A magic link provider (`id: "email"`) for user-facing sign-in
|
|
63
|
+
* - A portal provider (`id: "portal"`) for admin dashboard sign-in
|
|
64
|
+
*
|
|
65
|
+
* Portal functionality is always available — no configuration flag
|
|
66
|
+
* needed. The portal UI works when you export `portalQuery`,
|
|
67
|
+
* `portalMutation`, `portalInternal` from your `convex/auth.ts`
|
|
68
|
+
* and upload the portal static files via CLI.
|
|
47
69
|
*/
|
|
48
70
|
export type AuthClassConfig = Omit<ConvexAuthConfig, "component">;
|
|
49
71
|
|
|
@@ -70,7 +92,7 @@ async function requirePortalAdmin(
|
|
|
70
92
|
invite.role === "portalAdmin" && invite.acceptedByUserId === userId,
|
|
71
93
|
);
|
|
72
94
|
if (!isAdmin) {
|
|
73
|
-
|
|
95
|
+
throwAuthError("PORTAL_NOT_AUTHORIZED");
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
|
|
@@ -84,7 +106,11 @@ async function requirePortalAdmin(
|
|
|
84
106
|
*
|
|
85
107
|
* ```ts
|
|
86
108
|
* export const auth = new Auth(components.auth, {
|
|
87
|
-
* providers: [
|
|
109
|
+
* providers: [google, password],
|
|
110
|
+
* email: {
|
|
111
|
+
* from: "My App <noreply@example.com>",
|
|
112
|
+
* send: (ctx, params) => resend.sendEmail(ctx, params),
|
|
113
|
+
* },
|
|
88
114
|
* });
|
|
89
115
|
* export const { signIn, signOut, store } = auth;
|
|
90
116
|
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
@@ -106,23 +132,29 @@ export class Auth {
|
|
|
106
132
|
readonly portalUrl: string;
|
|
107
133
|
|
|
108
134
|
// ---- Proxied auth helper sub-objects ----
|
|
109
|
-
/** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.viewer(ctx)` */
|
|
135
|
+
/** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.patch(ctx, userId, data)`, `.viewer(ctx)`, `.group.list(ctx, ...)`, `.group.get(ctx, ...)` */
|
|
110
136
|
get user() { return this._auth.user; }
|
|
111
|
-
/** Session helpers */
|
|
137
|
+
/** Session helpers: `.current(ctx)`, `.invalidate(ctx, { userId, except? })` */
|
|
112
138
|
get session() { return this._auth.session; }
|
|
113
|
-
/** Provider helpers */
|
|
139
|
+
/** Provider helpers: `.signIn(ctx, provider, args)` */
|
|
114
140
|
get provider() { return this._auth.provider; }
|
|
115
|
-
/** Account helpers */
|
|
141
|
+
/** Account helpers: `.create(ctx, args)`, `.get(ctx, args)`, `.updateCredentials(ctx, args)` */
|
|
116
142
|
get account() { return this._auth.account; }
|
|
117
|
-
/** Group helpers */
|
|
143
|
+
/** Group helpers: `.create(ctx, ...)`, `.get(ctx, id)`, `.list(ctx, ...)`, `.update(ctx, ...)`, `.delete(ctx, id)`, `.member.*` */
|
|
118
144
|
get group() { return this._auth.group; }
|
|
119
|
-
/** Invite helpers */
|
|
145
|
+
/** Invite helpers: `.create(ctx, ...)`, `.get(ctx, id)`, `.getByTokenHash(ctx, hash)`, `.list(ctx, ...)`, `.accept(ctx, ...)`, `.revoke(ctx, id)` */
|
|
120
146
|
get invite() { return this._auth.invite; }
|
|
121
|
-
/** Passkey helpers */
|
|
147
|
+
/** Passkey helpers: `.list(ctx, { userId })`, `.rename(ctx, id, name)`, `.remove(ctx, id)` */
|
|
122
148
|
get passkey() { return this._auth.passkey; }
|
|
123
|
-
/** TOTP helpers */
|
|
149
|
+
/** TOTP helpers: `.list(ctx, { userId })`, `.remove(ctx, id)` */
|
|
124
150
|
get totp() { return this._auth.totp; }
|
|
151
|
+
/** API key helpers: `.create(ctx, ...)`, `.verify(ctx, rawKey)`, `.list(ctx, ...)`, `.get(ctx, id)`, `.update(ctx, ...)`, `.revoke(ctx, id)`, `.remove(ctx, id)` */
|
|
152
|
+
get key() { return this._auth.key; }
|
|
125
153
|
|
|
154
|
+
/**
|
|
155
|
+
* @param component - The auth component reference from `components.auth`.
|
|
156
|
+
* @param config - Auth configuration (providers, email transport, session, JWT, callbacks).
|
|
157
|
+
*/
|
|
126
158
|
constructor(component: AuthComponentApi, config: AuthClassConfig) {
|
|
127
159
|
this.component = component;
|
|
128
160
|
|
|
@@ -131,45 +163,78 @@ export class Auth {
|
|
|
131
163
|
? `${process.env.CONVEX_SITE_URL.replace(/\/$/, "")}/auth`
|
|
132
164
|
: "/auth";
|
|
133
165
|
|
|
134
|
-
|
|
166
|
+
const emailTransport = config.email;
|
|
135
167
|
const providers = [...config.providers];
|
|
168
|
+
|
|
169
|
+
// Auto-register user-facing magic link provider when email is configured.
|
|
170
|
+
// Skipped if the user already registered their own provider with id "email".
|
|
171
|
+
const hasUserEmailProvider = providers.some(
|
|
172
|
+
(p) => typeof p === "object" && "id" in p && p.id === "email",
|
|
173
|
+
);
|
|
174
|
+
if (emailTransport && !hasUserEmailProvider) {
|
|
175
|
+
providers.push(
|
|
176
|
+
emailProvider({
|
|
177
|
+
id: "email",
|
|
178
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
179
|
+
authorize: undefined, // Magic link — no OTP email check needed
|
|
180
|
+
async sendVerificationRequest({ identifier, url }, ctx) {
|
|
181
|
+
if (!ctx) {
|
|
182
|
+
throwAuthError("MISSING_ACTION_CONTEXT");
|
|
183
|
+
}
|
|
184
|
+
const { host } = new URL(url);
|
|
185
|
+
await emailTransport.send(ctx, {
|
|
186
|
+
from: emailTransport.from,
|
|
187
|
+
to: identifier,
|
|
188
|
+
subject: `Sign in to ${host}`,
|
|
189
|
+
html: defaultMagicLinkEmail(url, host),
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Auto-register portal admin magic link provider.
|
|
197
|
+
// Uses its own styled dark-theme email template.
|
|
136
198
|
providers.push(
|
|
137
|
-
|
|
199
|
+
emailProvider({
|
|
138
200
|
id: "portal",
|
|
139
201
|
maxAge: 60 * 60 * 24, // 24 hours
|
|
140
|
-
authorize: undefined, // Magic link — no email check needed
|
|
141
|
-
async sendVerificationRequest({ identifier, url, expires }) {
|
|
202
|
+
authorize: undefined, // Magic link — no OTP email check needed
|
|
203
|
+
async sendVerificationRequest({ identifier, url, expires }, ctx) {
|
|
204
|
+
if (!emailTransport) {
|
|
205
|
+
throwAuthError("EMAIL_CONFIG_REQUIRED");
|
|
206
|
+
}
|
|
207
|
+
if (!ctx) {
|
|
208
|
+
throwAuthError("MISSING_ACTION_CONTEXT");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check authorization BEFORE sending — only portal-authorized emails
|
|
212
|
+
const invites = await ctx.runQuery(component.public.inviteList, {
|
|
213
|
+
status: "accepted",
|
|
214
|
+
});
|
|
215
|
+
const hasAccess = invites.some(
|
|
216
|
+
(invite: any) => invite.role === "portalAdmin" && invite.email === identifier,
|
|
217
|
+
);
|
|
218
|
+
if (!hasAccess) {
|
|
219
|
+
throwAuthError("PORTAL_NOT_AUTHORIZED");
|
|
220
|
+
}
|
|
221
|
+
|
|
142
222
|
const hours = Math.max(
|
|
143
223
|
1,
|
|
144
224
|
Math.floor((+expires - Date.now()) / (60 * 60 * 1000)),
|
|
145
225
|
);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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({
|
|
226
|
+
try {
|
|
227
|
+
await emailTransport.send(ctx, {
|
|
228
|
+
from: emailTransport.from,
|
|
165
229
|
to: identifier,
|
|
166
|
-
subject: "Sign in to
|
|
167
|
-
html,
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
230
|
+
subject: "Sign in to Auth Portal",
|
|
231
|
+
html: portalMagicLinkEmail(url, hours),
|
|
232
|
+
});
|
|
233
|
+
} catch (e: unknown) {
|
|
234
|
+
throwAuthError(
|
|
235
|
+
"EMAIL_SEND_FAILED",
|
|
236
|
+
"Failed to send portal sign-in email.",
|
|
237
|
+
{ detail: e instanceof Error ? e.message : String(e) },
|
|
173
238
|
);
|
|
174
239
|
}
|
|
175
240
|
},
|
|
@@ -203,6 +268,10 @@ export class Auth {
|
|
|
203
268
|
* auth.addHttpRoutes(http);
|
|
204
269
|
* export default http;
|
|
205
270
|
* ```
|
|
271
|
+
*
|
|
272
|
+
* @param http - The Convex HTTP router to register routes on.
|
|
273
|
+
* @param opts.pathPrefix - URL prefix for portal static files. Defaults to `"/auth"`.
|
|
274
|
+
* @param opts.spaFallback - Serve `index.html` for unmatched sub-paths. Defaults to `true`.
|
|
206
275
|
*/
|
|
207
276
|
addHttpRoutes(
|
|
208
277
|
http: HttpRouter,
|
|
@@ -211,9 +280,36 @@ export class Auth {
|
|
|
211
280
|
// Core auth routes (OAuth, JWKS, etc.)
|
|
212
281
|
this._auth.addHttpRoutes(http);
|
|
213
282
|
|
|
214
|
-
// Portal static file serving
|
|
215
283
|
const prefix = opts?.pathPrefix ?? "/auth";
|
|
216
284
|
|
|
285
|
+
// Portal configuration endpoint — serves Convex URLs + version info.
|
|
286
|
+
// The portal SPA fetches this at startup to discover its Convex backend,
|
|
287
|
+
// which is critical for custom domain deployments where the hostname
|
|
288
|
+
// alone doesn't reveal the Convex cloud URL.
|
|
289
|
+
// Registered as an exact path match before the static file prefix catch-all.
|
|
290
|
+
http.route({
|
|
291
|
+
path: `${prefix}/.well-known/portal-config`,
|
|
292
|
+
method: "GET",
|
|
293
|
+
handler: httpActionGeneric(async () => {
|
|
294
|
+
return new Response(
|
|
295
|
+
JSON.stringify({
|
|
296
|
+
convexUrl: process.env.CONVEX_CLOUD_URL,
|
|
297
|
+
siteUrl: process.env.CONVEX_SITE_URL,
|
|
298
|
+
version: AUTH_VERSION,
|
|
299
|
+
}),
|
|
300
|
+
{
|
|
301
|
+
status: 200,
|
|
302
|
+
headers: {
|
|
303
|
+
"Content-Type": "application/json",
|
|
304
|
+
"Cache-Control":
|
|
305
|
+
"public, max-age=60, stale-while-revalidate=60",
|
|
306
|
+
"Access-Control-Allow-Origin": "*",
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
|
|
217
313
|
// Create a shim that maps the self-hosting ComponentApi shape
|
|
218
314
|
// to the auth component's portalBridge functions
|
|
219
315
|
const selfHostingShim = {
|
|
@@ -241,15 +337,17 @@ export class Auth {
|
|
|
241
337
|
// ============================================================================
|
|
242
338
|
|
|
243
339
|
/**
|
|
244
|
-
* Create portal function definitions from
|
|
340
|
+
* Create portal function definitions from an `Auth` instance.
|
|
245
341
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* cannot trace through `instance.method()`.
|
|
342
|
+
* Standalone function (not a class method) because Convex's bundler
|
|
343
|
+
* can trace `export const { x } = fn(instance)` but not `instance.method()`.
|
|
249
344
|
*
|
|
250
345
|
* ```ts
|
|
251
346
|
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
252
347
|
* ```
|
|
348
|
+
*
|
|
349
|
+
* @param auth - The `Auth` class instance from your `convex/auth.ts`.
|
|
350
|
+
* @returns `{ portalQuery, portalMutation, portalInternal }` — export all three.
|
|
253
351
|
*/
|
|
254
352
|
export function Portal(auth: Auth) {
|
|
255
353
|
const authComponent = auth.component;
|
|
@@ -309,7 +407,7 @@ export function Portal(auth: Auth) {
|
|
|
309
407
|
case "validateInvite": {
|
|
310
408
|
// userId param repurposed as tokenHash for this action
|
|
311
409
|
const tokenHash = userId;
|
|
312
|
-
if (!tokenHash)
|
|
410
|
+
if (!tokenHash) throwAuthError("INVITE_TOKEN_REQUIRED");
|
|
313
411
|
const invite = await ctx.runQuery(
|
|
314
412
|
authComponent.public.inviteGetByTokenHash,
|
|
315
413
|
{ tokenHash },
|
|
@@ -328,8 +426,22 @@ export function Portal(auth: Auth) {
|
|
|
328
426
|
authComponent.portalBridge.getCurrentDeployment,
|
|
329
427
|
);
|
|
330
428
|
|
|
429
|
+
// ---- API Keys (portal admin) ----
|
|
430
|
+
case "listKeys":
|
|
431
|
+
return await ctx.runQuery(authComponent.public.keyList);
|
|
432
|
+
|
|
433
|
+
case "getUserKeys":
|
|
434
|
+
return await ctx.runQuery(authComponent.public.keyListByUserId, {
|
|
435
|
+
userId: userId!,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
case "getKey":
|
|
439
|
+
return await ctx.runQuery(authComponent.public.keyGetById, {
|
|
440
|
+
keyId: userId!, // userId param repurposed as keyId
|
|
441
|
+
});
|
|
442
|
+
|
|
331
443
|
default:
|
|
332
|
-
|
|
444
|
+
throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portal query action: ${action}`);
|
|
333
445
|
}
|
|
334
446
|
},
|
|
335
447
|
});
|
|
@@ -339,30 +451,42 @@ export function Portal(auth: Auth) {
|
|
|
339
451
|
action: v.string(),
|
|
340
452
|
sessionId: v.optional(v.string()),
|
|
341
453
|
tokenHash: v.optional(v.string()),
|
|
454
|
+
// API key fields
|
|
455
|
+
keyId: v.optional(v.string()),
|
|
456
|
+
keyUserId: v.optional(v.string()),
|
|
457
|
+
keyName: v.optional(v.string()),
|
|
458
|
+
keyScopes: v.optional(
|
|
459
|
+
v.array(
|
|
460
|
+
v.object({
|
|
461
|
+
resource: v.string(),
|
|
462
|
+
actions: v.array(v.string()),
|
|
463
|
+
}),
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
keyRateLimit: v.optional(
|
|
467
|
+
v.object({
|
|
468
|
+
maxRequests: v.number(),
|
|
469
|
+
windowMs: v.number(),
|
|
470
|
+
}),
|
|
471
|
+
),
|
|
472
|
+
keyExpiresAt: v.optional(v.number()),
|
|
342
473
|
},
|
|
343
|
-
handler: async (
|
|
344
|
-
ctx: any,
|
|
345
|
-
{
|
|
346
|
-
action,
|
|
347
|
-
sessionId,
|
|
348
|
-
tokenHash,
|
|
349
|
-
}: { action: string; sessionId?: string; tokenHash?: string },
|
|
350
|
-
) => {
|
|
474
|
+
handler: async (ctx: any, args: any) => {
|
|
351
475
|
const currentUserId = await authHelper.user.require(ctx);
|
|
352
476
|
|
|
353
|
-
switch (action) {
|
|
477
|
+
switch (args.action) {
|
|
354
478
|
case "acceptInvite": {
|
|
355
|
-
if (!tokenHash)
|
|
479
|
+
if (!args.tokenHash) throwAuthError("INVITE_TOKEN_REQUIRED");
|
|
356
480
|
const invite = await ctx.runQuery(
|
|
357
481
|
authComponent.public.inviteGetByTokenHash,
|
|
358
|
-
{ tokenHash },
|
|
482
|
+
{ tokenHash: args.tokenHash },
|
|
359
483
|
);
|
|
360
|
-
if (!invite)
|
|
484
|
+
if (!invite) throwAuthError("INVALID_INVITE");
|
|
361
485
|
if (invite.status !== "pending") {
|
|
362
|
-
|
|
486
|
+
throwAuthError("INVITE_ALREADY_USED", `Invite already ${invite.status}`);
|
|
363
487
|
}
|
|
364
488
|
if (invite.expiresTime && invite.expiresTime < Date.now()) {
|
|
365
|
-
|
|
489
|
+
throwAuthError("INVITE_EXPIRED");
|
|
366
490
|
}
|
|
367
491
|
await ctx.runMutation(authComponent.public.inviteAccept, {
|
|
368
492
|
inviteId: invite._id,
|
|
@@ -374,13 +498,49 @@ export function Portal(auth: Auth) {
|
|
|
374
498
|
case "revokeSession": {
|
|
375
499
|
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
376
500
|
await ctx.runMutation(authComponent.public.sessionDelete, {
|
|
377
|
-
sessionId: sessionId!,
|
|
501
|
+
sessionId: args.sessionId!,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---- API Keys (portal admin) ----
|
|
507
|
+
case "createKey": {
|
|
508
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
509
|
+
const result = await authHelper.key.create(ctx, {
|
|
510
|
+
userId: args.keyUserId!,
|
|
511
|
+
name: args.keyName!,
|
|
512
|
+
scopes: args.keyScopes ?? [],
|
|
513
|
+
rateLimit: args.keyRateLimit,
|
|
514
|
+
expiresAt: args.keyExpiresAt,
|
|
378
515
|
});
|
|
516
|
+
// Return the raw key — portal will show it once
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
case "revokeKey": {
|
|
521
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
522
|
+
await authHelper.key.revoke(ctx, args.keyId!);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
case "deleteKey": {
|
|
527
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
528
|
+
await authHelper.key.remove(ctx, args.keyId!);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case "updateKey": {
|
|
533
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
534
|
+
const data: Record<string, any> = {};
|
|
535
|
+
if (args.keyName) data.name = args.keyName;
|
|
536
|
+
if (args.keyScopes) data.scopes = args.keyScopes;
|
|
537
|
+
if (args.keyRateLimit) data.rateLimit = args.keyRateLimit;
|
|
538
|
+
await authHelper.key.update(ctx, args.keyId!, data);
|
|
379
539
|
return;
|
|
380
540
|
}
|
|
381
541
|
|
|
382
542
|
default:
|
|
383
|
-
|
|
543
|
+
throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portal mutation action: ${args.action}`);
|
|
384
544
|
}
|
|
385
545
|
},
|
|
386
546
|
});
|
|
@@ -461,10 +621,162 @@ export function Portal(auth: Auth) {
|
|
|
461
621
|
}
|
|
462
622
|
|
|
463
623
|
default:
|
|
464
|
-
|
|
624
|
+
throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portalInternal action: ${args.action}`);
|
|
465
625
|
}
|
|
466
626
|
},
|
|
467
627
|
});
|
|
468
628
|
|
|
469
629
|
return { portalQuery, portalMutation, portalInternal };
|
|
470
630
|
}
|
|
631
|
+
|
|
632
|
+
// ============================================================================
|
|
633
|
+
// AuthCtx — ctx enrichment for customQuery / customMutation
|
|
634
|
+
// ============================================================================
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Configuration for auth context enrichment.
|
|
638
|
+
*/
|
|
639
|
+
export type AuthCtxConfig = {
|
|
640
|
+
/**
|
|
641
|
+
* When `true`, unauthenticated requests set `ctx.auth.userId` and
|
|
642
|
+
* `ctx.auth.user` to `null` instead of throwing.
|
|
643
|
+
*
|
|
644
|
+
* @default false
|
|
645
|
+
*/
|
|
646
|
+
optional?: boolean;
|
|
647
|
+
/**
|
|
648
|
+
* Resolve additional context after authentication succeeds (e.g.
|
|
649
|
+
* group/role for multi-tenant apps). The returned object is spread
|
|
650
|
+
* into `ctx.auth`.
|
|
651
|
+
*/
|
|
652
|
+
resolve?: (
|
|
653
|
+
ctx: any,
|
|
654
|
+
user: any,
|
|
655
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Create a `convex-helpers`–compatible customization object that
|
|
660
|
+
* enriches `ctx.auth` with the authenticated user's data.
|
|
661
|
+
*
|
|
662
|
+
* Standalone function (not a class method) because Convex's bundler
|
|
663
|
+
* can trace `export const x = fn(instance)` but not `instance.method()`.
|
|
664
|
+
*
|
|
665
|
+
* ### Basic usage (with `convex-helpers`)
|
|
666
|
+
*
|
|
667
|
+
* ```ts
|
|
668
|
+
* // convex/functions.ts
|
|
669
|
+
* import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
|
|
670
|
+
* import { query as rawQuery, mutation as rawMutation } from "./_generated/server";
|
|
671
|
+
* import { AuthCtx } from "\@robelest/convex-auth/component";
|
|
672
|
+
* import { auth } from "./auth";
|
|
673
|
+
*
|
|
674
|
+
* const authCtx = AuthCtx(auth);
|
|
675
|
+
*
|
|
676
|
+
* export const query = customQuery(rawQuery, authCtx);
|
|
677
|
+
* export const mutation = customMutation(rawMutation, authCtx);
|
|
678
|
+
* ```
|
|
679
|
+
*
|
|
680
|
+
* Then in any function file:
|
|
681
|
+
*
|
|
682
|
+
* ```ts
|
|
683
|
+
* // convex/messages.ts
|
|
684
|
+
* import { query, mutation } from "./functions";
|
|
685
|
+
*
|
|
686
|
+
* export const list = query({
|
|
687
|
+
* args: {},
|
|
688
|
+
* handler: async (ctx) => {
|
|
689
|
+
* // ctx.auth.userId and ctx.auth.user are already resolved
|
|
690
|
+
* return ctx.db.query("messages").collect();
|
|
691
|
+
* },
|
|
692
|
+
* });
|
|
693
|
+
* ```
|
|
694
|
+
*
|
|
695
|
+
* ### Optional auth (public routes)
|
|
696
|
+
*
|
|
697
|
+
* ```ts
|
|
698
|
+
* export const publicQuery = customQuery(rawQuery, AuthCtx(auth, { optional: true }));
|
|
699
|
+
* // ctx.auth.userId is null when unauthenticated
|
|
700
|
+
* ```
|
|
701
|
+
*
|
|
702
|
+
* ### Multi-tenant with group resolution
|
|
703
|
+
*
|
|
704
|
+
* ```ts
|
|
705
|
+
* const authCtx = AuthCtx(auth, {
|
|
706
|
+
* resolve: async (ctx, user) => {
|
|
707
|
+
* const groupId = user?.extend?.lastActiveGroup;
|
|
708
|
+
* const membership = await auth.user.group.get(ctx, {
|
|
709
|
+
* userId: user._id,
|
|
710
|
+
* groupId,
|
|
711
|
+
* });
|
|
712
|
+
* return { groupId, role: membership?.role ?? "member" };
|
|
713
|
+
* },
|
|
714
|
+
* });
|
|
715
|
+
* // ctx.auth.groupId and ctx.auth.role available in handlers
|
|
716
|
+
* ```
|
|
717
|
+
*
|
|
718
|
+
* @param auth - The `Auth` class instance from your `convex/auth.ts`.
|
|
719
|
+
* @param config - Optional configuration for optional auth and group resolution.
|
|
720
|
+
* @returns A `{ args, input }` customization object compatible with
|
|
721
|
+
* `customQuery` / `customMutation` from `convex-helpers`.
|
|
722
|
+
*/
|
|
723
|
+
export function AuthCtx(auth: Auth, config?: AuthCtxConfig) {
|
|
724
|
+
const authHelper = (auth as any)._auth;
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
args: {},
|
|
728
|
+
input: async (ctx: any, _args: any, _extra?: any) => {
|
|
729
|
+
const nativeAuth = ctx.auth;
|
|
730
|
+
|
|
731
|
+
if (config?.optional) {
|
|
732
|
+
const userId = await authHelper.user.current(ctx);
|
|
733
|
+
if (!userId) {
|
|
734
|
+
return {
|
|
735
|
+
ctx: {
|
|
736
|
+
auth: {
|
|
737
|
+
getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
|
|
738
|
+
userId: null,
|
|
739
|
+
user: null,
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
args: {},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const user = await authHelper.user.get(ctx, userId);
|
|
746
|
+
const extra = config.resolve
|
|
747
|
+
? await config.resolve(ctx, user)
|
|
748
|
+
: {};
|
|
749
|
+
return {
|
|
750
|
+
ctx: {
|
|
751
|
+
auth: {
|
|
752
|
+
getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
|
|
753
|
+
userId,
|
|
754
|
+
user,
|
|
755
|
+
...extra,
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
args: {},
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Required mode (default): throws NOT_SIGNED_IN
|
|
763
|
+
const userId = await authHelper.user.require(ctx);
|
|
764
|
+
const user = await authHelper.user.get(ctx, userId);
|
|
765
|
+
const extra = config?.resolve
|
|
766
|
+
? await config.resolve(ctx, user)
|
|
767
|
+
: {};
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
ctx: {
|
|
771
|
+
auth: {
|
|
772
|
+
getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
|
|
773
|
+
userId,
|
|
774
|
+
user,
|
|
775
|
+
...extra,
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
args: {},
|
|
779
|
+
};
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default email templates generated by the Auth library.
|
|
3
|
+
*
|
|
4
|
+
* These are used when the library sends emails on behalf of the developer
|
|
5
|
+
* (magic links, portal admin sign-in). The developer provides the transport
|
|
6
|
+
* via `email.send`; the library provides the content.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default magic link email template.
|
|
13
|
+
*
|
|
14
|
+
* Clean, minimal design that works across email clients.
|
|
15
|
+
* Used by the auto-registered `email` provider when `email` is
|
|
16
|
+
* configured in the Auth constructor.
|
|
17
|
+
*/
|
|
18
|
+
export function defaultMagicLinkEmail(url: string, host: string): string {
|
|
19
|
+
const escapedHost = host.replace(/[&<>"']/g, (c) =>
|
|
20
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return `<!DOCTYPE html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
28
|
+
<title>Sign in to ${escapedHost}</title>
|
|
29
|
+
</head>
|
|
30
|
+
<body style="margin:0;padding:0;background-color:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
|
31
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;padding:40px 16px;">
|
|
32
|
+
<tr>
|
|
33
|
+
<td align="center">
|
|
34
|
+
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
|
35
|
+
<tr>
|
|
36
|
+
<td style="padding:32px 32px 0 32px;text-align:center;">
|
|
37
|
+
<h1 style="margin:0 0 8px 0;font-size:20px;font-weight:600;color:#111827;line-height:1.3;">
|
|
38
|
+
Sign in to ${escapedHost}
|
|
39
|
+
</h1>
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
<tr>
|
|
43
|
+
<td style="padding:24px 32px;">
|
|
44
|
+
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#4b5563;text-align:center;">
|
|
45
|
+
Click the button below to sign in. This link will expire shortly.
|
|
46
|
+
</p>
|
|
47
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
48
|
+
<tr>
|
|
49
|
+
<td align="center" style="padding:0 0 24px 0;">
|
|
50
|
+
<a href="${url}" target="_blank" style="display:inline-block;background-color:#111827;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:6px;line-height:1;">
|
|
51
|
+
Sign in
|
|
52
|
+
</a>
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</table>
|
|
56
|
+
<p style="margin:0 0 12px 0;font-size:13px;line-height:1.6;color:#9ca3af;">
|
|
57
|
+
If the button doesn't work, copy and paste this URL into your browser:
|
|
58
|
+
</p>
|
|
59
|
+
<p style="margin:0;font-size:13px;line-height:1.5;color:#6b7280;word-break:break-all;">
|
|
60
|
+
${url}
|
|
61
|
+
</p>
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
<tr>
|
|
65
|
+
<td style="padding:20px 32px;border-top:1px solid #e5e7eb;">
|
|
66
|
+
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;text-align:center;">
|
|
67
|
+
If you didn't request this email, you can safely ignore it.
|
|
68
|
+
</p>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</table>
|
|
72
|
+
</td>
|
|
73
|
+
</tr>
|
|
74
|
+
</table>
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|