@stacksjs/auth 0.70.88 → 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.d.ts +43 -0
- package/dist/authentication.js +413 -0
- package/dist/authenticator.d.ts +17 -0
- package/dist/authenticator.js +14 -0
- package/dist/authorizable.d.ts +77 -0
- package/dist/authorizable.js +28 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +26 -0
- package/dist/email-verification.d.ts +29 -0
- package/dist/email-verification.js +111 -0
- package/dist/gate.d.ts +180 -0
- package/dist/gate.js +203 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +26 -0
- package/dist/internal-constants.d.ts +19 -0
- package/dist/internal-constants.js +1 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +28 -0
- package/dist/passkey.d.ts +97 -0
- package/dist/passkey.js +76 -0
- package/dist/password/reset.d.ts +10 -0
- package/dist/password/reset.js +156 -0
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +91 -0
- package/dist/rate-limiter.d.ts +44 -0
- package/dist/rate-limiter.js +91 -0
- package/dist/rbac-seed.d.ts +24 -0
- package/dist/rbac-seed.js +30 -0
- package/dist/rbac-store-bqb.d.ts +18 -0
- package/dist/rbac-store-bqb.js +180 -0
- package/dist/rbac.d.ts +265 -0
- package/dist/rbac.js +324 -0
- package/dist/register.d.ts +3 -0
- package/dist/register.js +43 -0
- package/dist/session-auth.d.ts +67 -0
- package/dist/session-auth.js +151 -0
- package/dist/team.d.ts +121 -0
- package/dist/team.js +88 -0
- package/dist/token.d.ts +16 -0
- package/dist/token.js +21 -0
- package/dist/tokens.d.ts +284 -0
- package/dist/tokens.js +489 -0
- package/dist/two-factor.d.ts +67 -0
- package/dist/two-factor.js +113 -0
- package/dist/user.d.ts +34 -0
- package/dist/user.js +41 -0
- package/package.json +3 -3
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { db } from "@stacksjs/database";
|
|
4
|
+
import { HttpError } from "@stacksjs/error-handling";
|
|
5
|
+
import { getCurrentRequest } from "@stacksjs/router";
|
|
6
|
+
import { env } from "@stacksjs/env";
|
|
7
|
+
import { sqlHelpers } from "@stacksjs/database";
|
|
8
|
+
const dbDriver = env.DB_CONNECTION || "sqlite", sql = sqlHelpers(dbDriver), { isPostgres, isMysql, now, boolTrue, boolFalse } = sql;
|
|
9
|
+
function param(index) {
|
|
10
|
+
return sql.param(index);
|
|
11
|
+
}
|
|
12
|
+
function hashToken(token) {
|
|
13
|
+
return createHash("sha256").update(token).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
function bearerLookupHash(bearer) {
|
|
16
|
+
const colonIdx = bearer.indexOf(":"), lookup = colonIdx === -1 ? bearer : bearer.substring(0, colonIdx);
|
|
17
|
+
return hashToken(lookup);
|
|
18
|
+
}
|
|
19
|
+
function generateSecureToken(bytes = 40) {
|
|
20
|
+
return randomBytes(bytes).toString("hex");
|
|
21
|
+
}
|
|
22
|
+
export async function getPasswordChangedAt(userId, q = db) {
|
|
23
|
+
if (userId === null || userId === void 0)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const value = (await q.unsafe(`
|
|
27
|
+
SELECT password_changed_at FROM users WHERE id = ${param(1)} LIMIT 1
|
|
28
|
+
`, [userId]))[0]?.password_changed_at;
|
|
29
|
+
if (value === null || value === void 0)
|
|
30
|
+
return null;
|
|
31
|
+
const parsed = new Date(String(value));
|
|
32
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function isIssuedBeforePasswordChange(createdAt, changedAt) {
|
|
38
|
+
if (!changedAt)
|
|
39
|
+
return !1;
|
|
40
|
+
if (createdAt === null || createdAt === void 0)
|
|
41
|
+
return !1;
|
|
42
|
+
const created = new Date(String(createdAt));
|
|
43
|
+
if (Number.isNaN(created.getTime()))
|
|
44
|
+
return !1;
|
|
45
|
+
return created.getTime() < changedAt.getTime();
|
|
46
|
+
}
|
|
47
|
+
export async function tokens(userId) {
|
|
48
|
+
return (await db.unsafe(`
|
|
49
|
+
SELECT t.*, c.provider as client_provider
|
|
50
|
+
FROM oauth_access_tokens t
|
|
51
|
+
LEFT JOIN oauth_clients c ON t.oauth_client_id = c.id
|
|
52
|
+
WHERE t.user_id = ${param(1)}
|
|
53
|
+
AND t.revoked = ${boolFalse}
|
|
54
|
+
ORDER BY t.created_at DESC
|
|
55
|
+
`, [userId])).map((row) => ({
|
|
56
|
+
id: row.id,
|
|
57
|
+
userId: row.user_id,
|
|
58
|
+
clientId: row.oauth_client_id,
|
|
59
|
+
name: row.name || "access-token",
|
|
60
|
+
scopes: parseScopes(row.scopes),
|
|
61
|
+
revoked: !!row.revoked,
|
|
62
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
|
63
|
+
createdAt: new Date(row.created_at),
|
|
64
|
+
updatedAt: row.updated_at ? new Date(row.updated_at) : new Date
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
export async function findToken(plainTextToken) {
|
|
68
|
+
const hashedToken = bearerLookupHash(plainTextToken), row = (await db.unsafe(`
|
|
69
|
+
SELECT * FROM oauth_access_tokens
|
|
70
|
+
WHERE token = ${param(1)}
|
|
71
|
+
AND revoked = ${boolFalse}
|
|
72
|
+
AND (expires_at IS NULL OR expires_at > ${now})
|
|
73
|
+
LIMIT 1
|
|
74
|
+
`, [hashedToken]))[0];
|
|
75
|
+
if (!row)
|
|
76
|
+
return null;
|
|
77
|
+
if (isIssuedBeforePasswordChange(row.created_at, await getPasswordChangedAt(row.user_id)))
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
id: row.id,
|
|
81
|
+
userId: row.user_id,
|
|
82
|
+
clientId: row.oauth_client_id,
|
|
83
|
+
name: row.name || "access-token",
|
|
84
|
+
scopes: parseScopes(row.scopes),
|
|
85
|
+
revoked: !!row.revoked,
|
|
86
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
|
87
|
+
createdAt: new Date(row.created_at),
|
|
88
|
+
updatedAt: row.updated_at ? new Date(row.updated_at) : new Date
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export async function currentAccessToken() {
|
|
92
|
+
const request = getCurrentRequest();
|
|
93
|
+
if (!request)
|
|
94
|
+
return null;
|
|
95
|
+
const attached = request._currentAccessToken;
|
|
96
|
+
if (attached)
|
|
97
|
+
return attached;
|
|
98
|
+
const bearerToken = request.bearerToken?.();
|
|
99
|
+
if (!bearerToken)
|
|
100
|
+
return null;
|
|
101
|
+
const token = await findToken(bearerToken);
|
|
102
|
+
if (token)
|
|
103
|
+
request._currentAccessToken = token;
|
|
104
|
+
return token;
|
|
105
|
+
}
|
|
106
|
+
export const token = currentAccessToken;
|
|
107
|
+
export async function tokenCan(scope) {
|
|
108
|
+
const accessToken = await currentAccessToken();
|
|
109
|
+
if (!accessToken)
|
|
110
|
+
return !1;
|
|
111
|
+
if (accessToken.scopes.includes("*"))
|
|
112
|
+
return !0;
|
|
113
|
+
return accessToken.scopes.includes(scope);
|
|
114
|
+
}
|
|
115
|
+
export async function tokenCant(scope) {
|
|
116
|
+
return !await tokenCan(scope);
|
|
117
|
+
}
|
|
118
|
+
export async function tokenCanAll(scopes) {
|
|
119
|
+
const accessToken = await currentAccessToken();
|
|
120
|
+
if (!accessToken)
|
|
121
|
+
return !1;
|
|
122
|
+
if (accessToken.scopes.includes("*"))
|
|
123
|
+
return !0;
|
|
124
|
+
return scopes.every((scope) => accessToken.scopes.includes(scope));
|
|
125
|
+
}
|
|
126
|
+
export async function tokenCanAny(scopes) {
|
|
127
|
+
const accessToken = await currentAccessToken();
|
|
128
|
+
if (!accessToken)
|
|
129
|
+
return !1;
|
|
130
|
+
if (accessToken.scopes.includes("*"))
|
|
131
|
+
return !0;
|
|
132
|
+
return scopes.some((scope) => accessToken.scopes.includes(scope));
|
|
133
|
+
}
|
|
134
|
+
export async function tokenAbilities() {
|
|
135
|
+
return (await currentAccessToken())?.scopes || [];
|
|
136
|
+
}
|
|
137
|
+
export async function createToken(userId, name = "access-token", scopes = ["*"], options = {}) {
|
|
138
|
+
const {
|
|
139
|
+
expiresInMinutes = 60,
|
|
140
|
+
withRefreshToken = !0,
|
|
141
|
+
refreshExpiresInDays = 30
|
|
142
|
+
} = options, client = (await db.unsafe(`
|
|
143
|
+
SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} LIMIT 1
|
|
144
|
+
`))[0];
|
|
145
|
+
if (!client)
|
|
146
|
+
throw new HttpError(500, "No personal access client found. Run ./buddy auth:setup first.");
|
|
147
|
+
const plainTextToken = generateSecureToken(40), hashedToken = hashToken(plainTextToken), expiresAt = new Date;
|
|
148
|
+
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInMinutes);
|
|
149
|
+
if (isPostgres)
|
|
150
|
+
await db.unsafe(`
|
|
151
|
+
INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
152
|
+
VALUES ($1, $2, $3, $4, $5, false, $6, NOW(), NOW())
|
|
153
|
+
`, [userId, client.id, hashedToken, name, JSON.stringify(scopes), expiresAt.toISOString()]);
|
|
154
|
+
else
|
|
155
|
+
await db.unsafe(`
|
|
156
|
+
INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ${now}, ${now})
|
|
158
|
+
`, [userId, client.id, hashedToken, name, JSON.stringify(scopes), expiresAt.toISOString()]);
|
|
159
|
+
const row = (await db.unsafe(`
|
|
160
|
+
SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
|
|
161
|
+
`, [hashedToken]))[0];
|
|
162
|
+
if (!row)
|
|
163
|
+
throw new HttpError(500, "Failed to create access token \u2014 inserted row not found");
|
|
164
|
+
const accessToken = {
|
|
165
|
+
id: row.id,
|
|
166
|
+
userId: row.user_id,
|
|
167
|
+
clientId: row.oauth_client_id,
|
|
168
|
+
name: row.name,
|
|
169
|
+
scopes: parseScopes(row.scopes),
|
|
170
|
+
revoked: !1,
|
|
171
|
+
expiresAt,
|
|
172
|
+
createdAt: new Date(row.created_at),
|
|
173
|
+
updatedAt: new Date(row.updated_at)
|
|
174
|
+
};
|
|
175
|
+
let refreshTokenPlain;
|
|
176
|
+
if (withRefreshToken) {
|
|
177
|
+
refreshTokenPlain = generateSecureToken(40);
|
|
178
|
+
const hashedRefreshToken = hashToken(refreshTokenPlain), refreshExpiresAt = new Date;
|
|
179
|
+
refreshExpiresAt.setDate(refreshExpiresAt.getDate() + refreshExpiresInDays);
|
|
180
|
+
if (isPostgres)
|
|
181
|
+
await db.unsafe(`
|
|
182
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
183
|
+
VALUES ($1, $2, false, $3, NOW())
|
|
184
|
+
`, [accessToken.id, hashedRefreshToken, refreshExpiresAt.toISOString()]);
|
|
185
|
+
else
|
|
186
|
+
await db.unsafe(`
|
|
187
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
188
|
+
VALUES (?, ?, 0, ?, ${now})
|
|
189
|
+
`, [accessToken.id, hashedRefreshToken, refreshExpiresAt.toISOString()]);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
accessToken,
|
|
193
|
+
plainTextToken,
|
|
194
|
+
refreshToken: refreshTokenPlain,
|
|
195
|
+
expiresIn: expiresInMinutes * 60
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export async function refreshToken(refreshTokenPlain, options = {}) {
|
|
199
|
+
const {
|
|
200
|
+
expiresInMinutes = 60,
|
|
201
|
+
refreshExpiresInDays = 30
|
|
202
|
+
} = options, hashedRefreshToken = hashToken(refreshTokenPlain);
|
|
203
|
+
return await db.transaction(async (rawTrx) => {
|
|
204
|
+
const trx = rawTrx, forUpdate = isPostgres || isMysql ? " FOR UPDATE" : "", refreshRow = (await trx.unsafe(`
|
|
205
|
+
SELECT r.*, t.user_id, t.oauth_client_id, t.name, t.scopes
|
|
206
|
+
FROM oauth_refresh_tokens r
|
|
207
|
+
JOIN oauth_access_tokens t ON r.access_token_id = t.id
|
|
208
|
+
WHERE r.token = ${param(1)}
|
|
209
|
+
AND r.revoked = ${boolFalse}
|
|
210
|
+
AND (r.expires_at IS NULL OR r.expires_at > ${now})
|
|
211
|
+
LIMIT 1${forUpdate}
|
|
212
|
+
`, [hashedRefreshToken]))[0];
|
|
213
|
+
if (!refreshRow)
|
|
214
|
+
throw new HttpError(401, "Invalid or expired refresh token");
|
|
215
|
+
if (isIssuedBeforePasswordChange(refreshRow.created_at, await getPasswordChangedAt(refreshRow.user_id, trx)))
|
|
216
|
+
throw new HttpError(401, "Invalid or expired refresh token");
|
|
217
|
+
await trx.unsafe(`
|
|
218
|
+
UPDATE oauth_refresh_tokens
|
|
219
|
+
SET revoked = ${boolTrue}
|
|
220
|
+
WHERE id = ${param(1)}
|
|
221
|
+
`, [refreshRow.id]);
|
|
222
|
+
await trx.unsafe(`
|
|
223
|
+
UPDATE oauth_access_tokens
|
|
224
|
+
SET revoked = ${boolTrue}
|
|
225
|
+
WHERE id = ${param(1)}
|
|
226
|
+
`, [refreshRow.access_token_id]);
|
|
227
|
+
const plainTextToken = generateSecureToken(40), hashedToken = hashToken(plainTextToken), expiresAt = new Date;
|
|
228
|
+
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInMinutes);
|
|
229
|
+
if (isPostgres)
|
|
230
|
+
await trx.unsafe(`
|
|
231
|
+
INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
232
|
+
VALUES ($1, $2, $3, $4, $5, false, $6, NOW(), NOW())
|
|
233
|
+
`, [refreshRow.user_id, refreshRow.oauth_client_id, hashedToken, refreshRow.name, refreshRow.scopes, expiresAt.toISOString()]);
|
|
234
|
+
else
|
|
235
|
+
await trx.unsafe(`
|
|
236
|
+
INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
237
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ${now}, ${now})
|
|
238
|
+
`, [refreshRow.user_id, refreshRow.oauth_client_id, hashedToken, refreshRow.name, refreshRow.scopes, expiresAt.toISOString()]);
|
|
239
|
+
const row = (await trx.unsafe(`
|
|
240
|
+
SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
|
|
241
|
+
`, [hashedToken]))[0], accessToken = {
|
|
242
|
+
id: row.id,
|
|
243
|
+
userId: row.user_id,
|
|
244
|
+
clientId: row.oauth_client_id,
|
|
245
|
+
name: row.name,
|
|
246
|
+
scopes: parseScopes(row.scopes),
|
|
247
|
+
revoked: !1,
|
|
248
|
+
expiresAt,
|
|
249
|
+
createdAt: new Date(row.created_at),
|
|
250
|
+
updatedAt: new Date(row.updated_at)
|
|
251
|
+
};
|
|
252
|
+
if (isIssuedBeforePasswordChange(row.created_at, await getPasswordChangedAt(refreshRow.user_id, trx)))
|
|
253
|
+
throw new HttpError(401, "Invalid or expired refresh token");
|
|
254
|
+
const newRefreshTokenPlain = generateSecureToken(40), newHashedRefreshToken = hashToken(newRefreshTokenPlain), refreshExpiresAt = new Date;
|
|
255
|
+
refreshExpiresAt.setDate(refreshExpiresAt.getDate() + refreshExpiresInDays);
|
|
256
|
+
if (isPostgres)
|
|
257
|
+
await trx.unsafe(`
|
|
258
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
259
|
+
VALUES ($1, $2, false, $3, NOW())
|
|
260
|
+
`, [accessToken.id, newHashedRefreshToken, refreshExpiresAt.toISOString()]);
|
|
261
|
+
else
|
|
262
|
+
await trx.unsafe(`
|
|
263
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
264
|
+
VALUES (?, ?, 0, ?, ${now})
|
|
265
|
+
`, [accessToken.id, newHashedRefreshToken, refreshExpiresAt.toISOString()]);
|
|
266
|
+
return {
|
|
267
|
+
accessToken,
|
|
268
|
+
plainTextToken,
|
|
269
|
+
refreshToken: newRefreshTokenPlain,
|
|
270
|
+
expiresIn: expiresInMinutes * 60
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
export async function validateRefreshToken(refreshTokenPlain) {
|
|
275
|
+
const hashedRefreshToken = hashToken(refreshTokenPlain);
|
|
276
|
+
return (await db.unsafe(`
|
|
277
|
+
SELECT id FROM oauth_refresh_tokens
|
|
278
|
+
WHERE token = ${param(1)}
|
|
279
|
+
AND revoked = ${boolFalse}
|
|
280
|
+
AND (expires_at IS NULL OR expires_at > ${now})
|
|
281
|
+
LIMIT 1
|
|
282
|
+
`, [hashedRefreshToken])).length > 0;
|
|
283
|
+
}
|
|
284
|
+
export async function revokeRefreshToken(refreshTokenPlain) {
|
|
285
|
+
const hashedRefreshToken = hashToken(refreshTokenPlain);
|
|
286
|
+
await db.unsafe(`
|
|
287
|
+
UPDATE oauth_refresh_tokens
|
|
288
|
+
SET revoked = ${boolTrue}
|
|
289
|
+
WHERE token = ${param(1)}
|
|
290
|
+
`, [hashedRefreshToken]);
|
|
291
|
+
}
|
|
292
|
+
export async function revokeAllRefreshTokens(userId) {
|
|
293
|
+
await db.unsafe(`
|
|
294
|
+
UPDATE oauth_refresh_tokens
|
|
295
|
+
SET revoked = ${boolTrue}
|
|
296
|
+
WHERE access_token_id IN (
|
|
297
|
+
SELECT id FROM oauth_access_tokens WHERE user_id = ${param(1)}
|
|
298
|
+
)
|
|
299
|
+
`, [userId]);
|
|
300
|
+
}
|
|
301
|
+
export async function deleteExpiredRefreshTokens() {
|
|
302
|
+
const result = await db.unsafe(`
|
|
303
|
+
DELETE FROM oauth_refresh_tokens
|
|
304
|
+
WHERE expires_at < ${now}
|
|
305
|
+
`);
|
|
306
|
+
return result?.changes || result?.rowCount || 0;
|
|
307
|
+
}
|
|
308
|
+
export async function deleteRevokedRefreshTokens(daysOld = 7) {
|
|
309
|
+
const cutoffDate = new Date;
|
|
310
|
+
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
|
311
|
+
const result = await db.unsafe(`
|
|
312
|
+
DELETE FROM oauth_refresh_tokens
|
|
313
|
+
WHERE revoked = ${boolTrue} AND created_at < ${param(1)}
|
|
314
|
+
`, [cutoffDate.toISOString()]);
|
|
315
|
+
return result?.changes || result?.rowCount || 0;
|
|
316
|
+
}
|
|
317
|
+
export async function revokeToken(plainTextToken) {
|
|
318
|
+
const hashedToken = bearerLookupHash(plainTextToken);
|
|
319
|
+
await db.unsafe(`
|
|
320
|
+
UPDATE oauth_refresh_tokens
|
|
321
|
+
SET revoked = ${boolTrue}
|
|
322
|
+
WHERE access_token_id IN (
|
|
323
|
+
SELECT id FROM oauth_access_tokens WHERE token = ${param(1)}
|
|
324
|
+
)
|
|
325
|
+
`, [hashedToken]);
|
|
326
|
+
await db.unsafe(`
|
|
327
|
+
UPDATE oauth_access_tokens
|
|
328
|
+
SET revoked = ${boolTrue}, updated_at = ${now}
|
|
329
|
+
WHERE token = ${param(1)}
|
|
330
|
+
`, [hashedToken]);
|
|
331
|
+
}
|
|
332
|
+
export async function revokeTokenById(tokenId) {
|
|
333
|
+
await db.unsafe(`
|
|
334
|
+
UPDATE oauth_refresh_tokens
|
|
335
|
+
SET revoked = ${boolTrue}
|
|
336
|
+
WHERE access_token_id = ${param(1)}
|
|
337
|
+
`, [tokenId]);
|
|
338
|
+
await db.unsafe(`
|
|
339
|
+
UPDATE oauth_access_tokens
|
|
340
|
+
SET revoked = ${boolTrue}, updated_at = ${now}
|
|
341
|
+
WHERE id = ${param(1)}
|
|
342
|
+
`, [tokenId]);
|
|
343
|
+
}
|
|
344
|
+
export async function revokeAllTokens(userId) {
|
|
345
|
+
await revokeAllRefreshTokens(userId);
|
|
346
|
+
await db.unsafe(`
|
|
347
|
+
UPDATE oauth_access_tokens
|
|
348
|
+
SET revoked = ${boolTrue}, updated_at = ${now}
|
|
349
|
+
WHERE user_id = ${param(1)}
|
|
350
|
+
`, [userId]);
|
|
351
|
+
}
|
|
352
|
+
export async function revokeOtherTokens(userId) {
|
|
353
|
+
const current = await currentAccessToken();
|
|
354
|
+
if (!current)
|
|
355
|
+
return revokeAllTokens(userId);
|
|
356
|
+
if (isPostgres) {
|
|
357
|
+
await db.unsafe(`
|
|
358
|
+
UPDATE oauth_refresh_tokens
|
|
359
|
+
SET revoked = true
|
|
360
|
+
WHERE access_token_id IN (
|
|
361
|
+
SELECT id FROM oauth_access_tokens WHERE user_id = $1 AND id != $2
|
|
362
|
+
)
|
|
363
|
+
`, [userId, current.id]);
|
|
364
|
+
await db.unsafe(`
|
|
365
|
+
UPDATE oauth_access_tokens
|
|
366
|
+
SET revoked = true, updated_at = NOW()
|
|
367
|
+
WHERE user_id = $1 AND id != $2
|
|
368
|
+
`, [userId, current.id]);
|
|
369
|
+
} else {
|
|
370
|
+
await db.unsafe(`
|
|
371
|
+
UPDATE oauth_refresh_tokens
|
|
372
|
+
SET revoked = 1
|
|
373
|
+
WHERE access_token_id IN (
|
|
374
|
+
SELECT id FROM oauth_access_tokens WHERE user_id = ? AND id != ?
|
|
375
|
+
)
|
|
376
|
+
`, [userId, current.id]);
|
|
377
|
+
await db.unsafe(`
|
|
378
|
+
UPDATE oauth_access_tokens
|
|
379
|
+
SET revoked = 1, updated_at = ${now}
|
|
380
|
+
WHERE user_id = ? AND id != ?
|
|
381
|
+
`, [userId, current.id]);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export async function deleteExpiredTokens() {
|
|
385
|
+
await db.unsafe(`
|
|
386
|
+
DELETE FROM oauth_refresh_tokens
|
|
387
|
+
WHERE access_token_id IN (
|
|
388
|
+
SELECT id FROM oauth_access_tokens WHERE expires_at < ${now}
|
|
389
|
+
)
|
|
390
|
+
`);
|
|
391
|
+
const result = await db.unsafe(`
|
|
392
|
+
DELETE FROM oauth_access_tokens
|
|
393
|
+
WHERE expires_at < ${now}
|
|
394
|
+
`);
|
|
395
|
+
return result?.changes || result?.rowCount || 0;
|
|
396
|
+
}
|
|
397
|
+
export async function deleteRevokedTokens(daysOld = 7) {
|
|
398
|
+
const cutoffDate = new Date;
|
|
399
|
+
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
|
400
|
+
await db.unsafe(`
|
|
401
|
+
DELETE FROM oauth_refresh_tokens
|
|
402
|
+
WHERE access_token_id IN (
|
|
403
|
+
SELECT id FROM oauth_access_tokens WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
|
|
404
|
+
)
|
|
405
|
+
`, [cutoffDate.toISOString()]);
|
|
406
|
+
const result = await db.unsafe(`
|
|
407
|
+
DELETE FROM oauth_access_tokens
|
|
408
|
+
WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
|
|
409
|
+
`, [cutoffDate.toISOString()]);
|
|
410
|
+
return result?.changes || result?.rowCount || 0;
|
|
411
|
+
}
|
|
412
|
+
export async function clients(userId) {
|
|
413
|
+
return (await db.unsafe(`
|
|
414
|
+
SELECT * FROM oauth_clients
|
|
415
|
+
WHERE user_id = ${param(1)} AND revoked = ${boolFalse}
|
|
416
|
+
ORDER BY created_at DESC
|
|
417
|
+
`, [userId])).map(mapToOAuthClient);
|
|
418
|
+
}
|
|
419
|
+
export async function findClient(clientId) {
|
|
420
|
+
const row = (await db.unsafe(`
|
|
421
|
+
SELECT * FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
|
|
422
|
+
`, [clientId]))[0];
|
|
423
|
+
return row ? mapToOAuthClient(row) : null;
|
|
424
|
+
}
|
|
425
|
+
export async function createClient(options) {
|
|
426
|
+
const secret = generateSecureToken(40);
|
|
427
|
+
if (isPostgres)
|
|
428
|
+
await db.unsafe(`
|
|
429
|
+
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
430
|
+
VALUES ($1, $2, 'local', $3, $4, $5, false, NOW())
|
|
431
|
+
`, [
|
|
432
|
+
options.name,
|
|
433
|
+
secret,
|
|
434
|
+
options.redirect,
|
|
435
|
+
options.personalAccessClient || !1,
|
|
436
|
+
options.passwordClient || !1
|
|
437
|
+
]);
|
|
438
|
+
else
|
|
439
|
+
await db.unsafe(`
|
|
440
|
+
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
441
|
+
VALUES (?, ?, 'local', ?, ?, ?, 0, ${now})
|
|
442
|
+
`, [
|
|
443
|
+
options.name,
|
|
444
|
+
secret,
|
|
445
|
+
options.redirect,
|
|
446
|
+
options.personalAccessClient ? 1 : 0,
|
|
447
|
+
options.passwordClient ? 1 : 0
|
|
448
|
+
]);
|
|
449
|
+
const inserted = await db.unsafe(`
|
|
450
|
+
SELECT * FROM oauth_clients WHERE secret = ${param(1)} LIMIT 1
|
|
451
|
+
`, [secret]);
|
|
452
|
+
return {
|
|
453
|
+
client: mapToOAuthClient(inserted[0]),
|
|
454
|
+
plainTextSecret: secret
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
export async function revokeClient(clientId) {
|
|
458
|
+
await db.unsafe(`
|
|
459
|
+
UPDATE oauth_clients
|
|
460
|
+
SET revoked = ${boolTrue}, updated_at = ${now}
|
|
461
|
+
WHERE id = ${param(1)}
|
|
462
|
+
`, [clientId]);
|
|
463
|
+
}
|
|
464
|
+
export function parseScopes(scopes) {
|
|
465
|
+
if (!scopes)
|
|
466
|
+
return [];
|
|
467
|
+
if (Array.isArray(scopes))
|
|
468
|
+
return scopes;
|
|
469
|
+
try {
|
|
470
|
+
const parsed = JSON.parse(scopes);
|
|
471
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
472
|
+
} catch {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function mapToOAuthClient(row) {
|
|
477
|
+
return {
|
|
478
|
+
id: row.id,
|
|
479
|
+
name: row.name,
|
|
480
|
+
secret: row.secret,
|
|
481
|
+
provider: row.provider,
|
|
482
|
+
redirect: row.redirect,
|
|
483
|
+
personalAccessClient: Boolean(row.personal_access_client),
|
|
484
|
+
passwordClient: Boolean(row.password_client),
|
|
485
|
+
revoked: Boolean(row.revoked),
|
|
486
|
+
createdAt: row.created_at ? new Date(row.created_at) : new Date,
|
|
487
|
+
updatedAt: row.updated_at ? new Date(row.updated_at) : null
|
|
488
|
+
};
|
|
489
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads `two_factor_secret`/`two_factor_enabled` directly — these are
|
|
3
|
+
* guarantee-ALTER columns (see ensureUsersAuthColumns), not part of
|
|
4
|
+
* the User model's typed `attributes`, so callers query them the same
|
|
5
|
+
* way email-verification.ts reads email_verified_at: raw db access,
|
|
6
|
+
* not the ORM model.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getTwoFactorState(userId: number): Promise<{ secret: string | null, enabled: boolean }>;
|
|
9
|
+
export declare function isTwoFactorEnabled(user: TwoFactorUser): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Generate a new (unpersisted) secret + otpauth:// URI for setup.
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateTwoFactorSetup(email: string, serviceName?: string): { secret: string, uri: string };
|
|
14
|
+
/**
|
|
15
|
+
* Stash a freshly generated secret server-side while the user goes
|
|
16
|
+
* scan/enter it into their authenticator app. Single pending secret
|
|
17
|
+
* per user — generating a new one invalidates any prior unconfirmed
|
|
18
|
+
* attempt, same delete-then-insert shape as storeWebAuthnChallenge.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stashPendingTwoFactorSecret(userId: number, secret: string, ttlSeconds?: number): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Consume (delete-on-read) the pending secret stashed for a user, or
|
|
23
|
+
* null if none exists / it expired.
|
|
24
|
+
*/
|
|
25
|
+
export declare function consumePendingTwoFactorSecret(userId: number): Promise<string | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Verify the setup code against the not-yet-persisted secret and, if
|
|
28
|
+
* valid, persist it + flip `two_factor_enabled` on.
|
|
29
|
+
*/
|
|
30
|
+
export declare function enableTwoFactor(userId: number, secret: string, code: string): Promise<boolean>;
|
|
31
|
+
export declare function disableTwoFactor(userId: number): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Verify a live login/dashboard-reauth code against the user's
|
|
34
|
+
* already-persisted secret.
|
|
35
|
+
*/
|
|
36
|
+
export declare function verifyTwoFactorLoginCode(userId: number, code: string): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Create a single-use, short-lived login challenge for a user whose
|
|
39
|
+
* password just verified but who still needs to supply a TOTP code.
|
|
40
|
+
* Mirrors storeWebAuthnChallenge's delete-then-insert shape, keyed by
|
|
41
|
+
* an opaque random id instead of (user_id, purpose) since a user can
|
|
42
|
+
* only have one login attempt in flight that matters here.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createTwoFactorChallenge(userId: number, ttlSeconds?: number): Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Consume (delete-on-read) a login challenge and return the user id it
|
|
47
|
+
* was issued for, or null if missing/expired.
|
|
48
|
+
*/
|
|
49
|
+
export declare function consumeTwoFactorChallenge(challengeToken: string): Promise<number | null>;
|
|
50
|
+
export declare const TwoFactor: {
|
|
51
|
+
isEnabled: unknown;
|
|
52
|
+
getState: unknown;
|
|
53
|
+
generateSetup: unknown;
|
|
54
|
+
stashPendingSecret: unknown;
|
|
55
|
+
consumePendingSecret: unknown;
|
|
56
|
+
enable: unknown;
|
|
57
|
+
disable: unknown;
|
|
58
|
+
verifyLoginCode: unknown;
|
|
59
|
+
createChallenge: unknown;
|
|
60
|
+
consumeChallenge: unknown
|
|
61
|
+
};
|
|
62
|
+
export declare interface TwoFactorUser {
|
|
63
|
+
id: number
|
|
64
|
+
email?: string
|
|
65
|
+
two_factor_secret?: string | null
|
|
66
|
+
two_factor_enabled?: boolean | number | null
|
|
67
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "@stacksjs/database";
|
|
3
|
+
import { generateTwoFactorSecret, generateTwoFactorUri, verifyTwoFactorCode } from "./authenticator";
|
|
4
|
+
import { RateLimiter } from "./rate-limiter";
|
|
5
|
+
const DEFAULT_CHALLENGE_TTL_SECONDS = 300, TWO_FACTOR_RATE_LIMIT_PREFIX = "2fa:", TWO_FACTOR_STEP_SECONDS = 30;
|
|
6
|
+
function currentTotpStep() {
|
|
7
|
+
return Math.floor(Date.now() / 1000 / TWO_FACTOR_STEP_SECONDS);
|
|
8
|
+
}
|
|
9
|
+
async function getLastUsedTwoFactorStep(userId) {
|
|
10
|
+
try {
|
|
11
|
+
const value = (await db.selectFrom("users").where("id", "=", userId).selectAll().executeTakeFirst())?.two_factor_last_used_step;
|
|
12
|
+
return value == null ? null : Number(value);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function setLastUsedTwoFactorStep(userId, step) {
|
|
18
|
+
try {
|
|
19
|
+
await db.updateTable("users").set({ two_factor_last_used_step: step }).where("id", "=", userId).executeTakeFirst();
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
export async function getTwoFactorState(userId) {
|
|
23
|
+
const row = await db.selectFrom("users").where("id", "=", userId).select(["two_factor_secret", "two_factor_enabled"]).executeTakeFirst();
|
|
24
|
+
return {
|
|
25
|
+
secret: row?.two_factor_secret ?? null,
|
|
26
|
+
enabled: Boolean(row?.two_factor_enabled)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function isTwoFactorEnabled(user) {
|
|
30
|
+
return Boolean(user.two_factor_enabled);
|
|
31
|
+
}
|
|
32
|
+
export function generateTwoFactorSetup(email, serviceName) {
|
|
33
|
+
const secret = generateTwoFactorSecret(), uri = generateTwoFactorUri(email, serviceName, secret);
|
|
34
|
+
return { secret, uri };
|
|
35
|
+
}
|
|
36
|
+
const PENDING_SECRET_TTL_SECONDS = 600;
|
|
37
|
+
export async function stashPendingTwoFactorSecret(userId, secret, ttlSeconds = PENDING_SECRET_TTL_SECONDS) {
|
|
38
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
39
|
+
await db.deleteFrom("two_factor_pending_secrets").where("user_id", "=", userId).execute();
|
|
40
|
+
await db.insertInto("two_factor_pending_secrets").values({
|
|
41
|
+
user_id: userId,
|
|
42
|
+
secret,
|
|
43
|
+
expires_at: expiresAt
|
|
44
|
+
}).execute();
|
|
45
|
+
}
|
|
46
|
+
export async function consumePendingTwoFactorSecret(userId) {
|
|
47
|
+
const row = await db.selectFrom("two_factor_pending_secrets").where("user_id", "=", userId).selectAll().executeTakeFirst();
|
|
48
|
+
if (!row)
|
|
49
|
+
return null;
|
|
50
|
+
await db.deleteFrom("two_factor_pending_secrets").where("user_id", "=", userId).execute();
|
|
51
|
+
const expiresAt = row.expires_at ? new Date(String(row.expires_at)).getTime() : 0;
|
|
52
|
+
if (Date.now() > expiresAt)
|
|
53
|
+
return null;
|
|
54
|
+
return String(row.secret);
|
|
55
|
+
}
|
|
56
|
+
export async function enableTwoFactor(userId, secret, code) {
|
|
57
|
+
if (!await verifyTwoFactorCode(code, secret))
|
|
58
|
+
return !1;
|
|
59
|
+
await db.updateTable("users").set({ two_factor_secret: secret, two_factor_enabled: !0 }).where("id", "=", userId).executeTakeFirst();
|
|
60
|
+
return !0;
|
|
61
|
+
}
|
|
62
|
+
export async function disableTwoFactor(userId) {
|
|
63
|
+
await db.updateTable("users").set({ two_factor_secret: null, two_factor_enabled: !1 }).where("id", "=", userId).executeTakeFirst();
|
|
64
|
+
}
|
|
65
|
+
export async function verifyTwoFactorLoginCode(userId, code) {
|
|
66
|
+
const rateKey = `${TWO_FACTOR_RATE_LIMIT_PREFIX}${userId}`;
|
|
67
|
+
await RateLimiter.validateAttempt(rateKey);
|
|
68
|
+
const { secret, enabled } = await getTwoFactorState(userId);
|
|
69
|
+
if (!enabled || !secret)
|
|
70
|
+
return !1;
|
|
71
|
+
if (!await verifyTwoFactorCode(code, secret)) {
|
|
72
|
+
await RateLimiter.recordFailedAttempt(rateKey);
|
|
73
|
+
return !1;
|
|
74
|
+
}
|
|
75
|
+
const step = currentTotpStep(), lastStep = await getLastUsedTwoFactorStep(userId);
|
|
76
|
+
if (lastStep !== null && step <= lastStep)
|
|
77
|
+
return !1;
|
|
78
|
+
await setLastUsedTwoFactorStep(userId, step);
|
|
79
|
+
await RateLimiter.resetAttempts(rateKey);
|
|
80
|
+
return !0;
|
|
81
|
+
}
|
|
82
|
+
export async function createTwoFactorChallenge(userId, ttlSeconds = DEFAULT_CHALLENGE_TTL_SECONDS) {
|
|
83
|
+
const id = randomBytes(32).toString("hex"), expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
84
|
+
await db.deleteFrom("two_factor_challenges").where("user_id", "=", userId).execute();
|
|
85
|
+
await db.insertInto("two_factor_challenges").values({
|
|
86
|
+
id,
|
|
87
|
+
user_id: userId,
|
|
88
|
+
expires_at: expiresAt
|
|
89
|
+
}).execute();
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
export async function consumeTwoFactorChallenge(challengeToken) {
|
|
93
|
+
const row = await db.selectFrom("two_factor_challenges").where("id", "=", challengeToken).selectAll().executeTakeFirst();
|
|
94
|
+
if (!row)
|
|
95
|
+
return null;
|
|
96
|
+
await db.deleteFrom("two_factor_challenges").where("id", "=", challengeToken).execute();
|
|
97
|
+
const expiresAt = row.expires_at ? new Date(String(row.expires_at)).getTime() : 0;
|
|
98
|
+
if (Date.now() > expiresAt)
|
|
99
|
+
return null;
|
|
100
|
+
return Number(row.user_id);
|
|
101
|
+
}
|
|
102
|
+
export const TwoFactor = {
|
|
103
|
+
isEnabled: isTwoFactorEnabled,
|
|
104
|
+
getState: getTwoFactorState,
|
|
105
|
+
generateSetup: generateTwoFactorSetup,
|
|
106
|
+
stashPendingSecret: stashPendingTwoFactorSecret,
|
|
107
|
+
consumePendingSecret: consumePendingTwoFactorSecret,
|
|
108
|
+
enable: enableTwoFactor,
|
|
109
|
+
disable: disableTwoFactor,
|
|
110
|
+
verifyLoginCode: verifyTwoFactorLoginCode,
|
|
111
|
+
createChallenge: createTwoFactorChallenge,
|
|
112
|
+
consumeChallenge: consumeTwoFactorChallenge
|
|
113
|
+
};
|