@robelest/convex-auth 0.0.2-preview.2 → 0.0.3-preview
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.cjs +467 -64
- package/dist/client/index.d.ts +127 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +424 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +56 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +141 -3
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/index.d.ts +5 -4
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +4 -3
- package/dist/component/index.js.map +1 -1
- package/dist/component/portalBridge.d.ts +80 -0
- package/dist/component/portalBridge.d.ts.map +1 -0
- package/dist/component/portalBridge.js +102 -0
- package/dist/component/portalBridge.js.map +1 -0
- package/dist/component/public.d.ts +353 -9
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +328 -33
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +168 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +113 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/passkey.d.ts +20 -0
- package/dist/providers/passkey.d.ts.map +1 -0
- package/dist/providers/passkey.js +32 -0
- package/dist/providers/passkey.js.map +1 -0
- package/dist/providers/totp.d.ts +14 -0
- package/dist/providers/totp.d.ts.map +1 -0
- package/dist/providers/totp.js +23 -0
- package/dist/providers/totp.js.map +1 -0
- package/dist/server/convex-auth.d.ts +296 -0
- package/dist/server/convex-auth.d.ts.map +1 -0
- package/dist/server/convex-auth.js +480 -0
- package/dist/server/convex-auth.js.map +1 -0
- package/dist/server/email-templates.d.ts +18 -0
- package/dist/server/email-templates.d.ts.map +1 -0
- package/dist/server/email-templates.js +74 -0
- package/dist/server/email-templates.js.map +1 -0
- package/dist/server/implementation/apiKey.d.ts +74 -0
- package/dist/server/implementation/apiKey.d.ts.map +1 -0
- package/dist/server/implementation/apiKey.js +140 -0
- package/dist/server/implementation/apiKey.js.map +1 -0
- package/dist/server/implementation/index.d.ts +169 -7
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +220 -5
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/passkey.d.ts +33 -0
- package/dist/server/implementation/passkey.d.ts.map +1 -0
- package/dist/server/implementation/passkey.js +450 -0
- package/dist/server/implementation/passkey.js.map +1 -0
- package/dist/server/implementation/redirects.d.ts.map +1 -1
- package/dist/server/implementation/redirects.js +4 -9
- package/dist/server/implementation/redirects.js.map +1 -1
- package/dist/server/implementation/signIn.d.ts +13 -0
- package/dist/server/implementation/signIn.d.ts.map +1 -1
- package/dist/server/implementation/signIn.js +29 -15
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/implementation/totp.d.ts +40 -0
- package/dist/server/implementation/totp.d.ts.map +1 -0
- package/dist/server/implementation/totp.js +211 -0
- package/dist/server/implementation/totp.js.map +1 -0
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +63 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/portal-email.d.ts +19 -0
- package/dist/server/portal-email.d.ts.map +1 -0
- package/dist/server/portal-email.js +89 -0
- package/dist/server/portal-email.js.map +1 -0
- package/dist/server/provider_utils.d.ts +3 -1
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/provider_utils.js +39 -1
- package/dist/server/provider_utils.js.map +1 -1
- package/dist/server/types.d.ts +263 -4
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.d.ts.map +1 -0
- package/dist/server/version.js +3 -0
- package/dist/server/version.js.map +1 -0
- package/package.json +7 -3
- package/src/cli/index.ts +49 -7
- package/src/cli/portal-link.ts +112 -0
- package/src/cli/portal-upload.ts +411 -0
- package/src/cli/utils.ts +248 -0
- package/src/client/index.ts +489 -1
- package/src/component/_generated/api.ts +72 -1
- package/src/component/_generated/component.ts +241 -4
- package/src/component/convex.config.ts +3 -0
- package/src/component/index.ts +8 -3
- package/src/component/portalBridge.ts +116 -0
- package/src/component/public.ts +373 -37
- package/src/component/schema.ts +122 -7
- package/src/providers/passkey.ts +35 -0
- package/src/providers/totp.ts +26 -0
- package/src/server/convex-auth.ts +602 -0
- package/src/server/email-templates.ts +77 -0
- package/src/server/implementation/apiKey.ts +185 -0
- package/src/server/implementation/index.ts +301 -8
- package/src/server/implementation/passkey.ts +650 -0
- package/src/server/implementation/redirects.ts +4 -11
- package/src/server/implementation/signIn.ts +41 -13
- package/src/server/implementation/totp.ts +366 -0
- package/src/server/index.ts +98 -34
- package/src/server/portal-email.ts +95 -0
- package/src/server/provider_utils.ts +42 -1
- package/src/server/types.ts +285 -4
- package/src/server/version.ts +2 -0
package/src/component/public.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { mutation, query } from "./_generated/server";
|
|
|
5
5
|
// Users
|
|
6
6
|
// ============================================================================
|
|
7
7
|
|
|
8
|
+
/** List all users. */
|
|
9
|
+
export const userList = query({
|
|
10
|
+
args: {},
|
|
11
|
+
handler: async (ctx) => {
|
|
12
|
+
return await ctx.db.query("user").collect();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
8
16
|
/** Retrieve a user by their document ID. */
|
|
9
17
|
export const userGetById = query({
|
|
10
18
|
args: { userId: v.id("user") },
|
|
@@ -79,6 +87,17 @@ export const userPatch = mutation({
|
|
|
79
87
|
// Accounts
|
|
80
88
|
// ============================================================================
|
|
81
89
|
|
|
90
|
+
/** List all accounts for a user. */
|
|
91
|
+
export const accountListByUser = query({
|
|
92
|
+
args: { userId: v.id("user") },
|
|
93
|
+
handler: async (ctx, { userId }) => {
|
|
94
|
+
return await ctx.db
|
|
95
|
+
.query("account")
|
|
96
|
+
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId as any))
|
|
97
|
+
.collect();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
82
101
|
/** Look up an account by provider and provider-specific account ID. */
|
|
83
102
|
export const accountGet = query({
|
|
84
103
|
args: { provider: v.string(), providerAccountId: v.string() },
|
|
@@ -133,6 +152,14 @@ export const accountDelete = mutation({
|
|
|
133
152
|
// Sessions
|
|
134
153
|
// ============================================================================
|
|
135
154
|
|
|
155
|
+
/** List all sessions. */
|
|
156
|
+
export const sessionList = query({
|
|
157
|
+
args: {},
|
|
158
|
+
handler: async (ctx) => {
|
|
159
|
+
return await ctx.db.query("session").collect();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
136
163
|
/** Create a new session for a user with an expiration time. */
|
|
137
164
|
export const sessionCreate = mutation({
|
|
138
165
|
args: { userId: v.id("user"), expirationTime: v.number() },
|
|
@@ -360,6 +387,150 @@ export const refreshTokenGetActive = query({
|
|
|
360
387
|
},
|
|
361
388
|
});
|
|
362
389
|
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// Passkeys
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
/** Store a new passkey credential for a user. */
|
|
395
|
+
export const passkeyInsert = mutation({
|
|
396
|
+
args: {
|
|
397
|
+
userId: v.id("user"),
|
|
398
|
+
credentialId: v.string(),
|
|
399
|
+
publicKey: v.bytes(),
|
|
400
|
+
algorithm: v.number(),
|
|
401
|
+
counter: v.number(),
|
|
402
|
+
transports: v.optional(v.array(v.string())),
|
|
403
|
+
deviceType: v.string(),
|
|
404
|
+
backedUp: v.boolean(),
|
|
405
|
+
name: v.optional(v.string()),
|
|
406
|
+
createdAt: v.number(),
|
|
407
|
+
},
|
|
408
|
+
handler: async (ctx, args) => {
|
|
409
|
+
return await ctx.db.insert("passkey", args);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
/** Look up a passkey by its credential ID. */
|
|
414
|
+
export const passkeyGetByCredentialId = query({
|
|
415
|
+
args: { credentialId: v.string() },
|
|
416
|
+
handler: async (ctx, { credentialId }) => {
|
|
417
|
+
return await ctx.db
|
|
418
|
+
.query("passkey")
|
|
419
|
+
.withIndex("credentialId", (q) => q.eq("credentialId", credentialId))
|
|
420
|
+
.unique();
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/** List all passkeys for a user. */
|
|
425
|
+
export const passkeyListByUserId = query({
|
|
426
|
+
args: { userId: v.id("user") },
|
|
427
|
+
handler: async (ctx, { userId }) => {
|
|
428
|
+
return await ctx.db
|
|
429
|
+
.query("passkey")
|
|
430
|
+
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
431
|
+
.collect();
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/** Update a passkey's counter and last used timestamp after authentication. */
|
|
436
|
+
export const passkeyUpdateCounter = mutation({
|
|
437
|
+
args: { passkeyId: v.id("passkey"), counter: v.number(), lastUsedAt: v.number() },
|
|
438
|
+
handler: async (ctx, { passkeyId, counter, lastUsedAt }) => {
|
|
439
|
+
await ctx.db.patch(passkeyId, { counter, lastUsedAt });
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
/** Update a passkey's metadata (name). */
|
|
444
|
+
export const passkeyUpdateMeta = mutation({
|
|
445
|
+
args: { passkeyId: v.id("passkey"), data: v.any() },
|
|
446
|
+
handler: async (ctx, { passkeyId, data }) => {
|
|
447
|
+
await ctx.db.patch(passkeyId, data);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
/** Delete a passkey credential. */
|
|
452
|
+
export const passkeyDelete = mutation({
|
|
453
|
+
args: { passkeyId: v.id("passkey") },
|
|
454
|
+
handler: async (ctx, { passkeyId }) => {
|
|
455
|
+
await ctx.db.delete(passkeyId);
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// TOTP Two-Factor Authentication
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
/** Store a new TOTP enrollment for a user. */
|
|
464
|
+
export const totpInsert = mutation({
|
|
465
|
+
args: {
|
|
466
|
+
userId: v.id("user"),
|
|
467
|
+
secret: v.bytes(),
|
|
468
|
+
digits: v.number(),
|
|
469
|
+
period: v.number(),
|
|
470
|
+
verified: v.boolean(),
|
|
471
|
+
name: v.optional(v.string()),
|
|
472
|
+
createdAt: v.number(),
|
|
473
|
+
},
|
|
474
|
+
handler: async (ctx, args) => {
|
|
475
|
+
return await ctx.db.insert("totp", args);
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
/** Get a verified TOTP enrollment for a user (returns first match). */
|
|
480
|
+
export const totpGetVerifiedByUserId = query({
|
|
481
|
+
args: { userId: v.id("user") },
|
|
482
|
+
handler: async (ctx, { userId }) => {
|
|
483
|
+
return await ctx.db
|
|
484
|
+
.query("totp")
|
|
485
|
+
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
486
|
+
.filter((q) => q.eq(q.field("verified"), true))
|
|
487
|
+
.first();
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
/** List all TOTP enrollments for a user. */
|
|
492
|
+
export const totpListByUserId = query({
|
|
493
|
+
args: { userId: v.id("user") },
|
|
494
|
+
handler: async (ctx, { userId }) => {
|
|
495
|
+
return await ctx.db
|
|
496
|
+
.query("totp")
|
|
497
|
+
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
498
|
+
.collect();
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/** Get a TOTP enrollment by its ID. */
|
|
503
|
+
export const totpGetById = query({
|
|
504
|
+
args: { totpId: v.id("totp") },
|
|
505
|
+
handler: async (ctx, { totpId }) => {
|
|
506
|
+
return await ctx.db.get(totpId);
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/** Mark a TOTP enrollment as verified (setup complete). */
|
|
511
|
+
export const totpMarkVerified = mutation({
|
|
512
|
+
args: { totpId: v.id("totp"), lastUsedAt: v.number() },
|
|
513
|
+
handler: async (ctx, { totpId, lastUsedAt }) => {
|
|
514
|
+
await ctx.db.patch(totpId, { verified: true, lastUsedAt });
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
/** Update a TOTP enrollment's last used timestamp. */
|
|
519
|
+
export const totpUpdateLastUsed = mutation({
|
|
520
|
+
args: { totpId: v.id("totp"), lastUsedAt: v.number() },
|
|
521
|
+
handler: async (ctx, { totpId, lastUsedAt }) => {
|
|
522
|
+
await ctx.db.patch(totpId, { lastUsedAt });
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/** Delete a TOTP enrollment. */
|
|
527
|
+
export const totpDelete = mutation({
|
|
528
|
+
args: { totpId: v.id("totp") },
|
|
529
|
+
handler: async (ctx, { totpId }) => {
|
|
530
|
+
await ctx.db.delete(totpId);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
363
534
|
// ============================================================================
|
|
364
535
|
// Rate Limits
|
|
365
536
|
// ============================================================================
|
|
@@ -629,8 +800,8 @@ export const memberUpdate = mutation({
|
|
|
629
800
|
export const inviteCreate = mutation({
|
|
630
801
|
args: {
|
|
631
802
|
groupId: v.optional(v.id("group")),
|
|
632
|
-
invitedByUserId: v.id("user"),
|
|
633
|
-
email: v.string(),
|
|
803
|
+
invitedByUserId: v.optional(v.id("user")),
|
|
804
|
+
email: v.optional(v.string()),
|
|
634
805
|
tokenHash: v.string(),
|
|
635
806
|
role: v.optional(v.string()),
|
|
636
807
|
status: v.union(
|
|
@@ -639,42 +810,48 @@ export const inviteCreate = mutation({
|
|
|
639
810
|
v.literal("revoked"),
|
|
640
811
|
v.literal("expired"),
|
|
641
812
|
),
|
|
642
|
-
expiresTime: v.number(),
|
|
813
|
+
expiresTime: v.optional(v.number()),
|
|
643
814
|
extend: v.optional(v.any()),
|
|
644
815
|
},
|
|
645
816
|
handler: async (ctx, args) => {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
817
|
+
// Only check for duplicates when an email is provided.
|
|
818
|
+
// CLI-generated invites (no email) are always allowed.
|
|
819
|
+
if (args.email !== undefined) {
|
|
820
|
+
if (args.groupId !== undefined) {
|
|
821
|
+
const existingGroupInvite = await ctx.db
|
|
822
|
+
.query("invite")
|
|
823
|
+
.withIndex("groupIdAndStatus", (q) =>
|
|
824
|
+
q.eq("groupId", args.groupId).eq("status", "pending"),
|
|
825
|
+
)
|
|
826
|
+
.filter((q) => q.eq(q.field("email"), args.email))
|
|
827
|
+
.first();
|
|
828
|
+
if (existingGroupInvite !== null) {
|
|
829
|
+
throw new ConvexError({
|
|
830
|
+
code: "DUPLICATE_INVITE",
|
|
831
|
+
message:
|
|
832
|
+
"A pending invite already exists for this email in this group",
|
|
833
|
+
email: args.email,
|
|
834
|
+
groupId: args.groupId,
|
|
835
|
+
existingInviteId: existingGroupInvite._id,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
const existingPlatformInvite = await ctx.db
|
|
840
|
+
.query("invite")
|
|
841
|
+
.withIndex("emailAndStatus", (q) =>
|
|
842
|
+
q.eq("email", args.email).eq("status", "pending"),
|
|
843
|
+
)
|
|
844
|
+
.filter((q) => q.eq(q.field("groupId"), undefined))
|
|
845
|
+
.first();
|
|
846
|
+
if (existingPlatformInvite !== null) {
|
|
847
|
+
throw new ConvexError({
|
|
848
|
+
code: "DUPLICATE_INVITE",
|
|
849
|
+
message:
|
|
850
|
+
"A pending platform invite already exists for this email",
|
|
851
|
+
email: args.email,
|
|
852
|
+
existingInviteId: existingPlatformInvite._id,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
678
855
|
}
|
|
679
856
|
}
|
|
680
857
|
return await ctx.db.insert("invite", args);
|
|
@@ -689,6 +866,17 @@ export const inviteGet = query({
|
|
|
689
866
|
},
|
|
690
867
|
});
|
|
691
868
|
|
|
869
|
+
/** Retrieve an invite by its token hash. Returns `null` if not found. */
|
|
870
|
+
export const inviteGetByTokenHash = query({
|
|
871
|
+
args: { tokenHash: v.string() },
|
|
872
|
+
handler: async (ctx, { tokenHash }) => {
|
|
873
|
+
return await ctx.db
|
|
874
|
+
.query("invite")
|
|
875
|
+
.withIndex("tokenHash", (q) => q.eq("tokenHash", tokenHash))
|
|
876
|
+
.unique();
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
|
|
692
880
|
/**
|
|
693
881
|
* List invites, optionally filtered by group and/or status.
|
|
694
882
|
* Both `groupId` and `status` are optional filters.
|
|
@@ -740,8 +928,11 @@ export const inviteList = query({
|
|
|
740
928
|
* The caller is responsible for creating the corresponding member record.
|
|
741
929
|
*/
|
|
742
930
|
export const inviteAccept = mutation({
|
|
743
|
-
args: {
|
|
744
|
-
|
|
931
|
+
args: {
|
|
932
|
+
inviteId: v.id("invite"),
|
|
933
|
+
acceptedByUserId: v.optional(v.id("user")),
|
|
934
|
+
},
|
|
935
|
+
handler: async (ctx, { inviteId, acceptedByUserId }) => {
|
|
745
936
|
const invite = await ctx.db.get(inviteId);
|
|
746
937
|
if (invite === null) {
|
|
747
938
|
throw new ConvexError({
|
|
@@ -761,6 +952,7 @@ export const inviteAccept = mutation({
|
|
|
761
952
|
await ctx.db.patch(inviteId, {
|
|
762
953
|
status: "accepted",
|
|
763
954
|
acceptedTime: Date.now(),
|
|
955
|
+
...(acceptedByUserId ? { acceptedByUserId } : {}),
|
|
764
956
|
});
|
|
765
957
|
},
|
|
766
958
|
});
|
|
@@ -793,3 +985,147 @@ export const inviteRevoke = mutation({
|
|
|
793
985
|
await ctx.db.patch(inviteId, { status: "revoked" });
|
|
794
986
|
},
|
|
795
987
|
});
|
|
988
|
+
|
|
989
|
+
// ============================================================================
|
|
990
|
+
// API Keys
|
|
991
|
+
// ============================================================================
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Insert a new API key record.
|
|
995
|
+
*
|
|
996
|
+
* The caller is responsible for hashing the raw key before passing it here —
|
|
997
|
+
* this function only stores the hash and metadata.
|
|
998
|
+
*/
|
|
999
|
+
export const keyInsert = mutation({
|
|
1000
|
+
args: {
|
|
1001
|
+
userId: v.id("user"),
|
|
1002
|
+
prefix: v.string(),
|
|
1003
|
+
hashedKey: v.string(),
|
|
1004
|
+
name: v.string(),
|
|
1005
|
+
scopes: v.array(
|
|
1006
|
+
v.object({
|
|
1007
|
+
resource: v.string(),
|
|
1008
|
+
actions: v.array(v.string()),
|
|
1009
|
+
}),
|
|
1010
|
+
),
|
|
1011
|
+
rateLimit: v.optional(
|
|
1012
|
+
v.object({
|
|
1013
|
+
maxRequests: v.number(),
|
|
1014
|
+
windowMs: v.number(),
|
|
1015
|
+
}),
|
|
1016
|
+
),
|
|
1017
|
+
expiresAt: v.optional(v.number()),
|
|
1018
|
+
},
|
|
1019
|
+
handler: async (ctx, args) => {
|
|
1020
|
+
return await ctx.db.insert("key", {
|
|
1021
|
+
...args,
|
|
1022
|
+
createdAt: Date.now(),
|
|
1023
|
+
revoked: false,
|
|
1024
|
+
});
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Look up an API key by its SHA-256 hash.
|
|
1030
|
+
*
|
|
1031
|
+
* Used during Bearer token verification. Returns the full key record
|
|
1032
|
+
* (including rate limit state) or `null` if not found.
|
|
1033
|
+
*/
|
|
1034
|
+
export const keyGetByHashedKey = query({
|
|
1035
|
+
args: { hashedKey: v.string() },
|
|
1036
|
+
handler: async (ctx, { hashedKey }) => {
|
|
1037
|
+
return await ctx.db
|
|
1038
|
+
.query("key")
|
|
1039
|
+
.withIndex("hashedKey", (q) => q.eq("hashedKey", hashedKey))
|
|
1040
|
+
.first();
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
/** List all API keys for a user. */
|
|
1045
|
+
export const keyListByUserId = query({
|
|
1046
|
+
args: { userId: v.id("user") },
|
|
1047
|
+
handler: async (ctx, { userId }) => {
|
|
1048
|
+
return await ctx.db
|
|
1049
|
+
.query("key")
|
|
1050
|
+
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
1051
|
+
.collect();
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
/** List all API keys across all users (for portal admin). */
|
|
1056
|
+
export const keyList = query({
|
|
1057
|
+
args: {},
|
|
1058
|
+
handler: async (ctx) => {
|
|
1059
|
+
return await ctx.db.query("key").collect();
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
/** Get a single API key by document ID. */
|
|
1064
|
+
export const keyGetById = query({
|
|
1065
|
+
args: { keyId: v.id("key") },
|
|
1066
|
+
handler: async (ctx, { keyId }) => {
|
|
1067
|
+
return await ctx.db.get(keyId);
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Patch an API key record. Used for updating name, scopes, rate limit config,
|
|
1073
|
+
* revocation, and lastUsedAt / rate limit state tracking.
|
|
1074
|
+
*/
|
|
1075
|
+
export const keyPatch = mutation({
|
|
1076
|
+
args: {
|
|
1077
|
+
keyId: v.id("key"),
|
|
1078
|
+
data: v.object({
|
|
1079
|
+
name: v.optional(v.string()),
|
|
1080
|
+
scopes: v.optional(
|
|
1081
|
+
v.array(
|
|
1082
|
+
v.object({
|
|
1083
|
+
resource: v.string(),
|
|
1084
|
+
actions: v.array(v.string()),
|
|
1085
|
+
}),
|
|
1086
|
+
),
|
|
1087
|
+
),
|
|
1088
|
+
rateLimit: v.optional(
|
|
1089
|
+
v.object({
|
|
1090
|
+
maxRequests: v.number(),
|
|
1091
|
+
windowMs: v.number(),
|
|
1092
|
+
}),
|
|
1093
|
+
),
|
|
1094
|
+
rateLimitState: v.optional(
|
|
1095
|
+
v.object({
|
|
1096
|
+
attemptsLeft: v.number(),
|
|
1097
|
+
lastAttemptTime: v.number(),
|
|
1098
|
+
}),
|
|
1099
|
+
),
|
|
1100
|
+
revoked: v.optional(v.boolean()),
|
|
1101
|
+
lastUsedAt: v.optional(v.number()),
|
|
1102
|
+
}),
|
|
1103
|
+
},
|
|
1104
|
+
handler: async (ctx, { keyId, data }) => {
|
|
1105
|
+
const key = await ctx.db.get(keyId);
|
|
1106
|
+
if (key === null) {
|
|
1107
|
+
throw new ConvexError({
|
|
1108
|
+
code: "KEY_NOT_FOUND",
|
|
1109
|
+
message: "API key not found",
|
|
1110
|
+
keyId,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
await ctx.db.patch(keyId, data);
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
/** Hard delete an API key record. */
|
|
1118
|
+
export const keyDelete = mutation({
|
|
1119
|
+
args: { keyId: v.id("key") },
|
|
1120
|
+
handler: async (ctx, { keyId }) => {
|
|
1121
|
+
const key = await ctx.db.get(keyId);
|
|
1122
|
+
if (key === null) {
|
|
1123
|
+
throw new ConvexError({
|
|
1124
|
+
code: "KEY_NOT_FOUND",
|
|
1125
|
+
message: "API key not found",
|
|
1126
|
+
keyId,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
await ctx.db.delete(keyId);
|
|
1130
|
+
},
|
|
1131
|
+
});
|
package/src/component/schema.ts
CHANGED
|
@@ -96,6 +96,61 @@ export default defineSchema({
|
|
|
96
96
|
signature: v.optional(v.string()),
|
|
97
97
|
}).index("signature", ["signature"]),
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* WebAuthn passkey credentials. Each credential links a user to a
|
|
101
|
+
* registered authenticator (Touch ID, Face ID, security key, etc.).
|
|
102
|
+
* A user can have multiple passkeys across different devices.
|
|
103
|
+
*/
|
|
104
|
+
passkey: defineTable({
|
|
105
|
+
userId: v.id("user"),
|
|
106
|
+
/** Base64url-encoded credential ID from the authenticator. */
|
|
107
|
+
credentialId: v.string(),
|
|
108
|
+
/** Public key bytes (SEC1 uncompressed for EC, SPKI for RSA). */
|
|
109
|
+
publicKey: v.bytes(),
|
|
110
|
+
/** COSE algorithm identifier (-7 for ES256, -257 for RS256, -8 for EdDSA). */
|
|
111
|
+
algorithm: v.number(),
|
|
112
|
+
/** Signature counter for clone detection. Many authenticators return 0. */
|
|
113
|
+
counter: v.number(),
|
|
114
|
+
/** Authenticator transport hints (e.g. "internal", "hybrid", "usb", "ble", "nfc"). */
|
|
115
|
+
transports: v.optional(v.array(v.string())),
|
|
116
|
+
/** Whether this is a single-device or multi-device (synced) credential. */
|
|
117
|
+
deviceType: v.string(),
|
|
118
|
+
/** Whether the credential is backed up (synced passkey). */
|
|
119
|
+
backedUp: v.boolean(),
|
|
120
|
+
/** User-assigned friendly name (e.g. "MacBook Touch ID"). */
|
|
121
|
+
name: v.optional(v.string()),
|
|
122
|
+
createdAt: v.number(),
|
|
123
|
+
lastUsedAt: v.optional(v.number()),
|
|
124
|
+
})
|
|
125
|
+
.index("userId", ["userId"])
|
|
126
|
+
.index("credentialId", ["credentialId"]),
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* TOTP two-factor authentication secrets. Each record links a user to
|
|
130
|
+
* an authenticator app. A user can have multiple TOTP enrollments
|
|
131
|
+
* (e.g. different authenticator apps) but typically has one.
|
|
132
|
+
*
|
|
133
|
+
* The `verified` flag indicates whether the user has completed setup
|
|
134
|
+
* by successfully entering a code from their authenticator app.
|
|
135
|
+
* Unverified enrollments are in-progress setup that can be discarded.
|
|
136
|
+
*/
|
|
137
|
+
totp: defineTable({
|
|
138
|
+
userId: v.id("user"),
|
|
139
|
+
/** Raw TOTP secret key bytes. */
|
|
140
|
+
secret: v.bytes(),
|
|
141
|
+
/** Number of digits in each code (typically 6). */
|
|
142
|
+
digits: v.number(),
|
|
143
|
+
/** Time period in seconds for code rotation (typically 30). */
|
|
144
|
+
period: v.number(),
|
|
145
|
+
/** Whether setup has been confirmed with a valid code. */
|
|
146
|
+
verified: v.boolean(),
|
|
147
|
+
/** User-assigned friendly name (e.g. "Google Authenticator"). */
|
|
148
|
+
name: v.optional(v.string()),
|
|
149
|
+
createdAt: v.number(),
|
|
150
|
+
lastUsedAt: v.optional(v.number()),
|
|
151
|
+
})
|
|
152
|
+
.index("userId", ["userId"]),
|
|
153
|
+
|
|
99
154
|
/**
|
|
100
155
|
* Rate limit tracking for OTP and password sign-in attempts.
|
|
101
156
|
*/
|
|
@@ -136,14 +191,17 @@ export default defineSchema({
|
|
|
136
191
|
.index("userId", ["userId"]),
|
|
137
192
|
|
|
138
193
|
/**
|
|
139
|
-
*
|
|
140
|
-
* invitations to
|
|
141
|
-
*
|
|
194
|
+
* Invitations. Tracks pending, accepted, revoked, and expired
|
|
195
|
+
* invitations. Optionally scoped to a group via `groupId`, or
|
|
196
|
+
* platform-level when `groupId` is omitted.
|
|
197
|
+
*
|
|
198
|
+
* `email` and `invitedByUserId` are optional to support CLI-generated
|
|
199
|
+
* invite links where neither is known upfront (e.g. portal admin invites).
|
|
142
200
|
*/
|
|
143
201
|
invite: defineTable({
|
|
144
202
|
groupId: v.optional(v.id("group")),
|
|
145
|
-
invitedByUserId: v.id("user"),
|
|
146
|
-
email: v.string(),
|
|
203
|
+
invitedByUserId: v.optional(v.id("user")),
|
|
204
|
+
email: v.optional(v.string()),
|
|
147
205
|
tokenHash: v.string(),
|
|
148
206
|
role: v.optional(v.string()),
|
|
149
207
|
status: v.union(
|
|
@@ -152,7 +210,7 @@ export default defineSchema({
|
|
|
152
210
|
v.literal("revoked"),
|
|
153
211
|
v.literal("expired"),
|
|
154
212
|
),
|
|
155
|
-
expiresTime: v.number(),
|
|
213
|
+
expiresTime: v.optional(v.number()),
|
|
156
214
|
acceptedByUserId: v.optional(v.id("user")),
|
|
157
215
|
acceptedTime: v.optional(v.number()),
|
|
158
216
|
extend: v.optional(v.any()),
|
|
@@ -162,5 +220,62 @@ export default defineSchema({
|
|
|
162
220
|
.index("emailAndStatus", ["email", "status"])
|
|
163
221
|
.index("invitedByUserIdAndStatus", ["invitedByUserId", "status"])
|
|
164
222
|
.index("groupId", ["groupId"])
|
|
165
|
-
.index("groupIdAndStatus", ["groupId", "status"])
|
|
223
|
+
.index("groupIdAndStatus", ["groupId", "status"])
|
|
224
|
+
.index("roleAndStatusAndAcceptedByUserId", [
|
|
225
|
+
"role",
|
|
226
|
+
"status",
|
|
227
|
+
"acceptedByUserId",
|
|
228
|
+
]),
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* API keys for programmatic access. Each key links a user to a set of
|
|
232
|
+
* scoped permissions and optional per-key rate limiting.
|
|
233
|
+
*
|
|
234
|
+
* The raw key is never stored — only a SHA-256 hash. A short prefix
|
|
235
|
+
* (e.g. "sk_live_abc1...") is kept for display in the portal.
|
|
236
|
+
*
|
|
237
|
+
* Keys support:
|
|
238
|
+
* - **Scoped permissions**: resource:action pairs (e.g. users:read)
|
|
239
|
+
* - **Per-key rate limiting**: token-bucket with configurable window
|
|
240
|
+
* - **Expiration**: optional TTL
|
|
241
|
+
* - **Soft revocation**: `revoked` flag preserves audit trail
|
|
242
|
+
*/
|
|
243
|
+
key: defineTable({
|
|
244
|
+
userId: v.id("user"),
|
|
245
|
+
/** First chars of the key for display (e.g. "sk_live_abc1..."). */
|
|
246
|
+
prefix: v.string(),
|
|
247
|
+
/** SHA-256 hex hash of the full raw key. */
|
|
248
|
+
hashedKey: v.string(),
|
|
249
|
+
/** User-assigned name (e.g. "CI Pipeline", "Production API"). */
|
|
250
|
+
name: v.string(),
|
|
251
|
+
/** Scoped permissions: [{ resource: "users", actions: ["read", "list"] }]. */
|
|
252
|
+
scopes: v.array(
|
|
253
|
+
v.object({
|
|
254
|
+
resource: v.string(),
|
|
255
|
+
actions: v.array(v.string()),
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
/** Optional per-key rate limit configuration. */
|
|
259
|
+
rateLimit: v.optional(
|
|
260
|
+
v.object({
|
|
261
|
+
maxRequests: v.number(),
|
|
262
|
+
windowMs: v.number(),
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
/** Rate limit state tracking (token-bucket). */
|
|
266
|
+
rateLimitState: v.optional(
|
|
267
|
+
v.object({
|
|
268
|
+
attemptsLeft: v.number(),
|
|
269
|
+
lastAttemptTime: v.number(),
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
/** Expiration timestamp. Null/undefined = never expires. */
|
|
273
|
+
expiresAt: v.optional(v.number()),
|
|
274
|
+
lastUsedAt: v.optional(v.number()),
|
|
275
|
+
createdAt: v.number(),
|
|
276
|
+
/** Soft-revoke flag. Revoked keys are kept for audit trail. */
|
|
277
|
+
revoked: v.boolean(),
|
|
278
|
+
})
|
|
279
|
+
.index("userId", ["userId"])
|
|
280
|
+
.index("hashedKey", ["hashedKey"]),
|
|
166
281
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PasskeyProviderConfig } from "../server/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passkey (WebAuthn) authentication provider.
|
|
5
|
+
*
|
|
6
|
+
* Enables passwordless authentication via biometrics, security keys,
|
|
7
|
+
* and synced passkeys using the Web Authentication API.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import passkey from "@robelest/convex-auth/providers/passkey";
|
|
11
|
+
*
|
|
12
|
+
* export const { auth, signIn, signOut, store } = Auth({
|
|
13
|
+
* component: components.auth,
|
|
14
|
+
* providers: [passkey()],
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @param config Optional configuration for the relying party and credential options.
|
|
19
|
+
*/
|
|
20
|
+
export default function passkey(
|
|
21
|
+
config?: Partial<PasskeyProviderConfig["options"]>,
|
|
22
|
+
): PasskeyProviderConfig {
|
|
23
|
+
return {
|
|
24
|
+
id: "passkey",
|
|
25
|
+
type: "passkey",
|
|
26
|
+
options: {
|
|
27
|
+
attestation: "none",
|
|
28
|
+
userVerification: "required",
|
|
29
|
+
residentKey: "preferred",
|
|
30
|
+
algorithms: [-7, -257], // ES256, RS256
|
|
31
|
+
challengeExpirationMs: 300_000, // 5 minutes
|
|
32
|
+
...config,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|