@robelest/convex-auth 0.0.4-preview.28 → 0.0.4-preview.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin.js +4 -1
  2. package/dist/component/_generated/component.d.ts +344 -0
  3. package/dist/component/convex.config.d.ts +2 -2
  4. package/dist/component/model.d.ts +25 -25
  5. package/dist/component/model.js +1 -0
  6. package/dist/component/public/factors/totp.js +12 -0
  7. package/dist/component/public/groups/core.js +89 -1
  8. package/dist/component/public/groups/members.js +39 -1
  9. package/dist/component/public/identity/accounts.js +22 -3
  10. package/dist/component/public/identity/users.js +79 -5
  11. package/dist/component/public/sso/audit.js +5 -2
  12. package/dist/component/public/sso/core.js +3 -3
  13. package/dist/component/public/sso/domains.js +7 -3
  14. package/dist/component/public/sso/scim.js +36 -1
  15. package/dist/component/public.js +5 -5
  16. package/dist/component/schema.d.ts +293 -289
  17. package/dist/component/schema.js +2 -1
  18. package/dist/core/index.d.ts +63 -27
  19. package/dist/core/index.js +1 -0
  20. package/dist/providers/credentials.d.ts +12 -0
  21. package/dist/providers/password.js +28 -7
  22. package/dist/server/auth-context.d.ts +17 -0
  23. package/dist/server/auth-context.js +1 -1
  24. package/dist/server/auth.d.ts +18 -6
  25. package/dist/server/auth.js +1 -0
  26. package/dist/server/context.js +11 -5
  27. package/dist/server/contract.js +44 -12
  28. package/dist/server/core.js +146 -149
  29. package/dist/server/ctxCache.js +94 -0
  30. package/dist/server/limits.js +39 -9
  31. package/dist/server/mounts.d.ts +85 -85
  32. package/dist/server/mutations/credentialsSignIn.js +114 -0
  33. package/dist/server/mutations/index.js +2 -1
  34. package/dist/server/mutations/refresh.js +25 -12
  35. package/dist/server/mutations/retrieve.js +2 -1
  36. package/dist/server/mutations/signin.js +27 -9
  37. package/dist/server/mutations/store.js +5 -0
  38. package/dist/server/mutations/verify.js +32 -8
  39. package/dist/server/runtime.d.ts +81 -47
  40. package/dist/server/services/group.js +23 -16
  41. package/dist/server/sessions.d.ts +21 -0
  42. package/dist/server/sessions.js +37 -27
  43. package/dist/server/signin.js +32 -9
  44. package/dist/server/sso/domain.js +1 -2
  45. package/dist/server/sso/oidc.js +1 -1
  46. package/dist/server/tokens.js +19 -3
  47. package/dist/server/users.js +3 -2
  48. package/dist/server/utils/span.js +18 -0
  49. package/package.json +1 -1
  50. package/README.md +0 -161
@@ -17,21 +17,29 @@ function createGroupService(deps) {
17
17
  kind
18
18
  });
19
19
  };
20
- const getGroupConnectionOidcConfigWithSecret = async (ctx, connection) => {
20
+ /**
21
+ * Core OIDC config resolver. Fires the connection fetch (when only the ID
22
+ * is known) in parallel with the secret fetch, and decrypts lazily once the
23
+ * secret is available. All three operations are independent until the final
24
+ * merge, so doing them sequentially was costing 30–60ms per OIDC lookup.
25
+ */
26
+ const resolveOidcConfigWithSecret = async (ctx, connectionId, preloadedConnection) => {
27
+ const [connection, secret] = await Promise.all([preloadedConnection !== void 0 ? Promise.resolve(preloadedConnection) : getGroupConnection(ctx, config.component.public, connectionId), getGroupConnectionSecret$1(ctx, connectionId, "oidc_client_secret")]);
28
+ if (!connection) throw convexError({
29
+ code: "INVALID_PARAMETERS",
30
+ message: connectionNotFoundError
31
+ });
21
32
  const oidc = getOidcConfig(connection.config);
22
- const secret = await getGroupConnectionSecret$1(ctx, connection._id, "oidc_client_secret");
23
- if (secret) {
24
- const decrypted = await decryptSecret(secret.ciphertext);
25
- const existingClient = typeof oidc.client === "object" && oidc.client !== null ? oidc.client : {};
26
- return {
27
- ...oidc,
28
- client: {
29
- ...existingClient,
30
- secret: decrypted
31
- }
32
- };
33
- }
34
- return oidc;
33
+ if (!secret) return oidc;
34
+ const decrypted = await decryptSecret(secret.ciphertext);
35
+ const existingClient = typeof oidc.client === "object" && oidc.client !== null ? oidc.client : {};
36
+ return {
37
+ ...oidc,
38
+ client: {
39
+ ...existingClient,
40
+ secret: decrypted
41
+ }
42
+ };
35
43
  };
36
44
  const loadGroupPolicyOrThrow = async (ctx, groupId) => {
37
45
  const group = await getGroup(ctx, config.component.public, groupId);
@@ -84,8 +92,7 @@ function createGroupService(deps) {
84
92
  };
85
93
  };
86
94
  const loadConnectionOidcOrThrow = async (ctx, connectionId) => {
87
- const connection = await loadActiveGroupConnectionOrThrow(ctx, connectionId);
88
- const oidc = await getGroupConnectionOidcConfigWithSecret(ctx, connection);
95
+ const [connection, oidc] = await Promise.all([loadActiveGroupConnectionOrThrow(ctx, connectionId), resolveOidcConfigWithSecret(ctx, connectionId)]);
89
96
  if (oidc.enabled !== true) throw convexError({
90
97
  code: "PROVIDER_NOT_CONFIGURED",
91
98
  message: "OIDC is not configured for this connection."
@@ -0,0 +1,21 @@
1
+ import "./types.js";
2
+ import { GenericId } from "convex/values";
3
+
4
+ //#region src/server/sessions.d.ts
5
+ /**
6
+ * Mutation-side session issuance result. The mutation creates the session
7
+ * and refresh-token rows; JWT signing happens on the action side so the
8
+ * transaction commits without paying the signing CPU cost.
9
+ */
10
+ type SessionIssuance = {
11
+ userId: GenericId<"User">;
12
+ sessionId: GenericId<"Session">;
13
+ /**
14
+ * Encoded refresh token (`${refreshTokenId}|${sessionId}`), or `null` when
15
+ * the caller opted out of refresh-token issuance (e.g. TOTP step-up).
16
+ */
17
+ refreshToken: string | null;
18
+ };
19
+ //#endregion
20
+ export { SessionIssuance };
21
+ //# sourceMappingURL=sessions.d.ts.map
@@ -3,23 +3,43 @@ import { authDb } from "./db.js";
3
3
  import { envOptionalNumber, readConfigSync } from "./env.js";
4
4
  import { log, maybeRedact } from "./log.js";
5
5
  import { REFRESH_TOKEN_DIVIDER, refreshTokenExpirationTime } from "./refresh.js";
6
+ import { withSpan } from "./utils/span.js";
6
7
  import { TOKEN_SUB_CLAIM_DIVIDER, generateToken } from "./tokens.js";
7
8
 
8
9
  //#region src/server/sessions.ts
9
10
  const DEFAULT_SESSION_TOTAL_DURATION_MS = 1e3 * 60 * 60 * 24 * 30;
10
11
  const sessionExpirationTime = (config, now = Date.now()) => now + (config.session?.totalDurationMs ?? readConfigSync(envOptionalNumber("AUTH_SESSION_TOTAL_DURATION_MS")) ?? DEFAULT_SESSION_TOTAL_DURATION_MS);
11
12
  const encodeRefreshToken = (refreshTokenId, sessionId) => `${refreshTokenId}${REFRESH_TOKEN_DIVIDER}${sessionId}`;
12
- /** @internal */
13
- async function maybeGenerateTokensForSession(config, args, generateTokens) {
14
- return {
15
- userId: args.userId,
16
- sessionId: args.sessionId,
17
- tokens: generateTokens && args.refreshTokenId !== void 0 ? await generateTokensForSession(config, {
18
- userId: args.userId,
19
- sessionId: args.sessionId,
20
- refreshTokenId: args.refreshTokenId
21
- }) : null
22
- };
13
+ /**
14
+ * Convert a {@link SessionIssuance} returned from a mutation into the
15
+ * external `SessionInfo` shape by signing the JWT on the action side.
16
+ *
17
+ * Must be called from an action context because `generateToken` performs
18
+ * RSA-2048 JWT signing that would otherwise block the mutation commit.
19
+ *
20
+ * @internal
21
+ */
22
+ async function finalizeSessionIssuance(config, issuance) {
23
+ return withSpan("convex-auth.session.finalize", { hasRefreshToken: issuance.refreshToken !== null }, async () => {
24
+ if (issuance.refreshToken === null) return {
25
+ userId: issuance.userId,
26
+ sessionId: issuance.sessionId,
27
+ tokens: null
28
+ };
29
+ const token = await generateToken({
30
+ userId: issuance.userId,
31
+ sessionId: issuance.sessionId
32
+ }, config);
33
+ log(LOG_LEVELS.DEBUG, `Generated token ${maybeRedact(token)} and refresh token ${maybeRedact(issuance.refreshToken)} for session ${maybeRedact(issuance.sessionId)}`);
34
+ return {
35
+ userId: issuance.userId,
36
+ sessionId: issuance.sessionId,
37
+ tokens: {
38
+ token,
39
+ refreshToken: issuance.refreshToken
40
+ }
41
+ };
42
+ });
23
43
  }
24
44
  /** @internal */
25
45
  async function issueSession(ctx, config, args) {
@@ -30,23 +50,13 @@ async function issueSession(ctx, config, args) {
30
50
  sessionExpirationTime: sessionExpirationTime(config),
31
51
  refreshTokenExpirationTime: args.generateTokens ? refreshTokenExpirationTime(config) : void 0
32
52
  });
33
- return await maybeGenerateTokensForSession(config, {
53
+ const sessionId = issued.sessionId;
54
+ const refreshTokenId = issued.refreshTokenId;
55
+ return {
34
56
  userId: issued.userId,
35
- sessionId: issued.sessionId,
36
- refreshTokenId: issued.refreshTokenId
37
- }, args.generateTokens);
38
- }
39
- /** @internal */
40
- async function generateTokensForSession(config, args) {
41
- const result = {
42
- token: await generateToken({
43
- userId: args.userId,
44
- sessionId: args.sessionId
45
- }, config),
46
- refreshToken: encodeRefreshToken(args.refreshTokenId, args.sessionId)
57
+ sessionId,
58
+ refreshToken: args.generateTokens && refreshTokenId !== void 0 ? encodeRefreshToken(refreshTokenId, sessionId) : null
47
59
  };
48
- log(LOG_LEVELS.DEBUG, `Generated token ${maybeRedact(result.token)} and refresh token ${maybeRedact(args.refreshTokenId)} for session ${maybeRedact(args.sessionId)}`);
49
- return result;
50
60
  }
51
61
  /** @internal */
52
62
  async function deleteSession(ctx, session, config) {
@@ -68,5 +78,5 @@ async function getAuthSessionId(ctx) {
68
78
  }
69
79
 
70
80
  //#endregion
71
- export { deleteSession, generateTokensForSession, getAuthSessionId, issueSession };
81
+ export { deleteSession, finalizeSessionIssuance, getAuthSessionId, issueSession };
72
82
  //# sourceMappingURL=sessions.js.map
@@ -1,8 +1,9 @@
1
1
  import { generateRandomString } from "./random.js";
2
2
  import { envOptionalString, readConfigSync, requireEnv } from "./env.js";
3
3
  import { log } from "./log.js";
4
- import { callCreateVerificationCode } from "./mutations/code.js";
5
4
  import { withSpan } from "./utils/span.js";
5
+ import { finalizeSessionIssuance } from "./sessions.js";
6
+ import { callCreateVerificationCode } from "./mutations/code.js";
6
7
  import { callRefreshSession } from "./mutations/refresh.js";
7
8
  import { callSignIn } from "./mutations/signin.js";
8
9
  import { callVerifier } from "./mutations/verifier.js";
@@ -178,7 +179,7 @@ async function handleCredentialsFx(ctx, provider, args, options) {
178
179
  return withSpan("convex-auth.signin.credentials", {}, async () => {
179
180
  let result;
180
181
  try {
181
- result = await provider.authorize(args.params ?? {}, ctx);
182
+ result = await withSpan("convex-auth.signin.credentials.authorize", { providerId: provider.id }, () => provider.authorize(args.params ?? {}, ctx));
182
183
  } catch (error) {
183
184
  throw asCredentialsError(error);
184
185
  }
@@ -186,19 +187,27 @@ async function handleCredentialsFx(ctx, provider, args, options) {
186
187
  kind: "signedIn",
187
188
  signedIn: null
188
189
  };
190
+ const hintedHasTotp = result.hasTotp;
191
+ const preIssuedIssuance = result.issuance;
189
192
  let hasTotpEnrolled;
190
- try {
191
- hasTotpEnrolled = await queryTotpVerifiedByUserId(ctx, result.userId) !== null;
193
+ if (hintedHasTotp === false) hasTotpEnrolled = false;
194
+ else if (hintedHasTotp === true) hasTotpEnrolled = true;
195
+ else if (preIssuedIssuance !== void 0 && preIssuedIssuance.refreshToken === null) hasTotpEnrolled = true;
196
+ else try {
197
+ hasTotpEnrolled = await withSpan("convex-auth.signin.credentials.totp-check", { hinted: hintedHasTotp ?? "unknown" }, () => queryTotpVerifiedByUserId(ctx, result.userId)) !== null;
192
198
  } catch (error) {
193
199
  throw asConvexError(error, "INTERNAL_ERROR", "Failed to load TOTP enrollment.");
194
200
  }
195
201
  if (hasTotpEnrolled) {
196
- try {
197
- await callSignIn(ctx, {
202
+ if (preIssuedIssuance === void 0) try {
203
+ await withSpan("convex-auth.signin.credentials.issue-session", {
204
+ generateTokens: false,
205
+ totpStepUp: true
206
+ }, () => callSignIn(ctx, {
198
207
  userId: result.userId,
199
208
  sessionId: result.sessionId,
200
209
  generateTokens: false
201
- });
210
+ }));
202
211
  } catch (error) {
203
212
  throw asConvexError(error, "INTERNAL_ERROR", "Failed to start TOTP sign-in.");
204
213
  }
@@ -213,13 +222,27 @@ async function handleCredentialsFx(ctx, provider, args, options) {
213
222
  verifier
214
223
  };
215
224
  }
225
+ if (preIssuedIssuance !== void 0) try {
226
+ return {
227
+ kind: "signedIn",
228
+ signedIn: await withSpan("convex-auth.signin.credentials.finalize", {
229
+ generateTokens: options.generateTokens,
230
+ fromAuthorize: true
231
+ }, () => finalizeSessionIssuance(ctx.auth.config, {
232
+ ...preIssuedIssuance,
233
+ refreshToken: options.generateTokens ? preIssuedIssuance.refreshToken : null
234
+ }))
235
+ };
236
+ } catch (error) {
237
+ throw asConvexError(error, "INTERNAL_ERROR", "Failed to finalize sign-in.");
238
+ }
216
239
  let idsAndTokens;
217
240
  try {
218
- idsAndTokens = await callSignIn(ctx, {
241
+ idsAndTokens = await withSpan("convex-auth.signin.credentials.issue-session", { generateTokens: options.generateTokens }, () => callSignIn(ctx, {
219
242
  userId: result.userId,
220
243
  sessionId: result.sessionId,
221
244
  generateTokens: options.generateTokens
222
- });
245
+ }));
223
246
  } catch (error) {
224
247
  throw asConvexError(error, "INTERNAL_ERROR", "Failed to complete sign-in.");
225
248
  }
@@ -184,12 +184,11 @@ function createGroupConnectionDomain(deps) {
184
184
  };
185
185
  },
186
186
  status: async (ctx, connectionId) => {
187
- const connection = await getGroupConnection(ctx, config.component.public, connectionId);
187
+ const [connection, domains] = await Promise.all([getGroupConnection(ctx, config.component.public, connectionId), listConnectionDomains(ctx, config.component.public, connectionId)]);
188
188
  if (connection === null) throw convexError({
189
189
  code: "INVALID_PARAMETERS",
190
190
  message: connectionNotFoundError
191
191
  });
192
- const domains = await listConnectionDomains(ctx, config.component.public, connectionId);
193
192
  const primaryDomain = domains.find((domain) => domain.isPrimary) ?? null;
194
193
  const verifiedDomains = domains.filter((domain) => domain.verifiedAt !== void 0);
195
194
  const pendingChallenges = (await Promise.all(domains.map(async (domain) => {
@@ -1,7 +1,7 @@
1
1
  import { log } from "../log.js";
2
+ import { withSpan } from "../utils/span.js";
2
3
  import { createCache } from "../utils/cache.js";
3
4
  import { retryWithBackoff } from "../utils/retry.js";
4
- import { withSpan } from "../utils/span.js";
5
5
  import { finalizeNormalizedProfile, normalizeStringArray } from "./profile.js";
6
6
  import { getGroupOidcUrls, groupOidcProviderId } from "./shared.js";
7
7
  import { sha256 } from "@oslojs/crypto/sha2";
@@ -1,5 +1,6 @@
1
1
  import { generateRandomString } from "./random.js";
2
2
  import { requireEnv } from "./env.js";
3
+ import { withSpan } from "./utils/span.js";
3
4
  import { SignJWT, importPKCS8 } from "jose";
4
5
 
5
6
  //#region src/server/tokens.ts
@@ -9,19 +10,34 @@ const TOKEN_JTI_LENGTH = 24;
9
10
  const TOKEN_JTI_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
10
11
  let cachedPrivateKeyPromise = null;
11
12
  let cachedIssuer = null;
13
+ const JWT_ALG = "EdDSA";
12
14
  const getPrivateKey = () => {
13
- if (cachedPrivateKeyPromise === null) cachedPrivateKeyPromise = importPKCS8(requireEnv("JWT_PRIVATE_KEY"), "RS256");
15
+ if (cachedPrivateKeyPromise === null) try {
16
+ cachedPrivateKeyPromise = importPKCS8(requireEnv("JWT_PRIVATE_KEY"), JWT_ALG).catch((error) => {
17
+ cachedPrivateKeyPromise = null;
18
+ throw error;
19
+ });
20
+ } catch (error) {
21
+ cachedPrivateKeyPromise = null;
22
+ throw error;
23
+ }
14
24
  return cachedPrivateKeyPromise;
15
25
  };
16
26
  const getIssuer = () => {
17
27
  if (cachedIssuer === null) cachedIssuer = requireEnv("CONVEX_SITE_URL");
18
28
  return cachedIssuer;
19
29
  };
30
+ try {
31
+ getPrivateKey().catch(() => {});
32
+ } catch {}
33
+ try {
34
+ getIssuer();
35
+ } catch {}
20
36
  /** @internal */
21
37
  async function generateToken(args, config) {
22
- const privateKey = await getPrivateKey();
38
+ const privateKey = await withSpan("convex-auth.tokens.import-key", { alg: JWT_ALG }, () => getPrivateKey());
23
39
  const expirationTime = new Date(Date.now() + (config.jwt?.durationMs ?? DEFAULT_JWT_DURATION_MS));
24
- return await new SignJWT({ sub: args.userId + TOKEN_SUB_CLAIM_DIVIDER + args.sessionId }).setProtectedHeader({ alg: "RS256" }).setIssuedAt().setJti(generateRandomString(TOKEN_JTI_LENGTH, TOKEN_JTI_ALPHABET)).setIssuer(getIssuer()).setAudience("convex").setExpirationTime(expirationTime).sign(privateKey);
40
+ return await withSpan("convex-auth.tokens.sign", { alg: JWT_ALG }, () => new SignJWT({ sub: args.userId + TOKEN_SUB_CLAIM_DIVIDER + args.sessionId }).setProtectedHeader({ alg: JWT_ALG }).setIssuedAt().setJti(generateRandomString(TOKEN_JTI_LENGTH, TOKEN_JTI_ALPHABET)).setIssuer(getIssuer()).setAudience("convex").setExpirationTime(expirationTime).sign(privateKey));
25
41
  }
26
42
 
27
43
  //#endregion
@@ -61,8 +61,9 @@ async function defaultCreateOrUpdateUser(ctx, existingSessionId, existingAccount
61
61
  const shouldLinkViaPhone = args.shouldLinkViaPhone || phoneVerified || provider.type === "phone";
62
62
  let userId = existingUserId ?? existingUserIdOverride;
63
63
  if (existingUserId === null) {
64
- const existingUserWithVerifiedEmailId = typeof profile.email === "string" && shouldLinkViaEmail ? (await uniqueUserWithVerifiedEmail(ctx, profile.email, config))?._id ?? null : null;
65
- const existingUserWithVerifiedPhoneId = typeof profile.phone === "string" && shouldLinkViaPhone ? (await uniqueUserWithVerifiedPhone(ctx, profile.phone, config))?._id ?? null : null;
64
+ const [emailLookup, phoneLookup] = await Promise.all([typeof profile.email === "string" && shouldLinkViaEmail ? uniqueUserWithVerifiedEmail(ctx, profile.email, config) : Promise.resolve(null), typeof profile.phone === "string" && shouldLinkViaPhone ? uniqueUserWithVerifiedPhone(ctx, profile.phone, config) : Promise.resolve(null)]);
65
+ const existingUserWithVerifiedEmailId = emailLookup?._id ?? null;
66
+ const existingUserWithVerifiedPhoneId = phoneLookup?._id ?? null;
66
67
  if (existingUserWithVerifiedEmailId !== null && existingUserWithVerifiedPhoneId !== null) {
67
68
  log(LOG_LEVELS.DEBUG, `Found existing email and phone verified users, so not linking: email: ${existingUserWithVerifiedEmailId}, phone: ${existingUserWithVerifiedPhoneId}`);
68
69
  userId = null;
@@ -7,13 +7,31 @@ import { SpanStatusCode, trace } from "@opentelemetry/api";
7
7
  * @module
8
8
  */
9
9
  const tracer = trace.getTracer("convex-auth");
10
+ const INVALID_TRACE_ID = "00000000000000000000000000000000";
11
+ const ACTIVE_CHECK_INTERVAL_MS = 5e3;
12
+ let cachedActiveAt = 0;
13
+ let cachedActive = false;
14
+ function isTracingActive() {
15
+ const now = Date.now();
16
+ if (now - cachedActiveAt < ACTIVE_CHECK_INTERVAL_MS) return cachedActive;
17
+ const probe = tracer.startSpan("__convex-auth.probe");
18
+ const active = probe.isRecording() || probe.spanContext().traceId !== INVALID_TRACE_ID;
19
+ probe.end();
20
+ cachedActive = active;
21
+ cachedActiveAt = now;
22
+ return active;
23
+ }
10
24
  /**
11
25
  * Run `fn` inside an OpenTelemetry span.
12
26
  *
13
27
  * If `fn` throws, the span is marked as errored and the exception is
14
28
  * recorded before re-throwing.
29
+ *
30
+ * When no tracer provider is registered, this is a cheap passthrough that
31
+ * skips span creation altogether.
15
32
  */
16
33
  async function withSpan(name, attributes, fn) {
34
+ if (!isTracingActive()) return fn();
17
35
  return tracer.startActiveSpan(name, { attributes }, async (span) => {
18
36
  try {
19
37
  return await fn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robelest/convex-auth",
3
- "version": "0.0.4-preview.28",
3
+ "version": "0.0.4-preview.29",
4
4
  "description": "Authentication for Convex",
5
5
  "keywords": [
6
6
  "auth",
package/README.md DELETED
@@ -1,161 +0,0 @@
1
- # @robelest/convex-auth
2
-
3
- Component-first authentication for [Convex](https://convex.dev). One component,
4
- one setup API, full TypeScript support.
5
-
6
- ## Features
7
-
8
- - **Convex-native setup API** — `createAuth(components.auth, { providers })`
9
- gives you everything.
10
- - **First-party OAuth providers** — Google, GitHub, Apple, and Microsoft with
11
- product-owned wrappers and sane defaults.
12
- - **Fluent Convex builders (recommended)** — cleaner auth-aware API handling
13
- with middleware and explicit `.public()` / `.internal()` exports.
14
- - **Password, passkeys, TOTP, magic links, OTP, phone, anonymous** — all built
15
- in.
16
- - **Device Authorization (RFC 8628)** — authenticate CLIs, smart TVs, and IoT
17
- devices.
18
- - **API keys** — scoped permissions, SHA-256 hashed storage, optional rate
19
- limiting.
20
- - **Groups, memberships, invites** — hierarchical multi-tenancy with roles.
21
- - **SSR support** — framework-agnostic httpOnly cookie API (SvelteKit, TanStack
22
- Start, Next.js).
23
- - **Context enrichment** — zero-boilerplate `ctx.auth.userId` via `auth.ctx()`.
24
-
25
- ## Install
26
-
27
- ```bash
28
- bun add @robelest/convex-auth
29
- ```
30
-
31
- > Renamed package: if you are migrating from earlier previews, replace
32
- > `@convex-dev/auth` with `@robelest/convex-auth` in imports and CLI commands.
33
-
34
- ## Quick Start
35
-
36
- Before running setup:
37
-
38
- - Run from your app project root (must include `package.json`).
39
- - Make sure a Convex deployment can be resolved. By default the CLI reads a
40
- typed `CONVEX_DEPLOYMENT` like `dev:my-deployment` (usually set by
41
- `npx convex dev`), or you can pass `--prod`, `--preview-name`,
42
- `--deployment-name`, `--url`, or `--admin-key`.
43
- - Use `--url` for explicit or self-hosted targets. Typed deployment and
44
- admin-key parsing only applies to Convex Cloud selections.
45
-
46
- ```bash
47
- bunx @robelest/convex-auth
48
- ```
49
-
50
- The interactive CLI sets up your Convex component, auth config, and HTTP routes
51
- in under a minute.
52
-
53
- ## Manual Setup
54
-
55
- ```ts
56
- // convex/convex.config.ts
57
- import { defineApp } from "convex/server";
58
- import auth from "@robelest/convex-auth/convex.config";
59
-
60
- const app = defineApp();
61
- app.use(auth);
62
- export default app;
63
- ```
64
-
65
- ```ts
66
- // convex/auth.ts
67
- import { createAuth } from "@robelest/convex-auth/component";
68
- import { github } from "@robelest/convex-auth/providers";
69
- import { components } from "./_generated/api";
70
-
71
- const auth = createAuth(components.auth, {
72
- providers: [
73
- github({
74
- clientId: process.env.AUTH_GITHUB_ID!,
75
- clientSecret: process.env.AUTH_GITHUB_SECRET!,
76
- }),
77
- ],
78
- });
79
-
80
- export { auth };
81
- export const { signIn, signOut, store } = auth;
82
- ```
83
-
84
- ```ts
85
- // convex/http.ts
86
- import { httpRouter } from "convex/server";
87
- import { auth } from "./auth";
88
-
89
- const http = httpRouter();
90
- auth.http.add(http);
91
- export default http;
92
- ```
93
-
94
- ## Recommended Convex API Handling (`fluent-convex`)
95
-
96
- For new projects, we recommend `fluent-convex` for auth middleware composition
97
- and cleaner API exports.
98
-
99
- ```ts
100
- // convex/functions.ts
101
- import { createBuilder } from "fluent-convex";
102
- import { WithZod } from "fluent-convex/zod";
103
- import type { DataModel } from "./_generated/dataModel";
104
- import { auth } from "./auth";
105
-
106
- const convex = createBuilder<DataModel>();
107
-
108
- // `auth.context(ctx)` resolves { userId, user, groupId, role, grants }
109
- // and throws before the handler runs when unauthenticated.
110
- const withRequiredAuth = convex.createMiddleware(async (ctx, next) => {
111
- return next({ ...ctx, auth: await auth.context(ctx) });
112
- });
113
-
114
- export const query = convex.query().use(withRequiredAuth).extend(WithZod);
115
- export const mutation = convex.mutation().use(withRequiredAuth).extend(WithZod);
116
- ```
117
-
118
- `auth.ctx()` works with `convex-helpers` and other custom builder setups.
119
-
120
- ## Providers
121
-
122
- | Provider | Import |
123
- | ----------------- | ------------------------------------------------------------- |
124
- | Google | `import { google } from "@robelest/convex-auth/providers"` |
125
- | GitHub | `import { github } from "@robelest/convex-auth/providers"` |
126
- | Apple | `import { apple } from "@robelest/convex-auth/providers"` |
127
- | Microsoft | `import { microsoft } from "@robelest/convex-auth/providers"` |
128
- | Password | `import { password } from "@robelest/convex-auth/providers"` |
129
- | Passkey | `import { passkey } from "@robelest/convex-auth/providers"` |
130
- | TOTP | `import { totp } from "@robelest/convex-auth/providers"` |
131
- | Phone/SMS | `import { phone } from "@robelest/convex-auth/providers"` |
132
- | Anonymous | `import { anonymous } from "@robelest/convex-auth/providers"` |
133
- | Device (RFC 8628) | `import { device } from "@robelest/convex-auth/providers"` |
134
- | Custom OAuth | `import { custom } from "@robelest/convex-auth/providers"` |
135
-
136
- OAuth provider-specific setup details live in the docs:
137
-
138
- - Google, GitHub, Apple, Microsoft: `/getting-started/providers`
139
- - Required env vars: `/getting-started/environment`
140
-
141
- ## Group SSO
142
-
143
- The group direction is a headless SDK/API rather than a hosted admin UI.
144
-
145
- - `auth.group.sso.*` stores group connection config alongside `auth.group`, with
146
- group-level policy owned by `auth.group.sso.policy.*`.
147
- - Standardized helpers are exposed as `auth.group.sso.connection.*`,
148
- `auth.group.sso.connection.domain.*`, `auth.group.sso.oidc.*`,
149
- `auth.group.sso.saml.*`, `auth.group.sso.policy.*`,
150
- `auth.group.sso.audit.list`, `auth.group.sso.webhook.endpoint.*`,
151
- `auth.group.sso.signIn`, `auth.group.sso.metadata`, and
152
- `auth.group.sso.scim.configure/get/validate`.
153
- - Group SSO helpers are server-side primitives. Consumers can build and expose
154
- their own Convex RPC wrappers when needed.
155
- - `auth.group.sso.metadata(...)` uses the local `@robelest/samlify` package to
156
- parse IdP metadata and generate SP metadata from the same setup state.
157
-
158
- ## Documentation
159
-
160
- See the full [README](https://github.com/robelest/convex-auth#readme) for
161
- detailed usage, API reference, SSR integration, and more.