@stacksjs/auth 0.70.257 → 0.70.259

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.
@@ -1,111 +1,3 @@
1
- import { Buffer } from "node:buffer";
2
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
- import { config } from "@stacksjs/config";
4
- import { db, sqlDateTime, parseSqlDateTime } 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: sqlDateTime(expiresAt)
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}
1
+ import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)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.");return appKey}function generateVerificationToken(userId){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,token,storedHash){const payload=`${userId}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){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);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){const{token,hash}=generateVerificationToken(user.id),expiryMinutes=getExpiryMinutes(),expiresAt=new Date(Date.now()+expiryMinutes*60*1000);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt)}).executeTakeFirst();const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";try{const{html,text}=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});if(!html&&!text)throw Error("email-verification template missing or rendered empty");await mail.send({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);await mail.send({to:user.email,subject:`Verify Your ${appName} Email Address`,text:`Please verify your email address by visiting: ${verificationUrl}
72
2
 
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 = parseSqlDateTime(record.expires_at) ?? new Date(Number.NaN);
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: sqlDateTime() }).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
- };
3
+ This link expires in ${expiryMinutes} minutes.`})}}export async function verifyEmail(userId,token){const record=await db.selectFrom("email_verifications").where("user_id","=",userId).selectAll().executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||new Date>expiresAt){await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}if(!verifyToken(userId,token,record.token))return{success:!1,message:"Invalid verification link."};await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).executeTakeFirst();await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!0,message:"Email verified successfully."}}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const existing=await db.selectFrom("email_verifications").where("user_id","=",user.id).selectAll().executeTakeFirst();if(existing){const createdAt=new Date(existing.created_at),secondsSince=(Date.now()-createdAt.getTime())/1000;if(Number.isNaN(secondsSince))return{success:!1,message:"Please wait a moment before requesting another verification email."};if(secondsSince<60)return{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}await sendVerificationEmail(user);return{success:!0,message:"Verification email sent."}}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
package/dist/gate.js CHANGED
@@ -1,203 +1 @@
1
- const RESERVED_POLICY_MEMBERS = new Set([
2
- "before",
3
- "allow",
4
- "deny",
5
- "denyIf",
6
- "denyUnless",
7
- "allowIf",
8
- "constructor"
9
- ]);
10
-
11
- export class AuthorizationResponse {
12
- isAllowed;
13
- message;
14
- code;
15
- constructor(allowed, message, code) {
16
- this.isAllowed = allowed;
17
- this.message = message;
18
- this.code = code;
19
- }
20
- static allow(message) {
21
- return new AuthorizationResponse(!0, message);
22
- }
23
- static deny(message, code) {
24
- return new AuthorizationResponse(!1, message || "This action is unauthorized.", code);
25
- }
26
- allowed() {
27
- return this.isAllowed;
28
- }
29
- denied() {
30
- return !this.isAllowed;
31
- }
32
- authorize() {
33
- if (!this.isAllowed)
34
- throw new AuthorizationException(this.message || "This action is unauthorized.", this.code);
35
- }
36
- }
37
-
38
- export class AuthorizationException extends Error {
39
- code;
40
- status;
41
- constructor(message = "This action is unauthorized.", code, status = 403) {
42
- super(message);
43
- this.code = code;
44
- this.status = status;
45
- this.name = "AuthorizationException";
46
- }
47
- }
48
- const state = {
49
- gates: new Map,
50
- policies: new Map,
51
- beforeCallbacks: [],
52
- afterCallbacks: []
53
- };
54
- export function define(ability, callback) {
55
- state.gates.set(ability, callback);
56
- }
57
- export function policy(model, policyClass) {
58
- const modelName = typeof model === "string" ? model : model.name;
59
- state.policies.set(modelName, policyClass);
60
- }
61
- export function before(callback) {
62
- state.beforeCallbacks.push(callback);
63
- }
64
- export function after(callback) {
65
- state.afterCallbacks.push(callback);
66
- }
67
- export async function allows(ability, user, ...args) {
68
- return check(ability, user, ...args);
69
- }
70
- export async function denies(ability, user, ...args) {
71
- return !await check(ability, user, ...args);
72
- }
73
- export async function can(ability, user, ...args) {
74
- return check(ability, user, ...args);
75
- }
76
- export async function cannot(ability, user, ...args) {
77
- return !await check(ability, user, ...args);
78
- }
79
- export async function any(abilities, user, ...args) {
80
- for (const ability of abilities)
81
- if (await check(ability, user, ...args))
82
- return !0;
83
- return !1;
84
- }
85
- export async function all(abilities, user, ...args) {
86
- for (const ability of abilities)
87
- if (!await check(ability, user, ...args))
88
- return !1;
89
- return !0;
90
- }
91
- export async function none(abilities, user, ...args) {
92
- return !await any(abilities, user, ...args);
93
- }
94
- export async function authorize(ability, user, ...args) {
95
- const result = await inspect(ability, user, ...args);
96
- if (!result.isAllowed)
97
- throw new AuthorizationException(result.message, result.code);
98
- return result;
99
- }
100
- export async function inspect(ability, user, ...args) {
101
- let response = null;
102
- for (const callback of state.beforeCallbacks) {
103
- const beforeResult = await callback(user, ability, args);
104
- if (beforeResult === !0) {
105
- response = AuthorizationResponse.allow();
106
- break;
107
- }
108
- if (beforeResult === !1) {
109
- response = AuthorizationResponse.deny();
110
- break;
111
- }
112
- }
113
- if (!response)
114
- response = await resolveAbility(ability, user, args);
115
- for (const callback of state.afterCallbacks) {
116
- const afterResult = await callback(user, ability, response.isAllowed, args);
117
- if (typeof afterResult === "boolean")
118
- return afterResult ? AuthorizationResponse.allow() : AuthorizationResponse.deny();
119
- }
120
- return response;
121
- }
122
- async function resolveAbility(ability, user, args) {
123
- const model = args[0];
124
- if (model && typeof model === "object") {
125
- const modelName = model.constructor?.name, policyClass = state.policies.get(modelName);
126
- if (policyClass) {
127
- const policyInstance = new policyClass;
128
- if (policyInstance.before) {
129
- const beforeResult = await policyInstance.before(user, ability);
130
- if (beforeResult === !0)
131
- return AuthorizationResponse.allow();
132
- if (beforeResult === !1)
133
- return AuthorizationResponse.deny();
134
- }
135
- const method = RESERVED_POLICY_MEMBERS.has(ability) ? void 0 : policyInstance[ability];
136
- if (typeof method === "function") {
137
- const result = await method.call(policyInstance, user, ...args);
138
- return normalizeResponse(result ?? !1);
139
- }
140
- }
141
- }
142
- const gate = state.gates.get(ability);
143
- if (gate)
144
- return normalizeResponse(await gate(user, ...args));
145
- return AuthorizationResponse.deny(`No gate or policy defined for ability: ${ability}`);
146
- }
147
- async function check(ability, user, ...args) {
148
- return (await inspect(ability, user, ...args)).isAllowed;
149
- }
150
- function normalizeResponse(result) {
151
- if (result instanceof AuthorizationResponse)
152
- return result;
153
- if (typeof result !== "boolean")
154
- throw TypeError(`[gate] Policy must return boolean or AuthorizationResponse; got ${typeof result}. If you returned a model/value by mistake, return \`true\`/\`false\` instead.`);
155
- return result ? AuthorizationResponse.allow() : AuthorizationResponse.deny();
156
- }
157
- export function getPolicyFor(model) {
158
- if (!model || typeof model !== "object")
159
- return null;
160
- const modelName = model.constructor?.name, policyClass = state.policies.get(modelName);
161
- if (policyClass)
162
- return new policyClass;
163
- return null;
164
- }
165
- export function has(ability) {
166
- return state.gates.has(ability);
167
- }
168
- export function hasPolicy(model) {
169
- const modelName = typeof model === "string" ? model : model.name;
170
- return state.policies.has(modelName);
171
- }
172
- export function abilities() {
173
- return Array.from(state.gates.keys());
174
- }
175
- export function flush() {
176
- state.gates.clear();
177
- state.policies.clear();
178
- state.beforeCallbacks = [];
179
- state.afterCallbacks = [];
180
- }
181
- export const Gate = {
182
- define,
183
- policy,
184
- before,
185
- after,
186
- allows,
187
- denies,
188
- can,
189
- cannot,
190
- any,
191
- all,
192
- none,
193
- authorize,
194
- inspect,
195
- has,
196
- hasPolicy,
197
- abilities,
198
- getPolicyFor,
199
- flush,
200
- AuthorizationResponse,
201
- AuthorizationException
202
- };
203
- export default Gate;
1
+ const RESERVED_POLICY_MEMBERS=new Set(["before","allow","deny","denyIf","denyUnless","allowIf","constructor"]);export class AuthorizationResponse{isAllowed;message;code;constructor(allowed,message,code){this.isAllowed=allowed;this.message=message;this.code=code}static allow(message){return new AuthorizationResponse(!0,message)}static deny(message,code){return new AuthorizationResponse(!1,message||"This action is unauthorized.",code)}allowed(){return this.isAllowed}denied(){return!this.isAllowed}authorize(){if(!this.isAllowed)throw new AuthorizationException(this.message||"This action is unauthorized.",this.code)}}export class AuthorizationException extends Error{code;status;constructor(message="This action is unauthorized.",code,status=403){super(message);this.code=code;this.status=status;this.name="AuthorizationException"}}const state={gates:new Map,policies:new Map,beforeCallbacks:[],afterCallbacks:[]};export function define(ability,callback){state.gates.set(ability,callback)}export function policy(model,policyClass){const modelName=typeof model==="string"?model:model.name;state.policies.set(modelName,policyClass)}export function before(callback){state.beforeCallbacks.push(callback)}export function after(callback){state.afterCallbacks.push(callback)}export async function allows(ability,user,...args){return check(ability,user,...args)}export async function denies(ability,user,...args){return!await check(ability,user,...args)}export async function can(ability,user,...args){return check(ability,user,...args)}export async function cannot(ability,user,...args){return!await check(ability,user,...args)}export async function any(abilities,user,...args){for(const ability of abilities)if(await check(ability,user,...args))return!0;return!1}export async function all(abilities,user,...args){for(const ability of abilities)if(!await check(ability,user,...args))return!1;return!0}export async function none(abilities,user,...args){return!await any(abilities,user,...args)}export async function authorize(ability,user,...args){const result=await inspect(ability,user,...args);if(!result.isAllowed)throw new AuthorizationException(result.message,result.code);return result}export async function inspect(ability,user,...args){let response=null;for(const callback of state.beforeCallbacks){const beforeResult=await callback(user,ability,args);if(beforeResult===!0){response=AuthorizationResponse.allow();break}if(beforeResult===!1){response=AuthorizationResponse.deny();break}}if(!response)response=await resolveAbility(ability,user,args);for(const callback of state.afterCallbacks){const afterResult=await callback(user,ability,response.isAllowed,args);if(typeof afterResult==="boolean")return afterResult?AuthorizationResponse.allow():AuthorizationResponse.deny()}return response}async function resolveAbility(ability,user,args){const model=args[0];if(model&&typeof model==="object"){const modelName=model.constructor?.name,policyClass=state.policies.get(modelName);if(policyClass){const policyInstance=new policyClass;if(policyInstance.before){const beforeResult=await policyInstance.before(user,ability);if(beforeResult===!0)return AuthorizationResponse.allow();if(beforeResult===!1)return AuthorizationResponse.deny()}const method=RESERVED_POLICY_MEMBERS.has(ability)?void 0:policyInstance[ability];if(typeof method==="function"){const result=await method.call(policyInstance,user,...args);return normalizeResponse(result??!1)}}}const gate=state.gates.get(ability);if(gate)return normalizeResponse(await gate(user,...args));return AuthorizationResponse.deny(`No gate or policy defined for ability: ${ability}`)}async function check(ability,user,...args){return(await inspect(ability,user,...args)).isAllowed}function normalizeResponse(result){if(result instanceof AuthorizationResponse)return result;if(typeof result!=="boolean")throw TypeError(`[gate] Policy must return boolean or AuthorizationResponse; got ${typeof result}. If you returned a model/value by mistake, return \`true\`/\`false\` instead.`);return result?AuthorizationResponse.allow():AuthorizationResponse.deny()}export function getPolicyFor(model){if(!model||typeof model!=="object")return null;const modelName=model.constructor?.name,policyClass=state.policies.get(modelName);if(policyClass)return new policyClass;return null}export function has(ability){return state.gates.has(ability)}export function hasPolicy(model){const modelName=typeof model==="string"?model:model.name;return state.policies.has(modelName)}export function abilities(){return Array.from(state.gates.keys())}export function flush(){state.gates.clear();state.policies.clear();state.beforeCallbacks=[];state.afterCallbacks=[]}export const Gate={define,policy,before,after,allows,denies,can,cannot,any,all,none,authorize,inspect,has,hasPolicy,abilities,getPolicyFor,flush,AuthorizationResponse,AuthorizationException};export default Gate;
package/dist/index.js CHANGED
@@ -1,28 +1 @@
1
- export * from "./authentication";
2
- export * from "./authenticator";
3
- export * from "./client";
4
- export * from "./middleware";
5
- export * from "./rate-limiter";
6
- export * from "./passkey";
7
- export * from "./password/reset";
8
- export * from "./register";
9
- export * from "./user";
10
- export * from "./tokens";
11
- export * from "./gate";
12
- export * from "./policy";
13
- export * from "./authorizable";
14
- export * from "./permissions";
15
- export * from "./rbac";
16
- export { createBqbRbacStore } from "./rbac-store-bqb";
17
- export { DEFAULT_ROLE_PACKS, seedDefaultRoles } from "./rbac-seed";
18
- export * from "./email-verification";
19
- export * from "./session-auth";
20
- export * from "./cookie-auth";
21
- export {
22
- generateTOTP,
23
- verifyTOTP,
24
- generateTOTPSecret,
25
- totpKeyUri
26
- } from "@stacksjs/ts-auth";
27
- export * from "./two-factor";
28
- export * from "./team";
1
+ export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./rate-limiter";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";
@@ -1 +1 @@
1
- export const DUMMY_BCRYPT_HASH = "$2b$12$000000000000000000000uGByljkdFkOJRCRiYZGFOAstyLlSgTSW";
1
+ export const DUMMY_BCRYPT_HASH="$2b$12$000000000000000000000uGByljkdFkOJRCRiYZGFOAstyLlSgTSW";
@@ -1,28 +1 @@
1
- import { Auth } from "./authentication";
2
- export async function authMiddleware(request) {
3
- let bearerToken = request.bearerToken?.();
4
- if (!bearerToken) {
5
- const authHeader = request.headers?.get?.("authorization") || request.headers?.get?.("Authorization");
6
- if (authHeader && authHeader.startsWith("Bearer "))
7
- bearerToken = authHeader.substring(7);
8
- }
9
- if (!bearerToken) {
10
- const error = Error("No authentication token provided.");
11
- error.statusCode = 401;
12
- throw error;
13
- }
14
- const user = await Auth.getUserFromToken(bearerToken);
15
- if (!user) {
16
- const error = Error("Invalid or expired authentication token.");
17
- error.statusCode = 401;
18
- throw error;
19
- }
20
- Auth.setUser(user);
21
- request._authenticatedUser = user;
22
- const accessToken = await Auth.currentAccessToken();
23
- request._currentAccessToken = accessToken;
24
- }
25
- export const authMiddlewareHandler = {
26
- name: "auth",
27
- handle: authMiddleware
28
- };
1
+ import{Auth}from"./authentication";export async function authMiddleware(request){let bearerToken=request.bearerToken?.();if(!bearerToken){const authHeader=request.headers?.get?.("authorization")||request.headers?.get?.("Authorization");if(authHeader&&authHeader.startsWith("Bearer "))bearerToken=authHeader.substring(7)}if(!bearerToken){const error=Error("No authentication token provided.");error.statusCode=401;throw error}const user=await Auth.getUserFromToken(bearerToken);if(!user){const error=Error("Invalid or expired authentication token.");error.statusCode=401;throw error}Auth.setUser(user);request._authenticatedUser=user;const accessToken=await Auth.currentAccessToken();request._currentAccessToken=accessToken}export const authMiddlewareHandler={name:"auth",handle:authMiddleware};
package/dist/passkey.js CHANGED
@@ -1,76 +1 @@
1
- import { Buffer } from "node:buffer";
2
- import { db, sqlDateTime, parseSqlDateTime } from "@stacksjs/database";
3
- import { User } from "@stacksjs/orm";
4
- export {
5
- generateRegistrationOptions,
6
- generateAuthenticationOptions,
7
- verifyRegistrationResponse,
8
- verifyAuthenticationResponse,
9
- startRegistration,
10
- startAuthentication,
11
- browserSupportsWebAuthn,
12
- browserSupportsWebAuthnAutofill,
13
- platformAuthenticatorIsAvailable
14
- } from "@stacksjs/ts-auth";
15
- export async function getUserPasskeys(userId) {
16
- return await db.selectFrom("passkeys").selectAll().where("user_id", "=", userId).execute();
17
- }
18
- export async function getUserPasskey(userId, passkeyId) {
19
- return await db.selectFrom("passkeys").selectAll().where("id", "=", passkeyId).where("user_id", "=", userId).executeTakeFirst();
20
- }
21
- export async function updatePasskeyCounter(userId, passkeyId, newCounter) {
22
- const passkey = await getUserPasskey(userId, passkeyId);
23
- if (!passkey)
24
- return !1;
25
- const stored = Number(passkey.counter ?? 0);
26
- if (newCounter <= stored && !(newCounter === 0 && stored === 0))
27
- return !1;
28
- await db.updateTable("passkeys").set({ counter: newCounter, last_used_at: formatDateTime() }).where("id", "=", passkeyId).where("user_id", "=", userId).execute();
29
- return !0;
30
- }
31
- export async function setCurrentRegistrationOptions(user, verified) {
32
- const credentialId = verified.registrationInfo?.credential.id, credentialPublicKey = verified.registrationInfo?.credential.publicKey;
33
- if (!credentialId)
34
- throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");
35
- if (!credentialPublicKey)
36
- throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");
37
- const passkeyData = {
38
- id: credentialId,
39
- cred_public_key: JSON.stringify(credentialPublicKey),
40
- user_id: user.id,
41
- webauthn_user_id: user.email || "",
42
- counter: verified.registrationInfo?.credential.counter || 0,
43
- credential_type: verified.registrationInfo?.credentialType || "",
44
- device_type: verified.registrationInfo?.credentialDeviceType || "",
45
- backup_eligible: !1,
46
- backup_status: verified.registrationInfo?.credentialBackedUp || !1,
47
- transports: JSON.stringify(["internal"]),
48
- last_used_at: formatDateTime()
49
- };
50
- await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow();
51
- }
52
- function formatDateTime() {
53
- const date = new Date, pad = (num) => String(num).padStart(2, "0"), year = date.getFullYear(), month = pad(date.getMonth() + 1), day = pad(date.getDate()), hours = pad(date.getHours()), minutes = pad(date.getMinutes()), seconds = pad(date.getSeconds());
54
- return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
55
- }
56
- const DEFAULT_CHALLENGE_TTL_SECONDS = 300;
57
- export async function storeWebAuthnChallenge(userId, challenge, purpose, ttlSeconds = DEFAULT_CHALLENGE_TTL_SECONDS) {
58
- const expiresAt = sqlDateTime(new Date(Date.now() + ttlSeconds * 1000)), encodedChallenge = typeof challenge === "string" ? challenge : Buffer.from(challenge).toString("base64url");
59
- await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
60
- await db.insertInto("webauthn_challenges").values({
61
- user_id: userId,
62
- challenge: encodedChallenge,
63
- purpose,
64
- expires_at: expiresAt
65
- }).execute();
66
- }
67
- export async function consumeWebAuthnChallenge(userId, purpose) {
68
- const row = await db.selectFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).selectAll().executeTakeFirst();
69
- if (!row)
70
- return null;
71
- await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
72
- const expiresAt = parseSqlDateTime(row.expires_at)?.getTime() ?? 0;
73
- if (Date.now() > expiresAt)
74
- return null;
75
- return Buffer.from(String(row.challenge), "base64url");
76
- }
1
+ import{Buffer}from"node:buffer";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{User}from"@stacksjs/orm";export{generateRegistrationOptions,generateAuthenticationOptions,verifyRegistrationResponse,verifyAuthenticationResponse,startRegistration,startAuthentication,browserSupportsWebAuthn,browserSupportsWebAuthnAutofill,platformAuthenticatorIsAvailable}from"@stacksjs/ts-auth";export async function getUserPasskeys(userId){return await db.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){const passkey=await getUserPasskey(userId,passkeyId);if(!passkey)return!1;const stored=Number(passkey.counter??0);if(newCounter<=stored&&!(newCounter===0&&stored===0))return!1;await db.updateTable("passkeys").set({counter:newCounter,last_used_at:formatDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0}export async function setCurrentRegistrationOptions(user,verified){const credentialId=verified.registrationInfo?.credential.id,credentialPublicKey=verified.registrationInfo?.credential.publicKey;if(!credentialId)throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");if(!credentialPublicKey)throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");const passkeyData={id:credentialId,cred_public_key:JSON.stringify(credentialPublicKey),user_id:user.id,webauthn_user_id:user.email||"",counter:verified.registrationInfo?.credential.counter||0,credential_type:verified.registrationInfo?.credentialType||"",device_type:verified.registrationInfo?.credentialDeviceType||"",backup_eligible:!1,backup_status:verified.registrationInfo?.credentialBackedUp||!1,transports:JSON.stringify(["internal"]),last_used_at:formatDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}function formatDateTime(){const date=new Date,pad=(num)=>String(num).padStart(2,"0"),year=date.getFullYear(),month=pad(date.getMonth()+1),day=pad(date.getDate()),hours=pad(date.getHours()),minutes=pad(date.getMinutes()),seconds=pad(date.getSeconds());return`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`}const DEFAULT_CHALLENGE_TTL_SECONDS=300;export async function storeWebAuthnChallenge(userId,challenge,purpose,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000)),encodedChallenge=typeof challenge==="string"?challenge:Buffer.from(challenge).toString("base64url");await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();await db.insertInto("webauthn_challenges").values({user_id:userId,challenge:encodedChallenge,purpose,expires_at:expiresAt}).execute()}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Buffer.from(String(row.challenge),"base64url")}
@@ -1,156 +1,3 @@
1
- import { randomBytes } from "node:crypto";
2
- import { config } from "@stacksjs/config";
3
- import { db, sqlDateTime } from "@stacksjs/database";
4
- import { mail, template } from "@stacksjs/email";
5
- import { log } from "@stacksjs/logging";
6
- import { formatDate } from "@stacksjs/orm";
7
- import { makeHash, verifyHash } from "@stacksjs/security";
8
- import { sessionDestroyAll } from "../session-auth";
9
- import { revokeAllTokens } from "../tokens";
10
- function getTokenExpireMinutes() {
11
- return config.auth.passwordReset?.expire ?? 60;
12
- }
13
- function isWithinExpiry(row) {
14
- const explicit = row.expires_at;
15
- if (typeof explicit === "string" || explicit instanceof Date)
16
- return new Date(explicit).getTime() > Date.now();
17
- const created = row.created_at;
18
- if (typeof created === "string" || created instanceof Date) {
19
- const expireMinutes = getTokenExpireMinutes();
20
- return new Date(created).getTime() + expireMinutes * 60000 > Date.now();
21
- }
22
- return !1;
23
- }
24
- async function sendPasswordChangedNotification(userEmail) {
25
- const appName = config.app.name || "Stacks", supportEmail = config.app.supportEmail || config.email?.from?.address || "", changedAt = new Date().toLocaleString("en-US", {
26
- dateStyle: "full",
27
- timeStyle: "short"
28
- });
29
- try {
30
- const { html, text } = await template("password-changed", {
31
- subject: `Your ${appName} password has been changed`,
32
- variables: {
33
- changedAt,
34
- supportEmail
35
- }
36
- });
37
- if (!html && !text) {
38
- await mail.send({
39
- to: userEmail,
40
- subject: `Your ${appName} password has been changed`,
41
- text: `Your ${appName} password was changed on ${changedAt}.${supportEmail ? ` If this wasn't you, contact ${supportEmail}.` : ""}`
42
- });
43
- return;
44
- }
45
- await mail.send({
46
- to: userEmail,
47
- subject: `Your ${appName} password has been changed`,
48
- text,
49
- html
50
- });
51
- } catch (error) {
52
- console.error("[PasswordReset] Failed to send password changed notification:", error);
53
- }
54
- }
55
- export function passwordResets(email) {
56
- function generateResetToken() {
57
- return randomBytes(32).toString("hex");
58
- }
59
- async function createResetToken() {
60
- const token = generateResetToken(), hashedToken = await makeHash(token, { algorithm: "bcrypt" }), expireMinutes = getTokenExpireMinutes(), expiresAt = sqlDateTime(new Date(Date.now() + expireMinutes * 60000));
61
- await db.deleteFrom("password_resets").where("email", "=", email).execute();
62
- await db.insertInto("password_resets").values({
63
- email,
64
- token: hashedToken,
65
- expires_at: expiresAt
66
- }).executeTakeFirst();
67
- return token;
68
- }
69
- async function sendEmail() {
70
- if (!await db.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst())
71
- return;
72
- const token = await createResetToken(), expireMinutes = getTokenExpireMinutes(), appName = config.app.name || "Stacks", base = config.app.url ? `https://${config.app.url}` : `http://localhost:${process.env.PORT || "3000"}`, filled = (config.auth.passwordReset?.url ?? "/password/reset/{token}?email={email}").replace("{token}", token).replace("{email}", encodeURIComponent(email)), resetUrl = /^https?:\/\//.test(filled) ? filled : `${base}${filled.startsWith("/") ? "" : "/"}${filled}`;
73
- try {
74
- const { html, text } = await template("password-reset", {
75
- subject: `Reset Your ${appName} Password`,
76
- variables: {
77
- resetUrl,
78
- expireMinutes
79
- }
80
- });
81
- if (!html && !text)
82
- throw Error("password-reset template missing or rendered empty");
83
- await mail.send({
84
- to: email,
85
- subject: `Reset Your ${appName} Password`,
86
- text,
87
- html
88
- });
89
- } catch (templateError) {
90
- const msg = templateError instanceof Error ? templateError.message : String(templateError);
91
- console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);
92
- await mail.send({
93
- to: email,
94
- subject: `Reset Your ${appName} Password`,
95
- text: `Reset your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{formatDate}from"@stacksjs/orm";import{makeHash,verifyHash}from"@stacksjs/security";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(typeof explicit==="string"||explicit instanceof Date)return new Date(explicit).getTime()>Date.now();const created=row.created_at;if(typeof created==="string"||created instanceof Date){const expireMinutes=getTokenExpireMinutes();return new Date(created).getTime()+expireMinutes*60000>Date.now()}return!1}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.send({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.send({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes(),expiresAt=sqlDateTime(new Date(Date.now()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt}).executeTakeFirst();return token}async function sendEmail(){if(!await db.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())return;const token=await createResetToken(),expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.passwordReset?.url??"/password/reset/{token}?email={email}").replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;try{const{html,text}=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});if(!html&&!text)throw Error("password-reset template missing or rendered empty");await mail.send({to:email,subject:`Reset Your ${appName} Password`,text,html})}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);await mail.send({to:email,subject:`Reset Your ${appName} Password`,text:`Reset your password by visiting: ${resetUrl}
96
2
 
97
- This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`
98
- });
99
- }
100
- }
101
- async function verifyToken(token) {
102
- const result = await db.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
103
- if (!result)
104
- return !1;
105
- if (!isWithinExpiry(result)) {
106
- await db.deleteFrom("password_resets").where("email", "=", email).execute();
107
- return !1;
108
- }
109
- const hashedToken = result.token;
110
- return await verifyHash(token, hashedToken);
111
- }
112
- async function resetPassword(token, newPassword) {
113
- const result = await db.transaction(async (rawTrx) => {
114
- const trx = rawTrx, resetRecord = await trx.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
115
- if (!resetRecord)
116
- return { success: !1, message: "Invalid or expired reset token" };
117
- if (!isWithinExpiry(resetRecord)) {
118
- await trx.deleteFrom("password_resets").where("email", "=", email).execute();
119
- return { success: !1, message: "This password reset link has expired. Please request a new one." };
120
- }
121
- const hashedToken = resetRecord.token;
122
- if (!await verifyHash(token, hashedToken))
123
- return { success: !1, message: "Invalid or expired reset token" };
124
- const user = await trx.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst();
125
- if (!user)
126
- return { success: !1, message: "Invalid or expired reset token" };
127
- const hashedPassword = await makeHash(newPassword, { algorithm: "bcrypt" });
128
- try {
129
- await trx.updateTable("users").set({ password: hashedPassword, password_changed_at: formatDate(new Date) }).where("email", "=", email).executeTakeFirst();
130
- } catch (err) {
131
- const message = err instanceof Error ? err.message : String(err);
132
- if (/password_changed_at|no such column|unknown column/i.test(message)) {
133
- log.warn("[PasswordReset] password_changed_at column missing \u2014 run `buddy migrate`; resetting without the credential-version stamp");
134
- await trx.updateTable("users").set({ password: hashedPassword }).where("email", "=", email).executeTakeFirst();
135
- } else
136
- throw err;
137
- }
138
- await trx.deleteFrom("password_resets").where("email", "=", email).execute();
139
- return { success: !0, userId: Number(user.id) };
140
- });
141
- if (result.success) {
142
- await revokeAllTokens(result.userId);
143
- await sessionDestroyAll(result.userId);
144
- sendPasswordChangedNotification(email).catch((err) => {
145
- console.error("[PasswordReset] Failed to send notification:", err);
146
- });
147
- return { success: !0 };
148
- }
149
- return result;
150
- }
151
- return {
152
- sendEmail,
153
- verifyToken,
154
- resetPassword
155
- };
156
- }
3
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`})}}async function verifyToken(token){const result=await db.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,resetRecord=await trx.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const user=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});try{await trx.updateTable("users").set({password:hashedPassword,password_changed_at:formatDate(new Date)}).where("email","=",email).executeTakeFirst()}catch(err){const message=err instanceof Error?err.message:String(err);if(/password_changed_at|no such column|unknown column/i.test(message)){log.warn("[PasswordReset] password_changed_at column missing \u2014 run `buddy migrate`; resetting without the credential-version stamp");await trx.updateTable("users").set({password:hashedPassword}).where("email","=",email).executeTakeFirst()}else throw err}await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!0,userId:Number(user.id)}});if(result.success){await revokeAllTokens(result.userId);await sessionDestroyAll(result.userId);sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}