@stacksjs/auth 0.70.87 → 0.70.90

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/rbac.js ADDED
@@ -0,0 +1,324 @@
1
+ class BoundedMap {
2
+ max;
3
+ map = new Map;
4
+ constructor(max) {
5
+ this.max = max;
6
+ }
7
+ get(key) {
8
+ return this.map.get(key);
9
+ }
10
+ has(key) {
11
+ return this.map.has(key);
12
+ }
13
+ set(key, value) {
14
+ if (this.map.has(key))
15
+ this.map.delete(key);
16
+ this.map.set(key, value);
17
+ if (this.map.size > this.max) {
18
+ const oldest = this.map.keys().next().value;
19
+ if (oldest !== void 0)
20
+ this.map.delete(oldest);
21
+ }
22
+ return this;
23
+ }
24
+ delete(key) {
25
+ return this.map.delete(key);
26
+ }
27
+ clear() {
28
+ this.map.clear();
29
+ }
30
+ }
31
+ const RBAC_USER_CACHE_MAX = 1e4, cache = {
32
+ userRoles: new BoundedMap(RBAC_USER_CACHE_MAX),
33
+ userPermissions: new BoundedMap(RBAC_USER_CACHE_MAX),
34
+ rolePermissions: new BoundedMap(RBAC_USER_CACHE_MAX),
35
+ roles: new Map,
36
+ permissions: new Map
37
+ };
38
+ let store = null;
39
+ export function setRbacStore(rbacStore) {
40
+ store = rbacStore;
41
+ flushRbacCache();
42
+ }
43
+ function getStore() {
44
+ if (!store)
45
+ throw Error("RBAC store not configured. Call setRbacStore() first.");
46
+ return store;
47
+ }
48
+ export function flushRbacCache() {
49
+ cache.userRoles.clear();
50
+ cache.userPermissions.clear();
51
+ cache.rolePermissions.clear();
52
+ cache.roles.clear();
53
+ cache.permissions.clear();
54
+ }
55
+ function getUserId(user) {
56
+ if (typeof user === "number") {
57
+ if (!Number.isFinite(user) || user <= 0)
58
+ throw TypeError("RBAC user id must be a positive number");
59
+ return user;
60
+ }
61
+ const id = user.id;
62
+ if (typeof id !== "number" || !Number.isFinite(id) || id <= 0)
63
+ throw TypeError(`RBAC user id must be a positive number, got ${typeof id} (${String(id)})`);
64
+ return id;
65
+ }
66
+ export async function createRole(name, guardName = "web", description) {
67
+ const role = await getStore().createRole(name, guardName, description);
68
+ cache.roles.set(`${name}:${guardName}`, role);
69
+ return role;
70
+ }
71
+ export async function findRole(name, guardName = "web") {
72
+ const cacheKey = `${name}:${guardName}`;
73
+ if (cache.roles.has(cacheKey))
74
+ return cache.roles.get(cacheKey);
75
+ const role = await getStore().findRoleByName(name, guardName);
76
+ if (role)
77
+ cache.roles.set(cacheKey, role);
78
+ return role;
79
+ }
80
+ export async function deleteRole(name, guardName = "web") {
81
+ const role = await findRole(name, guardName);
82
+ if (role) {
83
+ await getStore().deleteRole(role.id);
84
+ cache.roles.delete(`${name}:${guardName}`);
85
+ flushRbacCache();
86
+ }
87
+ }
88
+ export async function getAllRoles(guardName) {
89
+ return getStore().getAllRoles(guardName);
90
+ }
91
+ export async function createPermission(name, guardName = "web", description) {
92
+ const permission = await getStore().createPermission(name, guardName, description);
93
+ cache.permissions.set(`${name}:${guardName}`, permission);
94
+ return permission;
95
+ }
96
+ export async function findPermission(name, guardName = "web") {
97
+ const cacheKey = `${name}:${guardName}`;
98
+ if (cache.permissions.has(cacheKey))
99
+ return cache.permissions.get(cacheKey);
100
+ const permission = await getStore().findPermissionByName(name, guardName);
101
+ if (permission)
102
+ cache.permissions.set(cacheKey, permission);
103
+ return permission;
104
+ }
105
+ export async function deletePermission(name, guardName = "web") {
106
+ const permission = await findPermission(name, guardName);
107
+ if (permission) {
108
+ await getStore().deletePermission(permission.id);
109
+ cache.permissions.delete(`${name}:${guardName}`);
110
+ flushRbacCache();
111
+ }
112
+ }
113
+ export async function getAllPermissions(guardName) {
114
+ return getStore().getAllPermissions(guardName);
115
+ }
116
+ export async function getUserRoles(user) {
117
+ const userId = getUserId(user);
118
+ if (cache.userRoles.has(userId))
119
+ return cache.userRoles.get(userId);
120
+ const roles = await getStore().getUserRoles(userId);
121
+ cache.userRoles.set(userId, roles);
122
+ return roles;
123
+ }
124
+ export async function assignRole(user, roleName, guardName = "web") {
125
+ const userId = getUserId(user), role = await findRole(roleName, guardName);
126
+ if (!role)
127
+ throw Error(`Role '${roleName}' not found.`);
128
+ await getStore().assignRoleToUser(userId, role.id);
129
+ cache.userRoles.delete(userId);
130
+ cache.userPermissions.delete(userId);
131
+ }
132
+ export async function removeRole(user, roleName, guardName = "web") {
133
+ const userId = getUserId(user), role = await findRole(roleName, guardName);
134
+ if (!role)
135
+ return;
136
+ await getStore().removeRoleFromUser(userId, role.id);
137
+ cache.userRoles.delete(userId);
138
+ cache.userPermissions.delete(userId);
139
+ }
140
+ export async function removeAllRoles(user) {
141
+ const userId = getUserId(user);
142
+ await getStore().removeAllRolesFromUser(userId);
143
+ cache.userRoles.delete(userId);
144
+ cache.userPermissions.delete(userId);
145
+ }
146
+ export async function syncRoles(user, roleNames, guardName = "web") {
147
+ const userId = getUserId(user), roleIds = [];
148
+ for (const name of roleNames) {
149
+ const role = await findRole(name, guardName);
150
+ if (!role)
151
+ throw Error(`Role '${name}' not found.`);
152
+ roleIds.push(role.id);
153
+ }
154
+ await getStore().syncUserRoles(userId, roleIds);
155
+ cache.userRoles.delete(userId);
156
+ cache.userPermissions.delete(userId);
157
+ }
158
+ export async function hasRole(user, roleName, guardName = "web") {
159
+ return (await getUserRoles(user)).some((r) => r.name === roleName && r.guard_name === guardName);
160
+ }
161
+ export async function hasAnyRole(user, roleNames, guardName = "web") {
162
+ const roles = await getUserRoles(user);
163
+ return roleNames.some((name) => roles.some((r) => r.name === name && r.guard_name === guardName));
164
+ }
165
+ export async function hasAllRoles(user, roleNames, guardName = "web") {
166
+ const roles = await getUserRoles(user);
167
+ return roleNames.every((name) => roles.some((r) => r.name === name && r.guard_name === guardName));
168
+ }
169
+ export async function getUserPermissions(user) {
170
+ const userId = getUserId(user);
171
+ if (cache.userPermissions.has(userId))
172
+ return cache.userPermissions.get(userId);
173
+ const directPermissions = await getStore().getUserDirectPermissions(userId), roles = await getUserRoles(user), rolePermissions = [];
174
+ for (const role of roles) {
175
+ const perms = await getRolePermissions(role.id);
176
+ rolePermissions.push(...perms);
177
+ }
178
+ const seen = new Set, allPermissions = [];
179
+ for (const perm of [...directPermissions, ...rolePermissions])
180
+ if (!seen.has(perm.id)) {
181
+ seen.add(perm.id);
182
+ allPermissions.push(perm);
183
+ }
184
+ cache.userPermissions.set(userId, allPermissions);
185
+ return allPermissions;
186
+ }
187
+ export async function givePermission(user, permissionName, guardName = "web") {
188
+ const userId = getUserId(user), permission = await findPermission(permissionName, guardName);
189
+ if (!permission)
190
+ throw Error(`Permission '${permissionName}' not found.`);
191
+ await getStore().assignPermissionToUser(userId, permission.id);
192
+ cache.userPermissions.delete(userId);
193
+ }
194
+ export async function revokePermission(user, permissionName, guardName = "web") {
195
+ const userId = getUserId(user), permission = await findPermission(permissionName, guardName);
196
+ if (!permission)
197
+ return;
198
+ await getStore().removePermissionFromUser(userId, permission.id);
199
+ cache.userPermissions.delete(userId);
200
+ }
201
+ export async function revokeAllPermissions(user) {
202
+ const userId = getUserId(user);
203
+ await getStore().removeAllPermissionsFromUser(userId);
204
+ cache.userPermissions.delete(userId);
205
+ }
206
+ export async function syncPermissions(user, permissionNames, guardName = "web") {
207
+ const userId = getUserId(user), permissionIds = [];
208
+ for (const name of permissionNames) {
209
+ const perm = await findPermission(name, guardName);
210
+ if (!perm)
211
+ throw Error(`Permission '${name}' not found.`);
212
+ permissionIds.push(perm.id);
213
+ }
214
+ await getStore().syncUserPermissions(userId, permissionIds);
215
+ cache.userPermissions.delete(userId);
216
+ }
217
+ export async function hasPermission(user, permissionName, guardName = "web") {
218
+ return (await getUserPermissions(user)).some((p) => p.name === permissionName && p.guard_name === guardName);
219
+ }
220
+ export async function hasAnyPermission(user, permissionNames, guardName = "web") {
221
+ const permissions = await getUserPermissions(user);
222
+ return permissionNames.some((name) => permissions.some((p) => p.name === name && p.guard_name === guardName));
223
+ }
224
+ export async function hasAllPermissions(user, permissionNames, guardName = "web") {
225
+ const permissions = await getUserPermissions(user);
226
+ return permissionNames.every((name) => permissions.some((p) => p.name === name && p.guard_name === guardName));
227
+ }
228
+ export async function getRolePermissions(roleId) {
229
+ if (cache.rolePermissions.has(roleId))
230
+ return cache.rolePermissions.get(roleId);
231
+ const permissions = await getStore().getRolePermissions(roleId);
232
+ cache.rolePermissions.set(roleId, permissions);
233
+ return permissions;
234
+ }
235
+ export async function givePermissionToRole(roleName, permissionName, guardName = "web") {
236
+ const role = await findRole(roleName, guardName);
237
+ if (!role)
238
+ throw Error(`Role '${roleName}' not found.`);
239
+ const permission = await findPermission(permissionName, guardName);
240
+ if (!permission)
241
+ throw Error(`Permission '${permissionName}' not found.`);
242
+ await getStore().assignPermissionToRole(role.id, permission.id);
243
+ cache.rolePermissions.delete(role.id);
244
+ cache.userPermissions.clear();
245
+ }
246
+ export async function revokePermissionFromRole(roleName, permissionName, guardName = "web") {
247
+ const role = await findRole(roleName, guardName);
248
+ if (!role)
249
+ return;
250
+ const permission = await findPermission(permissionName, guardName);
251
+ if (!permission)
252
+ return;
253
+ await getStore().removePermissionFromRole(role.id, permission.id);
254
+ cache.rolePermissions.delete(role.id);
255
+ cache.userPermissions.clear();
256
+ }
257
+ export async function syncRolePermissions(roleName, permissionNames, guardName = "web") {
258
+ const role = await findRole(roleName, guardName);
259
+ if (!role)
260
+ throw Error(`Role '${roleName}' not found.`);
261
+ const permissionIds = [];
262
+ for (const name of permissionNames) {
263
+ const perm = await findPermission(name, guardName);
264
+ if (!perm)
265
+ throw Error(`Permission '${name}' not found.`);
266
+ permissionIds.push(perm.id);
267
+ }
268
+ await getStore().syncRolePermissions(role.id, permissionIds);
269
+ cache.rolePermissions.delete(role.id);
270
+ cache.userPermissions.clear();
271
+ }
272
+ export function withRbac(user) {
273
+ const userId = getUserId(user);
274
+ return Object.assign(user, {
275
+ hasRole: (roleName, guardName) => hasRole(userId, roleName, guardName),
276
+ hasAnyRole: (roleNames, guardName) => hasAnyRole(userId, roleNames, guardName),
277
+ hasAllRoles: (roleNames, guardName) => hasAllRoles(userId, roleNames, guardName),
278
+ hasPermission: (permissionName, guardName) => hasPermission(userId, permissionName, guardName),
279
+ hasAnyPermission: (permissionNames, guardName) => hasAnyPermission(userId, permissionNames, guardName),
280
+ hasAllPermissions: (permissionNames, guardName) => hasAllPermissions(userId, permissionNames, guardName),
281
+ getRoles: () => getUserRoles(userId),
282
+ getPermissions: () => getUserPermissions(userId),
283
+ assignRole: (roleName, guardName) => assignRole(userId, roleName, guardName),
284
+ removeRole: (roleName, guardName) => removeRole(userId, roleName, guardName),
285
+ syncRoles: (roleNames, guardName) => syncRoles(userId, roleNames, guardName),
286
+ givePermission: (permissionName, guardName) => givePermission(userId, permissionName, guardName),
287
+ revokePermission: (permissionName, guardName) => revokePermission(userId, permissionName, guardName),
288
+ syncPermissions: (permissionNames, guardName) => syncPermissions(userId, permissionNames, guardName)
289
+ });
290
+ }
291
+ export const Rbac = {
292
+ setStore: setRbacStore,
293
+ flushCache: flushRbacCache,
294
+ createRole,
295
+ findRole,
296
+ deleteRole,
297
+ getAllRoles,
298
+ createPermission,
299
+ findPermission,
300
+ deletePermission,
301
+ getAllPermissions,
302
+ getUserRoles,
303
+ assignRole,
304
+ removeRole,
305
+ removeAllRoles,
306
+ syncRoles,
307
+ hasRole,
308
+ hasAnyRole,
309
+ hasAllRoles,
310
+ getUserPermissions,
311
+ givePermission,
312
+ revokePermission,
313
+ revokeAllPermissions,
314
+ syncPermissions,
315
+ hasPermission,
316
+ hasAnyPermission,
317
+ hasAllPermissions,
318
+ getRolePermissions,
319
+ givePermissionToRole,
320
+ revokePermissionFromRole,
321
+ syncRolePermissions,
322
+ withRbac
323
+ };
324
+ export default Rbac;
@@ -0,0 +1,43 @@
1
+ import { config } from "@stacksjs/config";
2
+ import { db } from "@stacksjs/database";
3
+ import { HttpError } from "@stacksjs/error-handling";
4
+ import { User } from "@stacksjs/orm";
5
+ import { makeHash } from "@stacksjs/security";
6
+ import { Auth } from "./authentication";
7
+ import { isUniqueViolation } from "./rbac-store-bqb";
8
+ function duplicateEmailError() {
9
+ if (config.auth?.registration?.preventEnumeration)
10
+ return new HttpError(422, "Registration could not be completed. Please check your details and try again.");
11
+ return new HttpError(409, "Email already exists");
12
+ }
13
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
14
+ export async function register(credentials) {
15
+ const { email, password, name } = credentials;
16
+ if (typeof email !== "string" || email.length > 254 || !EMAIL_RE.test(email))
17
+ throw new HttpError(422, "Email address is invalid");
18
+ if (typeof password !== "string" || password.length < 8)
19
+ throw new HttpError(422, "Password must be at least 8 characters");
20
+ const hashedPassword = await makeHash(password, { algorithm: "bcrypt" }), userId = await db.transaction(async (rawTrx) => {
21
+ const trx = rawTrx;
22
+ if (await trx.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst())
23
+ throw duplicateEmailError();
24
+ try {
25
+ await trx.insertInto("users").values({
26
+ email,
27
+ password: hashedPassword,
28
+ name
29
+ }).execute();
30
+ } catch (err) {
31
+ if (isUniqueViolation(err))
32
+ throw duplicateEmailError();
33
+ throw err;
34
+ }
35
+ const created = await trx.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst();
36
+ if (!created)
37
+ throw Error("Failed to retrieve created user");
38
+ return Number(created.id);
39
+ }), user = await User.find(userId);
40
+ if (!user)
41
+ throw Error("Failed to retrieve created user");
42
+ return { token: await Auth.createToken(user, "user-auth-token") };
43
+ }
@@ -1,4 +1,22 @@
1
1
  import { User } from '@stacksjs/orm';
2
+ /**
3
+ * Decide whether a stored session must be rejected because its captured
4
+ * fingerprint no longer matches the current request (basic hijack detection,
5
+ * stacksjs/stacks#1985 — H-6 stored these fields but never compared them).
6
+ *
7
+ * OFF by default: a legitimately changing client IP (mobile/VPN) would
8
+ * otherwise lock the real user out. Opt in via
9
+ * `config.auth.session.enforceFingerprint` — `true` enforces both fields, or
10
+ * `{ ip, userAgent }` enforces each independently (userAgent-only is the
11
+ * safest, since it rarely changes for a real user).
12
+ *
13
+ * A field is compared ONLY when both the stored value and a current-request
14
+ * value are present, so a check outside request scope, or a session logged in
15
+ * without headers, is never a false "mismatch". On mismatch we reject THIS
16
+ * request but leave the session row intact — the real owner (original
17
+ * fingerprint) keeps working; we don't punish them for an attacker's attempt.
18
+ */
19
+ export declare function fingerprintMismatch(enforce: boolean | { ip?: boolean, userAgent?: boolean } | undefined | null, stored: { ip?: unknown, userAgent?: unknown }, current: { ip: string | null, userAgent: string | null }): boolean;
2
20
  /**
3
21
  * Authenticate a user via email and password, creating a session.
4
22
  * Sessions are persisted to the database so they survive server restarts.
@@ -0,0 +1,151 @@
1
+ import { HttpError } from "@stacksjs/error-handling";
2
+ import { log } from "@stacksjs/logging";
3
+ import { User } from "@stacksjs/orm";
4
+ import { verifyHash } from "@stacksjs/security";
5
+ import { config } from "@stacksjs/config";
6
+ import { db } from "@stacksjs/database";
7
+ import { getCurrentRequest } from "@stacksjs/router";
8
+ import { DUMMY_BCRYPT_HASH } from "./internal-constants";
9
+ import { RateLimiter } from "./rate-limiter";
10
+ function generateSessionId() {
11
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
12
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
13
+ }
14
+ function readRequestFingerprint(override) {
15
+ if (override)
16
+ return {
17
+ ip: override.ip ?? null,
18
+ userAgent: override.userAgent ?? null
19
+ };
20
+ const req = getCurrentRequest();
21
+ if (!req)
22
+ return { ip: null, userAgent: null };
23
+ const headers = req.headers, fwd = headers?.get?.("x-forwarded-for") || headers?.get?.("X-Forwarded-For") || "", realIp = headers?.get?.("x-real-ip") || headers?.get?.("X-Real-IP") || "", ip = (fwd ? fwd.split(",")[0].trim() : "") || realIp || null, userAgent = headers?.get?.("user-agent") || headers?.get?.("User-Agent") || null;
24
+ return { ip, userAgent };
25
+ }
26
+ export function fingerprintMismatch(enforce, stored, current) {
27
+ if (!enforce)
28
+ return !1;
29
+ const enforceIp = enforce === !0 || !!enforce.ip, enforceUa = enforce === !0 || !!enforce.userAgent;
30
+ if (!enforceIp && !enforceUa)
31
+ return !1;
32
+ if (enforceIp && current.ip != null && stored.ip != null && String(stored.ip) !== current.ip)
33
+ return !0;
34
+ if (enforceUa && current.userAgent != null && stored.userAgent != null && String(stored.userAgent) !== current.userAgent)
35
+ return !0;
36
+ return !1;
37
+ }
38
+ function sessionFingerprintRejected(session) {
39
+ return fingerprintMismatch(config.auth?.session?.enforceFingerprint, { ip: session.ip_address, userAgent: session.user_agent }, readRequestFingerprint());
40
+ }
41
+ export async function sessionLogin(email, password, fingerprint) {
42
+ const normalizedEmail = (email || "").toLowerCase(), isRateLimited = await RateLimiter.isRateLimited(normalizedEmail), user = await User.where("email", email).first(), hashToVerify = user?.password || DUMMY_BCRYPT_HASH, isValid = await verifyHash(password, hashToVerify);
43
+ if (isRateLimited)
44
+ throw new HttpError(429, "Too many login attempts. Please try again later.");
45
+ if (!isValid || !user) {
46
+ await RateLimiter.recordFailedAttempt(normalizedEmail);
47
+ throw new HttpError(401, "Invalid credentials");
48
+ }
49
+ await RateLimiter.resetAttempts(normalizedEmail);
50
+ const sessionId = generateSessionId(), expiresAt = new Date(Date.now() + 86400000), { ip, userAgent } = readRequestFingerprint(fingerprint);
51
+ log.debug(`[auth] Session created for user#${user.id}`);
52
+ try {
53
+ await db.insertInto("sessions").values({
54
+ id: sessionId,
55
+ user_id: user.id,
56
+ ip_address: ip,
57
+ user_agent: userAgent,
58
+ payload: "{}",
59
+ last_activity: Math.floor(Date.now() / 1000),
60
+ expires_at: expiresAt.toISOString()
61
+ }).execute();
62
+ } catch (err) {
63
+ log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);
64
+ throw new HttpError(500, "Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).");
65
+ }
66
+ return { user, sessionId };
67
+ }
68
+ export async function sessionLogout(sessionId) {
69
+ log.debug("[auth] Session destroyed");
70
+ try {
71
+ await db.deleteFrom("sessions").where("id", "=", sessionId).execute();
72
+ } catch (err) {
73
+ log.debug(`[auth] Session destroy failed: ${err.message}`);
74
+ }
75
+ }
76
+ export async function sessionDestroyAll(userId) {
77
+ try {
78
+ await db.deleteFrom("sessions").where("user_id", "=", userId).execute();
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ if (message.includes("sessions") && /no such table|does not exist|doesn't exist/i.test(message))
82
+ return;
83
+ throw err;
84
+ }
85
+ }
86
+ export async function sessionUser(sessionId) {
87
+ try {
88
+ const session = await db.selectFrom("sessions").where("id", "=", sessionId).selectAll().executeTakeFirst();
89
+ if (!session)
90
+ return;
91
+ const expiresAt = session.expires_at ? new Date(String(session.expires_at)).getTime() : 0;
92
+ if (Date.now() > expiresAt) {
93
+ await db.deleteFrom("sessions").where("id", "=", sessionId).execute();
94
+ return;
95
+ }
96
+ if (sessionFingerprintRejected(session)) {
97
+ log.warn("[auth] Session fingerprint mismatch \u2014 rejecting request (possible hijack, session left intact)");
98
+ return;
99
+ }
100
+ return await User.find(session.user_id);
101
+ } catch {
102
+ return;
103
+ }
104
+ }
105
+ export async function sessionCheck(sessionId) {
106
+ try {
107
+ const session = await db.selectFrom("sessions").where("id", "=", sessionId).selectAll().executeTakeFirst();
108
+ if (!session)
109
+ return !1;
110
+ const expiresAt = session.expires_at ? new Date(String(session.expires_at)).getTime() : 0;
111
+ if (Date.now() > expiresAt) {
112
+ await db.deleteFrom("sessions").where("id", "=", sessionId).execute();
113
+ return !1;
114
+ }
115
+ if (sessionFingerprintRejected(session))
116
+ return !1;
117
+ return !0;
118
+ } catch {
119
+ return !1;
120
+ }
121
+ }
122
+ export async function sessionRefresh(sessionId, ttlMs = 86400000) {
123
+ try {
124
+ const session = await db.selectFrom("sessions").where("id", "=", sessionId).selectAll().executeTakeFirst();
125
+ if (!session)
126
+ return !1;
127
+ const expiresAt = session.expires_at ? new Date(String(session.expires_at)).getTime() : 0;
128
+ if (Date.now() > expiresAt) {
129
+ await db.deleteFrom("sessions").where("id", "=", sessionId).execute();
130
+ return !1;
131
+ }
132
+ if (sessionFingerprintRejected(session))
133
+ return !1;
134
+ const newExpiry = new Date(Date.now() + ttlMs);
135
+ await db.updateTable("sessions").set({
136
+ expires_at: newExpiry.toISOString(),
137
+ last_activity: Math.floor(Date.now() / 1000)
138
+ }).where("id", "=", sessionId).execute();
139
+ return !0;
140
+ } catch {
141
+ return !1;
142
+ }
143
+ }
144
+ export const SessionAuth = {
145
+ login: sessionLogin,
146
+ logout: sessionLogout,
147
+ destroyAll: sessionDestroyAll,
148
+ user: sessionUser,
149
+ check: sessionCheck,
150
+ refresh: sessionRefresh
151
+ };
package/dist/team.js ADDED
@@ -0,0 +1,88 @@
1
+ import { config } from "@stacksjs/config";
2
+ import { db } from "@stacksjs/database";
3
+ import { Auth } from "./authentication";
4
+ import { sessionUser } from "./session-auth";
5
+ export const ACTIVE_TEAM_COOKIE = "active_team";
6
+ export function selectActiveTeam(opts) {
7
+ const priority = opts.rolePriority ?? { owner: 0, admin: 1 }, sorted = [...opts.memberships ?? []].sort((a, b) => (priority[a.role ?? ""] ?? 2) - (priority[b.role ?? ""] ?? 2)), active = opts.activeTeamId == null ? null : Number(opts.activeTeamId);
8
+ if (active != null && Number.isFinite(active)) {
9
+ const member = sorted.find((x) => Number(x.team_id) === active);
10
+ if (member)
11
+ return { teamId: active, role: member.role ?? null };
12
+ if (opts.allowAnyTeam)
13
+ return { teamId: active, role: "admin" };
14
+ }
15
+ const first = sorted[0];
16
+ return first ? { teamId: Number(first.team_id), role: first.role ?? null } : { teamId: null, role: null };
17
+ }
18
+ export function getActiveTeamPreference(request) {
19
+ const raw = request.cookies?.get(ACTIVE_TEAM_COOKIE);
20
+ if (!raw)
21
+ return null;
22
+ const n = Number(raw);
23
+ return Number.isFinite(n) && n > 0 ? n : null;
24
+ }
25
+ export function buildActiveTeamCookie(teamId, opts = {}) {
26
+ const maxAge = Math.max(0, Math.floor(opts.maxAgeSeconds ?? 31536000)), parts = [`${ACTIVE_TEAM_COOKIE}=${encodeURIComponent(String(teamId))}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
27
+ if (opts.secure)
28
+ parts.push("Secure");
29
+ return parts.join("; ");
30
+ }
31
+ export function clearActiveTeamCookie() {
32
+ return `${ACTIVE_TEAM_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
33
+ }
34
+ export async function resolveAuthenticatedMembership(request) {
35
+ const user = await resolveAuthenticatedUser(request);
36
+ if (!user?.id)
37
+ return null;
38
+ const memberships = await db.selectFrom("team_members").where("user_id", "=", user.id).where("status", "=", "active").select(["team_id", "role"]).execute();
39
+ if (memberships.length === 0)
40
+ return null;
41
+ const picked = selectActiveTeam({
42
+ memberships: memberships.map((m) => ({
43
+ team_id: Number(m.team_id),
44
+ role: m.role == null ? null : String(m.role)
45
+ })),
46
+ activeTeamId: getActiveTeamPreference(request)
47
+ });
48
+ return picked.teamId != null ? { teamId: picked.teamId, role: picked.role ?? "" } : null;
49
+ }
50
+ export async function resolveTeamContext(request, opts = {}) {
51
+ const empty = { user: null, teamId: null, role: null, teams: [], activeTeamId: null }, guardName = config.auth?.default || "api", user = (config.auth?.guards?.[guardName]?.driver || "token") === "session" ? await resolveSessionUser(request) : await resolveTokenUser(request);
52
+ if (!user?.id)
53
+ return empty;
54
+ const memberships = await db.selectFrom("team_members").where("user_id", "=", user.id).where("status", "=", "active").select(["team_id", "role"]).execute(), allowAny = opts.allowAnyTeam ? !!opts.allowAnyTeam(user) : !1, activeTeamId = getActiveTeamPreference(request), picked = selectActiveTeam({
55
+ memberships: memberships.map((m) => ({ team_id: Number(m.team_id), role: String(m.role) })),
56
+ activeTeamId,
57
+ allowAnyTeam: allowAny
58
+ }), roleByTeam = new Map(memberships.map((m) => [Number(m.team_id), String(m.role)]));
59
+ let teamRows = [];
60
+ if (allowAny)
61
+ teamRows = await db.selectFrom("teams").select(["id", "name"]).execute();
62
+ else {
63
+ const ids = [...roleByTeam.keys()];
64
+ teamRows = ids.length ? await db.selectFrom("teams").whereIn("id", ids).select(["id", "name"]).execute() : [];
65
+ }
66
+ const teams = teamRows.map((t) => ({ id: Number(t.id), name: String(t.name), role: roleByTeam.get(Number(t.id)) || "viewer" })).sort((a, b) => a.name.localeCompare(b.name));
67
+ return { user, teamId: picked.teamId, role: picked.role, teams, activeTeamId };
68
+ }
69
+ export async function resolveAuthenticatedTeamId(request) {
70
+ const membership = await resolveAuthenticatedMembership(request);
71
+ return membership ? membership.teamId : null;
72
+ }
73
+ export async function resolveAuthenticatedUser(request) {
74
+ const guardName = config.auth?.default || "api", user = ((config.auth?.guards?.[guardName] || { driver: "token" }).driver || "token") === "session" ? await resolveSessionUser(request) : await resolveTokenUser(request);
75
+ return user?.id ? user : void 0;
76
+ }
77
+ async function resolveSessionUser(request) {
78
+ const sessionId = request.cookies?.get("session_id");
79
+ if (!sessionId)
80
+ return;
81
+ return sessionUser(sessionId);
82
+ }
83
+ async function resolveTokenUser(request) {
84
+ const cookieName = config.auth?.defaultTokenName || "auth-token", bearer = (typeof request.bearerToken === "function" ? request.bearerToken() : void 0) ?? request.cookies?.get(cookieName);
85
+ if (!bearer)
86
+ return;
87
+ return Auth.getUserFromToken(bearer);
88
+ }
package/dist/token.js ADDED
@@ -0,0 +1,21 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+ import process from "node:process";
4
+
5
+ export class TokenManager {
6
+ static generateJWT(userId, expiresInSeconds = 3600) {
7
+ const appKey = process.env.APP_KEY;
8
+ if (!appKey)
9
+ throw Error("APP_KEY is not set. JWT tokens cannot be generated without a secure application key.");
10
+ const header = {
11
+ alg: "HS256",
12
+ typ: "JWT"
13
+ }, nowSeconds = Math.floor(Date.now() / 1000), payload = {
14
+ sub: userId,
15
+ iat: nowSeconds,
16
+ exp: nowSeconds + expiresInSeconds,
17
+ jti: randomBytes(16).toString("hex")
18
+ }, encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"), encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"), signature = createHmac("sha256", appKey).update(`${encodedHeader}.${encodedPayload}`).digest("base64url");
19
+ return `${encodedHeader}.${encodedPayload}.${signature}`;
20
+ }
21
+ }