@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/authentication.js +413 -0
- package/dist/authenticator.js +14 -0
- package/dist/authorizable.js +28 -0
- package/dist/client.js +26 -0
- package/dist/email-verification.js +111 -0
- package/dist/gate.js +203 -0
- package/dist/index.js +26 -165
- package/dist/internal-constants.js +1 -0
- package/dist/middleware.js +28 -0
- package/dist/passkey.js +76 -0
- package/dist/password/reset.js +156 -0
- package/dist/policy.js +91 -0
- package/dist/rate-limiter.js +91 -0
- package/dist/rbac-seed.js +30 -0
- package/dist/rbac-store-bqb.js +180 -0
- package/dist/rbac.js +324 -0
- package/dist/register.js +43 -0
- package/dist/session-auth.d.ts +18 -0
- package/dist/session-auth.js +151 -0
- package/dist/team.js +88 -0
- package/dist/token.js +21 -0
- package/dist/tokens.js +489 -0
- package/dist/two-factor.js +113 -0
- package/dist/user.js +41 -0
- package/package.json +3 -3
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { config } from "@stacksjs/config";
|
|
2
|
+
import { db } from "@stacksjs/database";
|
|
3
|
+
import { HttpError } from "@stacksjs/error-handling";
|
|
4
|
+
import { formatDate, User } from "@stacksjs/orm";
|
|
5
|
+
import { getCurrentRequest, request } from "@stacksjs/router";
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { decrypt, encrypt, verifyHash } from "@stacksjs/security";
|
|
9
|
+
import { log } from "@stacksjs/logging";
|
|
10
|
+
import { DUMMY_BCRYPT_HASH } from "./internal-constants";
|
|
11
|
+
import { RateLimiter } from "./rate-limiter";
|
|
12
|
+
const REQUEST_AUTH_STATE_KEY = Symbol.for("stacks.requestAuthState");
|
|
13
|
+
function authStateOrNull() {
|
|
14
|
+
const req = getCurrentRequest();
|
|
15
|
+
if (!req)
|
|
16
|
+
return null;
|
|
17
|
+
let state = req[REQUEST_AUTH_STATE_KEY];
|
|
18
|
+
if (!state) {
|
|
19
|
+
state = {};
|
|
20
|
+
req[REQUEST_AUTH_STATE_KEY] = state;
|
|
21
|
+
}
|
|
22
|
+
return state;
|
|
23
|
+
}
|
|
24
|
+
function hashToken(token) {
|
|
25
|
+
return createHash("sha256").update(token).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
import { createToken as createRawToken, getPasswordChangedAt, isIssuedBeforePasswordChange, parseScopes } from "./tokens";
|
|
28
|
+
|
|
29
|
+
export class Auth {
|
|
30
|
+
static getBearerToken() {
|
|
31
|
+
let bearerToken = request.bearerToken?.();
|
|
32
|
+
if (!bearerToken) {
|
|
33
|
+
const authHeader = request.headers?.get?.("authorization") || request.headers?.get?.("Authorization");
|
|
34
|
+
if (authHeader && authHeader.startsWith("Bearer "))
|
|
35
|
+
bearerToken = authHeader.substring(7);
|
|
36
|
+
}
|
|
37
|
+
return bearerToken || null;
|
|
38
|
+
}
|
|
39
|
+
static parseToken(token) {
|
|
40
|
+
const firstColonIndex = token.indexOf(":");
|
|
41
|
+
if (firstColonIndex === -1)
|
|
42
|
+
return null;
|
|
43
|
+
const plainToken = token.substring(0, firstColonIndex), encryptedId = token.substring(firstColonIndex + 1);
|
|
44
|
+
if (!plainToken || !encryptedId)
|
|
45
|
+
return null;
|
|
46
|
+
return { plainToken, encryptedId };
|
|
47
|
+
}
|
|
48
|
+
static async getClientSecret() {
|
|
49
|
+
const state = authStateOrNull();
|
|
50
|
+
if (state?.clientSecret)
|
|
51
|
+
return state.clientSecret;
|
|
52
|
+
const client = await this.getPersonalAccessClient();
|
|
53
|
+
if (state)
|
|
54
|
+
state.clientSecret = client.secret;
|
|
55
|
+
return client.secret;
|
|
56
|
+
}
|
|
57
|
+
static async encryptTokenId(id) {
|
|
58
|
+
return await encrypt(String(id));
|
|
59
|
+
}
|
|
60
|
+
static async decryptTokenId(encryptedId) {
|
|
61
|
+
try {
|
|
62
|
+
return await decrypt(encryptedId);
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
const clientSecret = await this.getClientSecret();
|
|
66
|
+
return await decrypt(encryptedId, clientSecret);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
static async getPersonalAccessClient() {
|
|
73
|
+
try {
|
|
74
|
+
const client = await db.selectFrom("oauth_clients").where("personal_access_client", "=", !0).where("revoked", "=", !1).selectAll().executeTakeFirst();
|
|
75
|
+
if (!client)
|
|
76
|
+
throw new HttpError(500, "No personal access client found. Please run `./buddy auth:setup` first.");
|
|
77
|
+
return client;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof Error && error.message.includes("does not exist"))
|
|
80
|
+
throw new HttpError(500, "OAuth tables not found. Please run `./buddy auth:setup` first.");
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
static async validateClient(clientId, clientSecret) {
|
|
85
|
+
const client = await db.selectFrom("oauth_clients").where("id", "=", clientId).where("revoked", "=", !1).selectAll().executeTakeFirst(), provided = Buffer.from(clientSecret);
|
|
86
|
+
if (!client?.secret) {
|
|
87
|
+
const dummy = Buffer.alloc(Math.max(provided.length, 1)), padded = provided.length > 0 ? provided : Buffer.alloc(1);
|
|
88
|
+
timingSafeEqual(dummy, padded);
|
|
89
|
+
return !1;
|
|
90
|
+
}
|
|
91
|
+
const stored = String(client.secret);
|
|
92
|
+
if (stored.startsWith("$2"))
|
|
93
|
+
return await verifyHash(clientSecret, stored);
|
|
94
|
+
const storedBuf = Buffer.from(stored);
|
|
95
|
+
if (storedBuf.length !== provided.length) {
|
|
96
|
+
timingSafeEqual(storedBuf, storedBuf);
|
|
97
|
+
return !1;
|
|
98
|
+
}
|
|
99
|
+
return timingSafeEqual(storedBuf, provided);
|
|
100
|
+
}
|
|
101
|
+
static async getTokenFromId(tokenId) {
|
|
102
|
+
const result = await db.selectFrom("oauth_access_tokens").where("id", "=", tokenId).selectAll().executeTakeFirst();
|
|
103
|
+
if (!result)
|
|
104
|
+
return null;
|
|
105
|
+
const token = result;
|
|
106
|
+
return {
|
|
107
|
+
id: token.id,
|
|
108
|
+
userId: token.user_id,
|
|
109
|
+
clientId: token.oauth_client_id,
|
|
110
|
+
name: token.name || "auth-token",
|
|
111
|
+
scopes: parseScopes(token.scopes),
|
|
112
|
+
abilities: parseScopes(token.scopes),
|
|
113
|
+
expiresAt: token.expires_at ? new Date(String(token.expires_at)) : null,
|
|
114
|
+
createdAt: token.created_at ? new Date(String(token.created_at)) : new Date,
|
|
115
|
+
updatedAt: token.updated_at ? new Date(String(token.updated_at)) : new Date,
|
|
116
|
+
revoked: !!token.revoked
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
static async attempt(credentials) {
|
|
120
|
+
const username = config.auth.username || "email", password = config.auth.password || "password", email = credentials[username];
|
|
121
|
+
if (!email)
|
|
122
|
+
return !1;
|
|
123
|
+
const isRateLimited = await RateLimiter.isRateLimited(email), user = await User.where("email", "=", email).first(), authPass = credentials[password] || "", hashToVerify = user?.password || DUMMY_BCRYPT_HASH, hashCheck = await verifyHash(authPass, hashToVerify);
|
|
124
|
+
if (isRateLimited)
|
|
125
|
+
return !1;
|
|
126
|
+
if (hashCheck && user) {
|
|
127
|
+
await RateLimiter.resetAttempts(email);
|
|
128
|
+
const state = authStateOrNull();
|
|
129
|
+
if (state)
|
|
130
|
+
state.authUser = user;
|
|
131
|
+
return !0;
|
|
132
|
+
}
|
|
133
|
+
await RateLimiter.recordFailedAttempt(email);
|
|
134
|
+
return !1;
|
|
135
|
+
}
|
|
136
|
+
static async validate(credentials) {
|
|
137
|
+
const username = config.auth.username || "email", password = config.auth.password || "password", email = credentials[username];
|
|
138
|
+
if (!email)
|
|
139
|
+
return !1;
|
|
140
|
+
const user = await User.where("email", "=", email).first(), authPass = credentials[password] || "", hashToVerify = user?.password || DUMMY_BCRYPT_HASH;
|
|
141
|
+
return await verifyHash(authPass, hashToVerify) && !!user;
|
|
142
|
+
}
|
|
143
|
+
static async login(credentials, options) {
|
|
144
|
+
const isValid = await this.attempt(credentials), authedUser = authStateOrNull()?.authUser;
|
|
145
|
+
if (!isValid || !authedUser)
|
|
146
|
+
return null;
|
|
147
|
+
const { plainTextToken, refreshToken, expiresIn } = await this.createTokenForUser(authedUser, options);
|
|
148
|
+
return { user: authedUser, token: plainTextToken, refreshToken, expiresIn };
|
|
149
|
+
}
|
|
150
|
+
static async loginUsingId(userId, options) {
|
|
151
|
+
const user = await User.find(userId);
|
|
152
|
+
if (!user)
|
|
153
|
+
return null;
|
|
154
|
+
const state = authStateOrNull();
|
|
155
|
+
if (state)
|
|
156
|
+
state.authUser = user;
|
|
157
|
+
const { plainTextToken, refreshToken, expiresIn } = await this.createTokenForUser(user, options);
|
|
158
|
+
return { user, token: plainTextToken, refreshToken, expiresIn };
|
|
159
|
+
}
|
|
160
|
+
static async logout() {
|
|
161
|
+
const bearerToken = this.getBearerToken();
|
|
162
|
+
if (bearerToken) {
|
|
163
|
+
const accessToken = await db.selectFrom("oauth_access_tokens").where("token", "=", hashToken(bearerToken)).select(["id"]).executeTakeFirst();
|
|
164
|
+
if (accessToken)
|
|
165
|
+
await db.updateTable("oauth_refresh_tokens").set({ revoked: !0 }).where("access_token_id", "=", Number(accessToken.id)).execute();
|
|
166
|
+
await this.revokeToken(bearerToken);
|
|
167
|
+
}
|
|
168
|
+
const state = authStateOrNull();
|
|
169
|
+
if (state) {
|
|
170
|
+
state.authUser = void 0;
|
|
171
|
+
state.currentToken = void 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
static async user() {
|
|
175
|
+
const state = authStateOrNull();
|
|
176
|
+
if (state?.authUser)
|
|
177
|
+
return state.authUser;
|
|
178
|
+
const bearerToken = this.getBearerToken();
|
|
179
|
+
if (!bearerToken)
|
|
180
|
+
return;
|
|
181
|
+
const user = await this.getUserFromToken(bearerToken);
|
|
182
|
+
if (user && state)
|
|
183
|
+
state.authUser = user;
|
|
184
|
+
return user;
|
|
185
|
+
}
|
|
186
|
+
static async check() {
|
|
187
|
+
return await this.user() !== void 0;
|
|
188
|
+
}
|
|
189
|
+
static async guest() {
|
|
190
|
+
return !await this.check();
|
|
191
|
+
}
|
|
192
|
+
static async id() {
|
|
193
|
+
return (await this.user())?.id;
|
|
194
|
+
}
|
|
195
|
+
static setUser(user) {
|
|
196
|
+
const state = authStateOrNull();
|
|
197
|
+
if (state)
|
|
198
|
+
state.authUser = user;
|
|
199
|
+
}
|
|
200
|
+
static async createTokenForUser(user, options) {
|
|
201
|
+
const name = options?.name ?? config.auth.defaultTokenName ?? "auth-token", abilities = options?.abilities ?? options?.scopes ?? config.auth.defaultAbilities ?? ["*"], accessTtlMs = options?.expiresInMinutes !== void 0 ? options.expiresInMinutes * 60 * 1000 : config.auth.tokenExpiry ?? 3600000, expiresAt = options?.expiresAt ?? new Date(Date.now() + accessTtlMs), expiresInMinutes = Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 60000)), refreshExpiresInDays = options?.refreshExpiresInDays ?? Math.max(1, Math.round((config.auth.refreshTokenExpiry ?? 2592000000) / 86400000));
|
|
202
|
+
log.debug(`[auth] Creating token for user#${user.id}: ${name}`);
|
|
203
|
+
const result = await createRawToken(user.id, name, abilities, {
|
|
204
|
+
expiresInMinutes,
|
|
205
|
+
withRefreshToken: options?.withRefreshToken !== !1,
|
|
206
|
+
refreshExpiresInDays
|
|
207
|
+
}), plainTextToken = result.plainTextToken;
|
|
208
|
+
return {
|
|
209
|
+
accessToken: {
|
|
210
|
+
id: result.accessToken.id,
|
|
211
|
+
userId: result.accessToken.userId,
|
|
212
|
+
clientId: result.accessToken.clientId,
|
|
213
|
+
name: result.accessToken.name,
|
|
214
|
+
scopes: result.accessToken.scopes,
|
|
215
|
+
abilities,
|
|
216
|
+
expiresAt: result.accessToken.expiresAt ?? expiresAt,
|
|
217
|
+
createdAt: result.accessToken.createdAt,
|
|
218
|
+
updatedAt: result.accessToken.updatedAt,
|
|
219
|
+
revoked: result.accessToken.revoked,
|
|
220
|
+
plainTextToken
|
|
221
|
+
},
|
|
222
|
+
plainTextToken,
|
|
223
|
+
refreshToken: result.refreshToken,
|
|
224
|
+
expiresIn: result.expiresIn
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
static async createToken(user, name = config.auth.defaultTokenName || "auth-token", abilities = config.auth.defaultAbilities || ["*"]) {
|
|
228
|
+
const { plainTextToken } = await this.createTokenForUser(user, { name, abilities });
|
|
229
|
+
return plainTextToken;
|
|
230
|
+
}
|
|
231
|
+
static async requestToken(credentials, clientId, clientSecret) {
|
|
232
|
+
if (!await this.validateClient(clientId, clientSecret))
|
|
233
|
+
throw new HttpError(401, "Invalid client credentials");
|
|
234
|
+
const isValid = await this.attempt(credentials), authedUser = authStateOrNull()?.authUser;
|
|
235
|
+
if (!isValid || !authedUser)
|
|
236
|
+
return null;
|
|
237
|
+
return { token: await this.createToken(authedUser, "user-auth-token") };
|
|
238
|
+
}
|
|
239
|
+
static async validateToken(token) {
|
|
240
|
+
const hashedPlainToken = hashToken(token), accessToken = await db.selectFrom("oauth_access_tokens").where("token", "=", hashedPlainToken).selectAll().executeTakeFirst();
|
|
241
|
+
if (!accessToken)
|
|
242
|
+
return !1;
|
|
243
|
+
log.debug(`[auth] Token validated for token#${accessToken.id}`);
|
|
244
|
+
if (accessToken.expires_at && new Date(String(accessToken.expires_at)) < new Date) {
|
|
245
|
+
await db.deleteFrom("oauth_access_tokens").where("id", "=", accessToken.id).execute();
|
|
246
|
+
return !1;
|
|
247
|
+
}
|
|
248
|
+
if (accessToken.revoked)
|
|
249
|
+
return !1;
|
|
250
|
+
if (isIssuedBeforePasswordChange(accessToken.created_at, await getPasswordChangedAt(accessToken.user_id)))
|
|
251
|
+
return !1;
|
|
252
|
+
await db.updateTable("oauth_access_tokens").set({ updated_at: formatDate(new Date) }).where("id", "=", accessToken.id).execute();
|
|
253
|
+
return !0;
|
|
254
|
+
}
|
|
255
|
+
static async getUserFromToken(token) {
|
|
256
|
+
const hashedPlainToken = hashToken(token), accessToken = await db.selectFrom("oauth_access_tokens").where("token", "=", hashedPlainToken).selectAll().executeTakeFirst();
|
|
257
|
+
if (!accessToken)
|
|
258
|
+
return;
|
|
259
|
+
if (accessToken.expires_at && new Date(String(accessToken.expires_at)) < new Date) {
|
|
260
|
+
await db.deleteFrom("oauth_access_tokens").where("id", "=", accessToken.id).execute();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (accessToken.revoked)
|
|
264
|
+
return;
|
|
265
|
+
const stateForToken = authStateOrNull();
|
|
266
|
+
if (stateForToken)
|
|
267
|
+
stateForToken.currentToken = await this.getTokenFromId(accessToken.id) ?? void 0;
|
|
268
|
+
await db.updateTable("oauth_access_tokens").set({ updated_at: formatDate(new Date) }).where("id", "=", accessToken.id).execute();
|
|
269
|
+
if (!accessToken?.user_id)
|
|
270
|
+
return;
|
|
271
|
+
const user = await User.find(accessToken.user_id);
|
|
272
|
+
if (isIssuedBeforePasswordChange(accessToken.created_at, await getPasswordChangedAt(accessToken.user_id)))
|
|
273
|
+
return;
|
|
274
|
+
return user;
|
|
275
|
+
}
|
|
276
|
+
static async currentAccessToken() {
|
|
277
|
+
const state = authStateOrNull();
|
|
278
|
+
if (state?.currentToken)
|
|
279
|
+
return state.currentToken;
|
|
280
|
+
const bearerToken = this.getBearerToken();
|
|
281
|
+
if (!bearerToken)
|
|
282
|
+
return;
|
|
283
|
+
const accessToken = await db.selectFrom("oauth_access_tokens").where("token", "=", hashToken(bearerToken)).select(["id"]).executeTakeFirst();
|
|
284
|
+
if (!accessToken)
|
|
285
|
+
return;
|
|
286
|
+
const token = await this.getTokenFromId(Number(accessToken.id));
|
|
287
|
+
if (token && state)
|
|
288
|
+
state.currentToken = token;
|
|
289
|
+
return token ?? void 0;
|
|
290
|
+
}
|
|
291
|
+
static async tokenCan(ability) {
|
|
292
|
+
const token = await this.currentAccessToken();
|
|
293
|
+
if (!token)
|
|
294
|
+
return !1;
|
|
295
|
+
if (token.abilities.includes("*"))
|
|
296
|
+
return !0;
|
|
297
|
+
return token.abilities.includes(ability);
|
|
298
|
+
}
|
|
299
|
+
static async tokenCant(ability) {
|
|
300
|
+
return !await this.tokenCan(ability);
|
|
301
|
+
}
|
|
302
|
+
static async tokenAbilities() {
|
|
303
|
+
return (await this.currentAccessToken())?.abilities ?? [];
|
|
304
|
+
}
|
|
305
|
+
static async tokenCanAll(abilities) {
|
|
306
|
+
const token = await this.currentAccessToken();
|
|
307
|
+
if (!token)
|
|
308
|
+
return !1;
|
|
309
|
+
if (token.abilities.includes("*"))
|
|
310
|
+
return !0;
|
|
311
|
+
return abilities.every((a) => token.abilities.includes(a));
|
|
312
|
+
}
|
|
313
|
+
static async tokenCanAny(abilities) {
|
|
314
|
+
const token = await this.currentAccessToken();
|
|
315
|
+
if (!token)
|
|
316
|
+
return !1;
|
|
317
|
+
if (token.abilities.includes("*"))
|
|
318
|
+
return !0;
|
|
319
|
+
return abilities.some((a) => token.abilities.includes(a));
|
|
320
|
+
}
|
|
321
|
+
static async tokens(userId) {
|
|
322
|
+
const uid = userId ?? await this.id();
|
|
323
|
+
if (!uid)
|
|
324
|
+
return [];
|
|
325
|
+
return (await db.selectFrom("oauth_access_tokens").where("user_id", "=", uid).where("revoked", "=", !1).selectAll().execute()).map((token) => ({
|
|
326
|
+
id: Number(token.id),
|
|
327
|
+
userId: Number(token.user_id),
|
|
328
|
+
clientId: Number(token.oauth_client_id),
|
|
329
|
+
name: String(token.name || "auth-token"),
|
|
330
|
+
scopes: parseScopes(String(token.scopes ?? "")),
|
|
331
|
+
abilities: parseScopes(String(token.scopes ?? "")),
|
|
332
|
+
expiresAt: token.expires_at ? new Date(String(token.expires_at)) : null,
|
|
333
|
+
createdAt: token.created_at ? new Date(String(token.created_at)) : new Date,
|
|
334
|
+
updatedAt: token.updated_at ? new Date(String(token.updated_at)) : new Date,
|
|
335
|
+
revoked: !!token.revoked
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
static async revokeToken(token) {
|
|
339
|
+
await db.updateTable("oauth_access_tokens").set({ revoked: !0, updated_at: formatDate(new Date) }).where("token", "=", hashToken(token)).execute();
|
|
340
|
+
}
|
|
341
|
+
static async revokeTokenById(tokenId) {
|
|
342
|
+
await db.updateTable("oauth_access_tokens").set({ revoked: !0, updated_at: formatDate(new Date) }).where("id", "=", tokenId).execute();
|
|
343
|
+
}
|
|
344
|
+
static async revokeAllTokens(userId) {
|
|
345
|
+
const uid = userId ?? await this.id();
|
|
346
|
+
if (!uid)
|
|
347
|
+
return;
|
|
348
|
+
await db.updateTable("oauth_access_tokens").set({ revoked: !0, updated_at: formatDate(new Date) }).where("user_id", "=", uid).execute();
|
|
349
|
+
}
|
|
350
|
+
static async revokeOtherTokens(userId) {
|
|
351
|
+
const uid = userId ?? await this.id();
|
|
352
|
+
if (!uid)
|
|
353
|
+
return;
|
|
354
|
+
const currentToken = await this.currentAccessToken();
|
|
355
|
+
if (!currentToken)
|
|
356
|
+
return;
|
|
357
|
+
await db.updateTable("oauth_access_tokens").set({ revoked: !0, updated_at: formatDate(new Date) }).where("user_id", "=", uid).where("id", "!=", currentToken.id).execute();
|
|
358
|
+
}
|
|
359
|
+
static async pruneExpiredTokens() {
|
|
360
|
+
const result = await db.deleteFrom("oauth_access_tokens").where("expires_at", "<", formatDate(new Date)).executeTakeFirst();
|
|
361
|
+
return Number(result?.numDeletedRows) || 0;
|
|
362
|
+
}
|
|
363
|
+
static async pruneRevokedTokens() {
|
|
364
|
+
const result = await db.deleteFrom("oauth_access_tokens").where("revoked", "=", !0).executeTakeFirst();
|
|
365
|
+
return Number(result?.numDeletedRows) || 0;
|
|
366
|
+
}
|
|
367
|
+
static async rotateToken(oldToken) {
|
|
368
|
+
const { findToken: findRawToken, revokeToken: revokeRawToken } = await import("./tokens"), existing = await findRawToken(oldToken);
|
|
369
|
+
if (!existing)
|
|
370
|
+
return null;
|
|
371
|
+
const remainingMs = existing.expiresAt ? existing.expiresAt.getTime() - Date.now() : config.auth.tokenExpiry ?? 3600000, expiresInMinutes = Math.max(1, Math.floor(remainingMs / 60000));
|
|
372
|
+
await revokeRawToken(oldToken);
|
|
373
|
+
const user = await User.find(existing.userId);
|
|
374
|
+
if (!user)
|
|
375
|
+
return null;
|
|
376
|
+
return (await this.createTokenForUser(user, {
|
|
377
|
+
name: existing.name,
|
|
378
|
+
abilities: existing.scopes ?? ["*"],
|
|
379
|
+
expiresInMinutes,
|
|
380
|
+
withRefreshToken: !1
|
|
381
|
+
})).plainTextToken;
|
|
382
|
+
}
|
|
383
|
+
static async findToken(tokenId) {
|
|
384
|
+
return this.getTokenFromId(tokenId);
|
|
385
|
+
}
|
|
386
|
+
static async once(credentials) {
|
|
387
|
+
const username = config.auth.username || "email", password = config.auth.password || "password", email = credentials[username];
|
|
388
|
+
if (!email)
|
|
389
|
+
return !1;
|
|
390
|
+
const user = await User.where("email", "=", email).first(), authPass = credentials[password] || "", hashToVerify = user?.password || DUMMY_BCRYPT_HASH;
|
|
391
|
+
if (await verifyHash(authPass, hashToVerify) && user) {
|
|
392
|
+
const state = authStateOrNull();
|
|
393
|
+
if (state)
|
|
394
|
+
state.authUser = user;
|
|
395
|
+
return !0;
|
|
396
|
+
}
|
|
397
|
+
return !1;
|
|
398
|
+
}
|
|
399
|
+
static guard(_name) {
|
|
400
|
+
return this;
|
|
401
|
+
}
|
|
402
|
+
static viaRemember() {
|
|
403
|
+
return !1;
|
|
404
|
+
}
|
|
405
|
+
static clearState() {
|
|
406
|
+
const state = authStateOrNull();
|
|
407
|
+
if (state) {
|
|
408
|
+
state.authUser = void 0;
|
|
409
|
+
state.currentToken = void 0;
|
|
410
|
+
state.clientSecret = void 0;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { generateTOTP, generateTOTPSecret, totpKeyUri, verifyTOTP } from "@stacksjs/ts-auth";
|
|
2
|
+
export function generateTwoFactorSecret() {
|
|
3
|
+
return generateTOTPSecret();
|
|
4
|
+
}
|
|
5
|
+
export async function generateTwoFactorToken(secret) {
|
|
6
|
+
return generateTOTP({ secret });
|
|
7
|
+
}
|
|
8
|
+
export async function verifyTwoFactorCode(token, secret) {
|
|
9
|
+
return verifyTOTP(token, { secret });
|
|
10
|
+
}
|
|
11
|
+
export function generateTwoFactorUri(user, service, secret) {
|
|
12
|
+
const userIdentifier = user || "johndoe@example.com", serviceName = service || "StacksJS 2fa", otpSecret = secret || generateTwoFactorSecret();
|
|
13
|
+
return totpKeyUri(userIdentifier, serviceName, otpSecret);
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { all, any, authorize as gateAuthorize, can, cannot, inspect } from "./gate";
|
|
2
|
+
export async function userCan(user, ability, ...args) {
|
|
3
|
+
return can(ability, user, ...args);
|
|
4
|
+
}
|
|
5
|
+
export async function userCannot(user, ability, ...args) {
|
|
6
|
+
return cannot(ability, user, ...args);
|
|
7
|
+
}
|
|
8
|
+
export async function userCanAny(user, abilities, ...args) {
|
|
9
|
+
return any(abilities, user, ...args);
|
|
10
|
+
}
|
|
11
|
+
export async function userCanAll(user, abilities, ...args) {
|
|
12
|
+
return all(abilities, user, ...args);
|
|
13
|
+
}
|
|
14
|
+
export async function authorizeUser(user, ability, ...args) {
|
|
15
|
+
return gateAuthorize(ability, user, ...args);
|
|
16
|
+
}
|
|
17
|
+
export async function inspectUser(user, ability, ...args) {
|
|
18
|
+
return inspect(ability, user, ...args);
|
|
19
|
+
}
|
|
20
|
+
export function withAuthorization(user) {
|
|
21
|
+
return Object.assign(user, {
|
|
22
|
+
can: (ability, ...args) => userCan(user, ability, ...args),
|
|
23
|
+
cannot: (ability, ...args) => userCannot(user, ability, ...args),
|
|
24
|
+
canAny: (abilities, ...args) => userCanAny(user, abilities, ...args),
|
|
25
|
+
canAll: (abilities, ...args) => userCanAll(user, abilities, ...args),
|
|
26
|
+
authorize: (ability, ...args) => authorizeUser(user, ability, ...args)
|
|
27
|
+
});
|
|
28
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "@stacksjs/database";
|
|
3
|
+
import { formatDate } from "@stacksjs/orm";
|
|
4
|
+
import { err, HttpError, ok } from "@stacksjs/error-handling";
|
|
5
|
+
import { makeHash } from "@stacksjs/security";
|
|
6
|
+
export async function createPersonalAccessClient() {
|
|
7
|
+
if ((await db.selectFrom("oauth_clients").where("personal_access_client", "=", !0).where("revoked", "=", !1).select(["id"]).executeTakeFirst())?.id)
|
|
8
|
+
return err({
|
|
9
|
+
code: "already-exists",
|
|
10
|
+
message: "A personal access client already exists. Revoke the existing client (`./buddy auth:revoke-client`) before creating a new one."
|
|
11
|
+
});
|
|
12
|
+
const secret = randomBytes(40).toString("hex"), hashedSecret = await makeHash(secret, { algorithm: "bcrypt" });
|
|
13
|
+
await db.insertInto("oauth_clients").values({
|
|
14
|
+
name: "Personal Access Client",
|
|
15
|
+
secret: hashedSecret,
|
|
16
|
+
provider: "local",
|
|
17
|
+
redirect: "http://localhost",
|
|
18
|
+
personal_access_client: !0,
|
|
19
|
+
password_client: !1,
|
|
20
|
+
revoked: !1,
|
|
21
|
+
created_at: formatDate(new Date)
|
|
22
|
+
}).execute();
|
|
23
|
+
if (!(await db.selectFrom("oauth_clients").where("secret", "=", hashedSecret).select(["id"]).executeTakeFirst())?.id)
|
|
24
|
+
throw new HttpError(500, "Failed to create personal access client");
|
|
25
|
+
return ok(secret);
|
|
26
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { config } from "@stacksjs/config";
|
|
4
|
+
import { db } from "@stacksjs/database";
|
|
5
|
+
import { mail, template } from "@stacksjs/email";
|
|
6
|
+
import { log } from "@stacksjs/logging";
|
|
7
|
+
function getVerificationKey() {
|
|
8
|
+
const appKey = config.app.key;
|
|
9
|
+
if (typeof appKey !== "string" || appKey.length === 0)
|
|
10
|
+
throw Error("[auth] config.app.key is not set \u2014 email-verification HMAC requires a real APP_KEY. " + "Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");
|
|
11
|
+
return appKey;
|
|
12
|
+
}
|
|
13
|
+
function generateVerificationToken(userId) {
|
|
14
|
+
const nonce = randomBytes(32).toString("hex"), payload = `${userId}:${nonce}`, hash = createHmac("sha256", getVerificationKey()).update(payload).digest("hex");
|
|
15
|
+
return { token: nonce, hash };
|
|
16
|
+
}
|
|
17
|
+
function verifyToken(userId, token, storedHash) {
|
|
18
|
+
const payload = `${userId}:${token}`, hash = createHmac("sha256", getVerificationKey()).update(payload).digest("hex"), a = Buffer.from(hash), b = Buffer.from(storedHash);
|
|
19
|
+
if (a.length !== b.length)
|
|
20
|
+
return !1;
|
|
21
|
+
return timingSafeEqual(a, b);
|
|
22
|
+
}
|
|
23
|
+
function getExpiryMinutes() {
|
|
24
|
+
const emailVerification = (config.auth ?? {}).emailVerification;
|
|
25
|
+
if (emailVerification != null && typeof emailVerification === "object") {
|
|
26
|
+
const ev = emailVerification;
|
|
27
|
+
if (typeof ev.expire === "number")
|
|
28
|
+
return ev.expire;
|
|
29
|
+
}
|
|
30
|
+
return 60;
|
|
31
|
+
}
|
|
32
|
+
function getVerificationUrl(userId, token) {
|
|
33
|
+
const base = config.app.url ? `https://${config.app.url}` : `http://localhost:${process.env.PORT || "3000"}`, filled = (config.auth.emailVerification?.url ?? "/verify-email/{id}/{token}").replace("{id}", String(userId)).replace("{token}", token);
|
|
34
|
+
return /^https?:\/\//.test(filled) ? filled : `${base}${filled.startsWith("/") ? "" : "/"}${filled}`;
|
|
35
|
+
}
|
|
36
|
+
export function isEmailVerified(user) {
|
|
37
|
+
return user.email_verified_at != null;
|
|
38
|
+
}
|
|
39
|
+
export async function sendVerificationEmail(user) {
|
|
40
|
+
const { token, hash } = generateVerificationToken(user.id), expiryMinutes = getExpiryMinutes(), expiresAt = new Date(Date.now() + expiryMinutes * 60 * 1000);
|
|
41
|
+
await db.deleteFrom("email_verifications").where("user_id", "=", user.id).execute();
|
|
42
|
+
await db.insertInto("email_verifications").values({
|
|
43
|
+
user_id: user.id,
|
|
44
|
+
token: hash,
|
|
45
|
+
expires_at: expiresAt.toISOString()
|
|
46
|
+
}).executeTakeFirst();
|
|
47
|
+
const verificationUrl = getVerificationUrl(user.id, token), appName = config.app.name || "Stacks";
|
|
48
|
+
try {
|
|
49
|
+
const { html, text } = await template("email-verification", {
|
|
50
|
+
subject: `Verify Your ${appName} Email Address`,
|
|
51
|
+
variables: {
|
|
52
|
+
verificationUrl,
|
|
53
|
+
expiryMinutes,
|
|
54
|
+
userName: user.name || user.email
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (!html && !text)
|
|
58
|
+
throw Error("email-verification template missing or rendered empty");
|
|
59
|
+
await mail.send({
|
|
60
|
+
to: user.email,
|
|
61
|
+
subject: `Verify Your ${appName} Email Address`,
|
|
62
|
+
text,
|
|
63
|
+
html
|
|
64
|
+
});
|
|
65
|
+
} catch (templateError) {
|
|
66
|
+
const errorMessage = templateError instanceof Error ? templateError.message : String(templateError);
|
|
67
|
+
log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);
|
|
68
|
+
await mail.send({
|
|
69
|
+
to: user.email,
|
|
70
|
+
subject: `Verify Your ${appName} Email Address`,
|
|
71
|
+
text: `Please verify your email address by visiting: ${verificationUrl}
|
|
72
|
+
|
|
73
|
+
This link expires in ${expiryMinutes} minutes.`
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function verifyEmail(userId, token) {
|
|
78
|
+
const record = await db.selectFrom("email_verifications").where("user_id", "=", userId).selectAll().executeTakeFirst();
|
|
79
|
+
if (!record)
|
|
80
|
+
return { success: !1, message: "No verification request found. Please request a new verification email." };
|
|
81
|
+
const expiresAt = new Date(record.expires_at);
|
|
82
|
+
if (Number.isNaN(expiresAt.getTime()) || new Date > expiresAt) {
|
|
83
|
+
await db.deleteFrom("email_verifications").where("user_id", "=", userId).execute();
|
|
84
|
+
return { success: !1, message: "Verification link has expired. Please request a new one." };
|
|
85
|
+
}
|
|
86
|
+
if (!verifyToken(userId, token, record.token))
|
|
87
|
+
return { success: !1, message: "Invalid verification link." };
|
|
88
|
+
await db.updateTable("users").set({ email_verified_at: new Date().toISOString() }).where("id", "=", userId).executeTakeFirst();
|
|
89
|
+
await db.deleteFrom("email_verifications").where("user_id", "=", userId).execute();
|
|
90
|
+
return { success: !0, message: "Email verified successfully." };
|
|
91
|
+
}
|
|
92
|
+
export async function resendVerificationEmail(user) {
|
|
93
|
+
if (isEmailVerified(user))
|
|
94
|
+
return { success: !1, message: "Email is already verified." };
|
|
95
|
+
const existing = await db.selectFrom("email_verifications").where("user_id", "=", user.id).selectAll().executeTakeFirst();
|
|
96
|
+
if (existing) {
|
|
97
|
+
const createdAt = new Date(existing.created_at), secondsSince = (Date.now() - createdAt.getTime()) / 1000;
|
|
98
|
+
if (Number.isNaN(secondsSince))
|
|
99
|
+
return { success: !1, message: "Please wait a moment before requesting another verification email." };
|
|
100
|
+
if (secondsSince < 60)
|
|
101
|
+
return { success: !1, message: `Please wait ${Math.ceil(60 - secondsSince)} seconds before requesting another verification email.` };
|
|
102
|
+
}
|
|
103
|
+
await sendVerificationEmail(user);
|
|
104
|
+
return { success: !0, message: "Verification email sent." };
|
|
105
|
+
}
|
|
106
|
+
export const EmailVerification = {
|
|
107
|
+
isVerified: isEmailVerified,
|
|
108
|
+
send: sendVerificationEmail,
|
|
109
|
+
verify: verifyEmail,
|
|
110
|
+
resend: resendVerificationEmail
|
|
111
|
+
};
|