@lastshotlabs/bunshot 0.0.21 → 0.0.27

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 (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -2,8 +2,8 @@ import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
3
  import { getAppName, getTokenExpiry } from "./appConfig";
4
4
  import { sha256 } from "./crypto";
5
- import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, } from "../adapters/sqliteAuth";
6
- import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, } from "../adapters/memoryAuth";
5
+ import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, sqliteConsumeVerificationToken, } from "../adapters/sqliteAuth";
6
+ import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, memoryConsumeVerificationToken, } from "../adapters/memoryAuth";
7
7
  function getVerificationModel() {
8
8
  if (appConnection.models["EmailVerification"])
9
9
  return appConnection.models["EmailVerification"];
@@ -16,6 +16,25 @@ function getVerificationModel() {
16
16
  }, { collection: "email_verifications" });
17
17
  return appConnection.model("EmailVerification", verificationSchema);
18
18
  }
19
+ // ---------------------------------------------------------------------------
20
+ // Redis helpers
21
+ // ---------------------------------------------------------------------------
22
+ /** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
23
+ async function redisGetDel(key) {
24
+ const redis = getRedis();
25
+ if (typeof redis.getdel === "function") {
26
+ try {
27
+ return await redis.getdel(key);
28
+ }
29
+ catch (err) {
30
+ const msg = err?.message ?? "";
31
+ if (!/unknown command|ERR unknown command/i.test(msg))
32
+ throw err;
33
+ }
34
+ }
35
+ const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
36
+ return result ?? null;
37
+ }
19
38
  let _store = "redis";
20
39
  export const setEmailVerificationStore = (store) => { _store = store; };
21
40
  // ---------------------------------------------------------------------------
@@ -24,7 +43,9 @@ export const setEmailVerificationStore = (store) => { _store = store; };
24
43
  /** Create a verification token. Returns the raw token (for the email link).
25
44
  * Only the SHA-256 hash is persisted in the store. */
26
45
  export const createVerificationToken = async (userId, email) => {
27
- const token = crypto.randomUUID();
46
+ const bytes = new Uint8Array(32);
47
+ crypto.getRandomValues(bytes);
48
+ const token = Buffer.from(bytes).toString("base64url");
28
49
  const hash = sha256(token);
29
50
  const ttl = getTokenExpiry();
30
51
  if (_store === "memory") {
@@ -84,3 +105,25 @@ export const deleteVerificationToken = async (token) => {
84
105
  }
85
106
  await getRedis().del(`verify:${getAppName()}:${hash}`);
86
107
  };
108
+ /** Atomically consume a verification token — returns its payload and deletes it in one operation.
109
+ * Returns null if the token is invalid, expired, or already used. */
110
+ export const consumeVerificationToken = async (token) => {
111
+ const hash = sha256(token);
112
+ if (_store === "memory")
113
+ return memoryConsumeVerificationToken(hash);
114
+ if (_store === "sqlite")
115
+ return sqliteConsumeVerificationToken(hash);
116
+ if (_store === "mongo") {
117
+ const doc = await getVerificationModel()
118
+ .findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } })
119
+ .lean();
120
+ if (!doc)
121
+ return null;
122
+ return { userId: doc.userId, email: doc.email };
123
+ }
124
+ // Redis: atomically return and remove the key (GETDEL or Lua fallback)
125
+ const raw = await redisGetDel(`verify:${getAppName()}:${hash}`);
126
+ if (!raw)
127
+ return null;
128
+ return JSON.parse(raw);
129
+ };
@@ -0,0 +1,113 @@
1
+ export interface GroupRecord {
2
+ id: string;
3
+ /** Machine-readable slug: /^[a-z0-9_-]+$/, unique within scope (app-wide or per-tenant). */
4
+ name: string;
5
+ displayName?: string;
6
+ description?: string;
7
+ /** Baseline roles granted to every member of this group. */
8
+ roles: string[];
9
+ /** null = app-wide group, string = tenant-scoped group. Immutable after creation. */
10
+ tenantId: string | null;
11
+ createdAt: number;
12
+ updatedAt: number;
13
+ }
14
+ export interface GroupMembershipRecord {
15
+ userId: string;
16
+ groupId: string;
17
+ /** Per-member extra roles on top of the group's baseline roles. */
18
+ roles: string[];
19
+ /**
20
+ * Denormalized from the group at insert time for efficient tenant-scoped queries.
21
+ * Immutable: the group's tenantId cannot change after creation, so this is always consistent.
22
+ */
23
+ tenantId: string | null;
24
+ createdAt: number;
25
+ }
26
+ export interface PaginationOpts {
27
+ /** Default: 50, max: 200 */
28
+ limit?: number;
29
+ /** Default: 0 */
30
+ offset?: number;
31
+ }
32
+ export interface PaginatedResult<T> {
33
+ items: T[];
34
+ total: number;
35
+ limit: number;
36
+ offset: number;
37
+ }
38
+ /**
39
+ * Create a new group. tenantId null = app-wide, string = tenant-scoped.
40
+ * The group name must be a slug (/^[a-z0-9_-]+$/) and unique within its scope.
41
+ * Returns the new group's id.
42
+ */
43
+ export declare const createGroup: (group: Omit<GroupRecord, "id" | "createdAt" | "updatedAt">) => Promise<{
44
+ id: string;
45
+ }>;
46
+ /**
47
+ * Delete a group by ID. All memberships are cascade-deleted by the adapter.
48
+ */
49
+ export declare const deleteGroup: (groupId: string) => Promise<void>;
50
+ /**
51
+ * Get a group by ID. Returns null if not found.
52
+ */
53
+ export declare const getGroup: (groupId: string) => Promise<GroupRecord | null>;
54
+ /**
55
+ * List groups scoped to a tenant (tenantId string) or app-wide (tenantId null).
56
+ * Results are paginated.
57
+ */
58
+ export declare const listGroups: (tenantId: string | null, opts?: PaginationOpts) => Promise<PaginatedResult<GroupRecord>>;
59
+ /**
60
+ * Update a group's mutable fields: name, displayName, description, roles.
61
+ * tenantId is NOT in the update type — it is immutable after creation.
62
+ */
63
+ export declare const updateGroup: (groupId: string, updates: Partial<Pick<GroupRecord, "roles" | "name" | "displayName" | "description">>) => Promise<void>;
64
+ /**
65
+ * Add a user to a group. Optionally supply per-membership roles (extras on top of group.roles).
66
+ *
67
+ * CONTRACT: throws if the user is already a member (unique constraint).
68
+ * Use updateGroupMembership to change roles on an existing membership.
69
+ * All adapters surface this as a thrown error, not a silent no-op.
70
+ */
71
+ export declare const addGroupMember: (groupId: string, userId: string, roles?: string[]) => Promise<void>;
72
+ /**
73
+ * Update the per-membership roles for an existing member.
74
+ * This replaces the member's roles[] entirely (not an additive operation).
75
+ */
76
+ export declare const updateGroupMembership: (groupId: string, userId: string, roles: string[]) => Promise<void>;
77
+ /**
78
+ * Remove a user from a group. No-op if the user is not a member.
79
+ */
80
+ export declare const removeGroupMember: (groupId: string, userId: string) => Promise<void>;
81
+ /**
82
+ * List members of a group, with their per-membership roles. Paginated.
83
+ */
84
+ export declare const getGroupMembers: (groupId: string, opts?: PaginationOpts) => Promise<PaginatedResult<{
85
+ userId: string;
86
+ roles: string[];
87
+ }>>;
88
+ /**
89
+ * List all groups a user belongs to, with their per-membership roles.
90
+ * Pass tenantId=null for app-wide groups, tenantId=string for tenant-scoped groups.
91
+ */
92
+ export declare const getUserGroups: (userId: string, tenantId: string | null) => Promise<Array<{
93
+ group: GroupRecord;
94
+ membershipRoles: string[];
95
+ }>>;
96
+ /**
97
+ * Return all roles a user effectively has in the given scope, combining:
98
+ * 1. Direct roles (app-wide or tenant-scoped)
99
+ * 2. Group baseline roles (from all groups the user belongs to in that scope)
100
+ * 3. Per-membership roles (user-specific extras within each group)
101
+ *
102
+ * SCOPE CONTRACT:
103
+ * - tenantId = null → app-wide direct roles + app-wide group roles
104
+ * - tenantId = string → tenant-scoped direct roles + tenant-scoped group roles
105
+ *
106
+ * Tenant-scoped group roles NEVER appear in app-wide effective roles and vice versa.
107
+ * This is intentional: a user who is "admin" only in a tenant-scoped group is NOT
108
+ * a global admin. Assign roles app-wide for global access.
109
+ *
110
+ * Used internally by requireRole and requireRole.global. Also exported for use in
111
+ * custom middleware, route handlers, or GET /auth/me enrichment.
112
+ */
113
+ export declare const getEffectiveRoles: (userId: string, tenantId: string | null) => Promise<string[]>;
@@ -0,0 +1,133 @@
1
+ import { getAuthAdapter } from "./authAdapter";
2
+ // ---------------------------------------------------------------------------
3
+ // Group CRUD
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * Create a new group. tenantId null = app-wide, string = tenant-scoped.
7
+ * The group name must be a slug (/^[a-z0-9_-]+$/) and unique within its scope.
8
+ * Returns the new group's id.
9
+ */
10
+ export const createGroup = async (group) => {
11
+ const adapter = getAuthAdapter();
12
+ if (!adapter.createGroup)
13
+ throw new Error("Auth adapter does not implement createGroup");
14
+ return adapter.createGroup(group);
15
+ };
16
+ /**
17
+ * Delete a group by ID. All memberships are cascade-deleted by the adapter.
18
+ */
19
+ export const deleteGroup = async (groupId) => {
20
+ const adapter = getAuthAdapter();
21
+ if (!adapter.deleteGroup)
22
+ throw new Error("Auth adapter does not implement deleteGroup");
23
+ return adapter.deleteGroup(groupId);
24
+ };
25
+ /**
26
+ * Get a group by ID. Returns null if not found.
27
+ */
28
+ export const getGroup = async (groupId) => {
29
+ const adapter = getAuthAdapter();
30
+ if (!adapter.getGroup)
31
+ throw new Error("Auth adapter does not implement getGroup");
32
+ return adapter.getGroup(groupId);
33
+ };
34
+ /**
35
+ * List groups scoped to a tenant (tenantId string) or app-wide (tenantId null).
36
+ * Results are paginated.
37
+ */
38
+ export const listGroups = async (tenantId, opts) => {
39
+ const adapter = getAuthAdapter();
40
+ if (!adapter.listGroups)
41
+ throw new Error("Auth adapter does not implement listGroups");
42
+ return adapter.listGroups(tenantId, opts);
43
+ };
44
+ /**
45
+ * Update a group's mutable fields: name, displayName, description, roles.
46
+ * tenantId is NOT in the update type — it is immutable after creation.
47
+ */
48
+ export const updateGroup = async (groupId, updates) => {
49
+ const adapter = getAuthAdapter();
50
+ if (!adapter.updateGroup)
51
+ throw new Error("Auth adapter does not implement updateGroup");
52
+ return adapter.updateGroup(groupId, updates);
53
+ };
54
+ // ---------------------------------------------------------------------------
55
+ // Membership management
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Add a user to a group. Optionally supply per-membership roles (extras on top of group.roles).
59
+ *
60
+ * CONTRACT: throws if the user is already a member (unique constraint).
61
+ * Use updateGroupMembership to change roles on an existing membership.
62
+ * All adapters surface this as a thrown error, not a silent no-op.
63
+ */
64
+ export const addGroupMember = async (groupId, userId, roles) => {
65
+ const adapter = getAuthAdapter();
66
+ if (!adapter.addGroupMember)
67
+ throw new Error("Auth adapter does not implement addGroupMember");
68
+ return adapter.addGroupMember(groupId, userId, roles);
69
+ };
70
+ /**
71
+ * Update the per-membership roles for an existing member.
72
+ * This replaces the member's roles[] entirely (not an additive operation).
73
+ */
74
+ export const updateGroupMembership = async (groupId, userId, roles) => {
75
+ const adapter = getAuthAdapter();
76
+ if (!adapter.updateGroupMembership)
77
+ throw new Error("Auth adapter does not implement updateGroupMembership");
78
+ return adapter.updateGroupMembership(groupId, userId, roles);
79
+ };
80
+ /**
81
+ * Remove a user from a group. No-op if the user is not a member.
82
+ */
83
+ export const removeGroupMember = async (groupId, userId) => {
84
+ const adapter = getAuthAdapter();
85
+ if (!adapter.removeGroupMember)
86
+ throw new Error("Auth adapter does not implement removeGroupMember");
87
+ return adapter.removeGroupMember(groupId, userId);
88
+ };
89
+ /**
90
+ * List members of a group, with their per-membership roles. Paginated.
91
+ */
92
+ export const getGroupMembers = async (groupId, opts) => {
93
+ const adapter = getAuthAdapter();
94
+ if (!adapter.getGroupMembers)
95
+ throw new Error("Auth adapter does not implement getGroupMembers");
96
+ return adapter.getGroupMembers(groupId, opts);
97
+ };
98
+ /**
99
+ * List all groups a user belongs to, with their per-membership roles.
100
+ * Pass tenantId=null for app-wide groups, tenantId=string for tenant-scoped groups.
101
+ */
102
+ export const getUserGroups = async (userId, tenantId) => {
103
+ const adapter = getAuthAdapter();
104
+ if (!adapter.getUserGroups)
105
+ throw new Error("Auth adapter does not implement getUserGroups");
106
+ return adapter.getUserGroups(userId, tenantId);
107
+ };
108
+ // ---------------------------------------------------------------------------
109
+ // Effective role resolution
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Return all roles a user effectively has in the given scope, combining:
113
+ * 1. Direct roles (app-wide or tenant-scoped)
114
+ * 2. Group baseline roles (from all groups the user belongs to in that scope)
115
+ * 3. Per-membership roles (user-specific extras within each group)
116
+ *
117
+ * SCOPE CONTRACT:
118
+ * - tenantId = null → app-wide direct roles + app-wide group roles
119
+ * - tenantId = string → tenant-scoped direct roles + tenant-scoped group roles
120
+ *
121
+ * Tenant-scoped group roles NEVER appear in app-wide effective roles and vice versa.
122
+ * This is intentional: a user who is "admin" only in a tenant-scoped group is NOT
123
+ * a global admin. Assign roles app-wide for global access.
124
+ *
125
+ * Used internally by requireRole and requireRole.global. Also exported for use in
126
+ * custom middleware, route handlers, or GET /auth/me enrichment.
127
+ */
128
+ export const getEffectiveRoles = async (userId, tenantId) => {
129
+ const adapter = getAuthAdapter();
130
+ if (!adapter.getEffectiveRoles)
131
+ throw new Error("Auth adapter does not implement getEffectiveRoles");
132
+ return adapter.getEffectiveRoles(userId, tenantId);
133
+ };
@@ -0,0 +1,22 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "./context";
3
+ export interface IdempotencyOptions {
4
+ /** TTL in seconds for cached responses. Default: 86400 (24 hours). */
5
+ ttl?: number;
6
+ }
7
+ type IdempotencyStore = "redis" | "mongo" | "sqlite" | "memory";
8
+ export declare const setIdempotencyStore: (store: IdempotencyStore) => void;
9
+ export declare const clearIdempotencyMemoryStore: () => void;
10
+ /**
11
+ * Idempotency middleware. Reads the `Idempotency-Key` header and returns a
12
+ * cached response if one exists for this user + key combination. Otherwise
13
+ * calls the next handler, stores the response, and returns it.
14
+ *
15
+ * On write collision (two concurrent identical requests), the second request
16
+ * re-reads and returns the first-stored result.
17
+ *
18
+ * When `signing.idempotencyKeys: true`, keys are HMAC'd before storage to
19
+ * prevent enumeration. When off, raw keys are stored (slight enumeration risk).
20
+ */
21
+ export declare const idempotent: (opts?: IdempotencyOptions) => MiddlewareHandler<AppEnv>;
22
+ export {};
@@ -0,0 +1,182 @@
1
+ import { getRedis } from "./redis";
2
+ import { appConnection, mongoose } from "./mongo";
3
+ import { getAppName } from "./appConfig";
4
+ import { getSigningConfig, getSigningSecret } from "./appConfig";
5
+ import { hmacSign } from "./signing";
6
+ import { HEADER_IDEMPOTENCY_KEY } from "./constants";
7
+ let _store = "redis";
8
+ export const setIdempotencyStore = (store) => { _store = store; };
9
+ // ---------------------------------------------------------------------------
10
+ // Memory store (tests only — no TTL eviction)
11
+ // ---------------------------------------------------------------------------
12
+ const _memory = new Map();
13
+ export const clearIdempotencyMemoryStore = () => _memory.clear();
14
+ function getIdempotencyModel() {
15
+ if (appConnection.models["Idempotency"])
16
+ return appConnection.models["Idempotency"];
17
+ const { Schema } = mongoose;
18
+ const schema = new Schema({
19
+ key: { type: String, required: true, unique: true },
20
+ status: { type: Number, required: true },
21
+ body: { type: String, required: true },
22
+ createdAt: { type: Date, required: true },
23
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
24
+ }, { collection: "idempotency" });
25
+ return appConnection.model("Idempotency", schema);
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // SQLite helpers (lazy — only available when bun:sqlite is in use)
29
+ // ---------------------------------------------------------------------------
30
+ function getSqliteDb() {
31
+ const { getDb } = require("../adapters/sqliteAuth");
32
+ return getDb();
33
+ }
34
+ function sqliteEnsureTable() {
35
+ const db = getSqliteDb();
36
+ db.run(`CREATE TABLE IF NOT EXISTS idempotency (
37
+ key TEXT PRIMARY KEY,
38
+ status INTEGER NOT NULL,
39
+ body TEXT NOT NULL,
40
+ createdAt INTEGER NOT NULL,
41
+ expiresAt INTEGER NOT NULL
42
+ )`);
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Key derivation
46
+ // ---------------------------------------------------------------------------
47
+ function deriveKey(rawKey, userId) {
48
+ const prefix = userId ?? "anon";
49
+ const cfg = getSigningConfig();
50
+ if (cfg?.idempotencyKeys) {
51
+ const secret = getSigningSecret();
52
+ if (secret) {
53
+ return `${prefix}:${hmacSign(rawKey, secret)}`;
54
+ }
55
+ }
56
+ return `${prefix}:${rawKey}`;
57
+ }
58
+ function redisIdempotencyKey(key) {
59
+ return `idempotency:${getAppName()}:${key}`;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Store operations
63
+ // ---------------------------------------------------------------------------
64
+ async function getRecord(key) {
65
+ if (_store === "memory") {
66
+ return _memory.get(key) ?? null;
67
+ }
68
+ if (_store === "sqlite") {
69
+ sqliteEnsureTable();
70
+ const row = getSqliteDb().query("SELECT status, body, createdAt FROM idempotency WHERE key = ? AND expiresAt > ?").get(key, Date.now());
71
+ return row ? { status: row.status, body: row.body, createdAt: row.createdAt } : null;
72
+ }
73
+ if (_store === "redis") {
74
+ const raw = await getRedis().get(redisIdempotencyKey(key));
75
+ if (!raw)
76
+ return null;
77
+ return JSON.parse(raw);
78
+ }
79
+ // mongo
80
+ const doc = await getIdempotencyModel()
81
+ .findOne({ key, expiresAt: { $gt: new Date() } }, "status body createdAt")
82
+ .lean();
83
+ return doc ? { status: doc.status, body: doc.body, createdAt: doc.createdAt.getTime() } : null;
84
+ }
85
+ /**
86
+ * Attempt to store a record. Returns true if stored, false if a record
87
+ * already exists (write collision — treat as cache hit).
88
+ */
89
+ async function tryStoreRecord(key, record, ttl) {
90
+ if (_store === "memory") {
91
+ if (_memory.has(key))
92
+ return false;
93
+ _memory.set(key, record);
94
+ return true;
95
+ }
96
+ if (_store === "sqlite") {
97
+ sqliteEnsureTable();
98
+ const db = getSqliteDb();
99
+ const expiresAt = record.createdAt + ttl * 1000;
100
+ db.run("INSERT OR IGNORE INTO idempotency (key, status, body, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)", [key, record.status, record.body, record.createdAt, expiresAt]);
101
+ // SQLite INSERT OR IGNORE doesn't throw on conflict; check changes count
102
+ const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
103
+ return changes > 0;
104
+ }
105
+ if (_store === "redis") {
106
+ // SET NX: set-if-not-exists — second concurrent writer gets a no-op
107
+ const value = JSON.stringify(record);
108
+ const result = await getRedis().set(redisIdempotencyKey(key), value, "EX", ttl, "NX");
109
+ return result === "OK";
110
+ }
111
+ // mongo — unique index on key; second writer catches duplicate key error
112
+ try {
113
+ const now = new Date(record.createdAt);
114
+ await getIdempotencyModel().create({
115
+ key,
116
+ status: record.status,
117
+ body: record.body,
118
+ createdAt: now,
119
+ expiresAt: new Date(now.getTime() + ttl * 1000),
120
+ });
121
+ return true;
122
+ }
123
+ catch (err) {
124
+ // Duplicate key — another concurrent request already stored the result
125
+ if (err?.code === 11000 || err?.code === "11000")
126
+ return false;
127
+ throw err;
128
+ }
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Middleware factory
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * Idempotency middleware. Reads the `Idempotency-Key` header and returns a
135
+ * cached response if one exists for this user + key combination. Otherwise
136
+ * calls the next handler, stores the response, and returns it.
137
+ *
138
+ * On write collision (two concurrent identical requests), the second request
139
+ * re-reads and returns the first-stored result.
140
+ *
141
+ * When `signing.idempotencyKeys: true`, keys are HMAC'd before storage to
142
+ * prevent enumeration. When off, raw keys are stored (slight enumeration risk).
143
+ */
144
+ export const idempotent = (opts) => async (c, next) => {
145
+ const rawKey = c.req.header(HEADER_IDEMPOTENCY_KEY);
146
+ if (!rawKey) {
147
+ await next();
148
+ return;
149
+ }
150
+ const userId = c.get("authUserId") ?? null;
151
+ const key = deriveKey(rawKey, userId);
152
+ const ttl = opts?.ttl ?? 86400;
153
+ // Cache hit — return stored response
154
+ const cached = await getRecord(key);
155
+ if (cached) {
156
+ return c.json(JSON.parse(cached.body), cached.status);
157
+ }
158
+ // Cache miss — call handler
159
+ await next();
160
+ // Capture the response body by reading it
161
+ const status = c.res.status;
162
+ let body = "";
163
+ try {
164
+ body = await c.res.clone().text();
165
+ }
166
+ catch {
167
+ // Non-text/non-json response — skip caching
168
+ return;
169
+ }
170
+ const record = { status, body, createdAt: Date.now() };
171
+ const stored = await tryStoreRecord(key, record, ttl);
172
+ if (!stored) {
173
+ // Write collision — return the first-stored result
174
+ const winner = await getRecord(key);
175
+ if (winner) {
176
+ c.res = new Response(winner.body, {
177
+ status: winner.status,
178
+ headers: { "content-type": "application/json" },
179
+ });
180
+ }
181
+ }
182
+ };
@@ -0,0 +1,25 @@
1
+ import { type JWK } from "jose";
2
+ export interface JwksKeyConfig {
3
+ privateKey: string;
4
+ publicKey: string;
5
+ kid?: string;
6
+ }
7
+ type KeyMaterial = CryptoKey;
8
+ export declare function loadJwksKey(config: JwksKeyConfig): Promise<void>;
9
+ export declare function loadPreviousKey(config: {
10
+ publicKey: string;
11
+ kid?: string;
12
+ }): Promise<void>;
13
+ export declare function generateAndLoadKeyPair(): Promise<{
14
+ privateKey: string;
15
+ publicKey: string;
16
+ }>;
17
+ export declare function getSigningPrivateKey(): KeyMaterial;
18
+ export declare function getVerifyPublicKeys(): KeyMaterial[];
19
+ export declare function getJwks(): {
20
+ keys: JWK[];
21
+ };
22
+ export declare function isJwksLoaded(): boolean;
23
+ /** @internal — reset for tests */
24
+ export declare function _resetJwksState(): void;
25
+ export {};
@@ -0,0 +1,51 @@
1
+ import { generateKeyPair, exportJWK, importPKCS8, importSPKI } from "jose";
2
+ let _primaryKey = null;
3
+ let _previousKeys = [];
4
+ export async function loadJwksKey(config) {
5
+ const kid = config.kid ?? "key-1";
6
+ const privateKey = await importPKCS8(config.privateKey, "RS256");
7
+ const publicKey = await importSPKI(config.publicKey, "RS256");
8
+ const jwk = await exportJWK(publicKey);
9
+ _primaryKey = { privateKey, publicKey, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid };
10
+ }
11
+ export async function loadPreviousKey(config) {
12
+ const kid = config.kid ?? `key-prev-${_previousKeys.length + 1}`;
13
+ const publicKey = await importSPKI(config.publicKey, "RS256");
14
+ const jwk = await exportJWK(publicKey);
15
+ _previousKeys.push({ privateKey: null, publicKey, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid });
16
+ }
17
+ export async function generateAndLoadKeyPair() {
18
+ const { privateKey: pk, publicKey: pubk } = await generateKeyPair("RS256", { modulusLength: 2048, extractable: true });
19
+ const { exportSPKI, exportPKCS8 } = await import("jose");
20
+ const privatePem = await exportPKCS8(pk);
21
+ const publicPem = await exportSPKI(pubk);
22
+ await loadJwksKey({ privateKey: privatePem, publicKey: publicPem, kid: "key-1" });
23
+ return { privateKey: privatePem, publicKey: publicPem };
24
+ }
25
+ export function getSigningPrivateKey() {
26
+ if (!_primaryKey)
27
+ throw new Error("RS256 requires OIDC key configuration — call loadJwksKey() first");
28
+ return _primaryKey.privateKey;
29
+ }
30
+ export function getVerifyPublicKeys() {
31
+ const keys = [];
32
+ if (_primaryKey)
33
+ keys.push(_primaryKey.publicKey);
34
+ keys.push(..._previousKeys.map((k) => k.publicKey));
35
+ return keys;
36
+ }
37
+ export function getJwks() {
38
+ const keys = [];
39
+ if (_primaryKey)
40
+ keys.push(_primaryKey.jwk);
41
+ keys.push(..._previousKeys.map((k) => k.jwk));
42
+ return { keys };
43
+ }
44
+ export function isJwksLoaded() {
45
+ return _primaryKey !== null;
46
+ }
47
+ /** @internal — reset for tests */
48
+ export function _resetJwksState() {
49
+ _primaryKey = null;
50
+ _previousKeys = [];
51
+ }
package/dist/lib/jwt.d.ts CHANGED
@@ -1,2 +1,15 @@
1
- export declare const signToken: (userId: string, sessionId: string, expirySeconds?: number) => Promise<string>;
2
- export declare const verifyToken: (token: string) => Promise<import("jose").JWTPayload>;
1
+ import type { JWTPayload } from "jose";
2
+ export declare function validateJwtSecrets(): void;
3
+ export type TokenClaims = {
4
+ sub: string;
5
+ sid?: string;
6
+ scope?: string;
7
+ [key: string]: unknown;
8
+ };
9
+ export declare function signToken(claims: TokenClaims, expirySeconds?: number): Promise<string>;
10
+ export declare function signToken(userId: string, sessionId: string, expirySeconds?: number): Promise<string>;
11
+ export declare const verifyToken: (token: string) => Promise<JWTPayload>;
12
+ /** @internal — used by Feature 8 (OIDC) to switch to RS256 once key material is loaded */
13
+ export declare function _setAlgorithm(alg: string): void;
14
+ /** @internal — reset for testing */
15
+ export declare function _resetJwtState(): void;