@stacksjs/auth 0.75.238 → 0.75.240

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,3 +1,3 @@
1
1
  import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{normalizeAuthEmail}from"./credential-user";import{authLinkBase}from"./link-url";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - 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,email){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${normalizeAuthEmail(email)}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,email,token,storedHash){const payload=`${userId}:${normalizeAuthEmail(email)}:${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=authLinkBase(),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){await deliverAfterCommit(await prepareVerificationEmail(user))}async function prepareVerificationEmail(user){const expiryMinutes=getExpiryMinutes(),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+expiryMinutes*60*1000),prepared=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||typeof owner.email!=="string"||!owner.email)throw Error("[auth] Email verification owner no longer exists or has no email.");const{token,hash}=generateVerificationToken(user.id,owner.email);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),created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("email_verifications").where("user_id","=",user.id).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hash)throw Error("[auth] Email verification could not replace the token.");return{currentUser:{id:Number(owner.id),email:owner.email,name:typeof owner.name==="string"?owner.name:user.name},token}});return()=>deliverVerificationEmail(prepared.currentUser,prepared.token,expiryMinutes)}async function deliverAfterCommit(deliver){if(!enqueueAfterCommit(deliver))await deliver()}async function deliverVerificationEmail(user,token,expiryMinutes){const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
2
2
 
3
- This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){return db.transaction(async()=>{const record=await db.primary.selectFrom("email_verifications").where("user_id","=",userId).select(["id","token","expires_at"]).executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const observed=db.deleteFrom("email_verifications").where("user_id","=",userId).where("id","=",record.id).where("token","=",record.token),claim=record.expires_at==null?observed.whereNull("expires_at"):observed.where("expires_at","=",record.expires_at),expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||Date.now()>=expiresAt.getTime()){await claim.execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}let owner=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();const user=await owner.executeTakeFirst();if(!user||typeof user.email!=="string"||!user.email)return{success:!1,message:"Invalid verification link."};if(!verifyToken(userId,user.email,token,record.token))return{success:!1,message:"Invalid verification link."};if(mutationCount(await claim.execute())!==1)return{success:!1,message:"Invalid verification link."};if(Date.now()>=expiresAt.getTime())return{success:!1,message:"Verification link has expired. Please request a new one."};const updated=await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).execute();if(mutationCount(updated)!==1&&user.email_verified_at==null)throw Error("[auth] Email verification could not update the user.");return{success:!0,message:"Email verified successfully."}})}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const outcome=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)return{result:{success:!1,message:"User not found."}};if(isEmailVerified(owner))return{result:{success:!1,message:"Email is already verified."}};let existingQuery=db.primary.selectFrom("email_verifications").where("user_id","=",user.id).select(["created_at"]);if(getDatabaseDialect()!=="sqlite")existingQuery=existingQuery.lockForUpdate();const existing=await existingQuery.executeTakeFirst();if(existing){const createdAt=parseSqlDateTime(existing.created_at),secondsSince=createdAt?(Date.now()-createdAt.getTime())/1000:Number.NaN;if(Number.isNaN(secondsSince))return{result:{success:!1,message:"Please wait a moment before requesting another verification email."}};if(secondsSince<60)return{result:{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}}const deliver=await prepareVerificationEmail(user);return{result:{success:!0,message:"Verification email sent."},deliver}},getDatabaseDialect()==="sqlite"?{retries:3}:void 0);if(outcome.deliver)await deliverAfterCommit(outcome.deliver);return outcome.result}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
3
+ This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){return db.transaction(async()=>{const record=await db.primary.selectFrom("email_verifications").where("user_id","=",userId).select(["id","token","expires_at"]).executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const observed=db.deleteFrom("email_verifications").where("user_id","=",userId).where("id","=",record.id).where("token","=",record.token),claim=record.expires_at==null?observed.whereNull("expires_at"):observed.where("expires_at","=",record.expires_at),expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||Date.now()>=expiresAt.getTime()){await claim.execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}let owner=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();const user=await owner.executeTakeFirst();if(!user||typeof user.email!=="string"||!user.email)return{success:!1,message:"Invalid verification link."};if(!verifyToken(userId,user.email,token,record.token))return{success:!1,message:"Invalid verification link."};if(mutationCount(await claim.execute())!==1)return{success:!1,message:"Invalid verification link."};if(Date.now()>=expiresAt.getTime())return{success:!1,message:"Verification link has expired. Please request a new one."};const updated=await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).execute();if(mutationCount(updated)!==1&&user.email_verified_at==null)throw Error("[auth] Email verification could not update the user.");return{success:!0,message:"Email verified successfully."}})}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const outcome=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)return{result:{success:!1,message:"User not found."}};if(isEmailVerified(owner))return{result:{success:!1,message:"Email is already verified."}};let existingQuery=db.primary.selectFrom("email_verifications").where("user_id","=",user.id).select(["created_at"]);if(getDatabaseDialect()!=="sqlite")existingQuery=existingQuery.lockForUpdate();const existing=await existingQuery.executeTakeFirst();if(existing){const createdAt=parseSqlDateTime(existing.created_at),secondsSince=createdAt?(Date.now()-createdAt.getTime())/1000:Number.NaN;if(Number.isNaN(secondsSince))return{result:{success:!1,message:"Please wait a moment before requesting another verification email."}};if(secondsSince<60)return{result:{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}}const deliver=await prepareVerificationEmail(user);return{result:{success:!0,message:"Verification email sent."},deliver}},getDatabaseDialect()==="sqlite"?{sqliteMode:"immediate",retries:3}:void 0);if(outcome.deliver)await deliverAfterCommit(outcome.deliver);return outcome.result}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
@@ -1,3 +1,3 @@
1
- import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{findAuthUserRowByEmail,normalizeAuthEmail}from"./credential-user";import{authLinkBase}from"./link-url";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/",isLocal=(value)=>{if(typeof value!=="string"||!value.startsWith("/"))return!1;try{return new URL(value,"https://stacks.invalid").origin==="https://stacks.invalid"}catch{return!1}};return isLocal(candidate)?candidate:isLocal(fallback)?fallback:"/"}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).whereNotNull("verified_at").select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return authLinkBase()}export async function sendMagicLink(email,options={}){const normalized=normalizeAuthEmail(email),rateKey=`magic:${normalized}`;if(await RateLimiter.isRateLimited(rateKey))return;await RateLimiter.recordFailedAttempt(rateKey);let user=await findAuthUserRowByEmail(normalized);if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await findAuthUserRowByEmail(normalized)}if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==normalized)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),token=hashToken(raw),expiresAt=sqlDateTime(new Date(Date.now()+ttl*60000));await db.deleteFrom("magic_link_tokens").where("email","=",normalized).whereNull("consumed_at").execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:userId,token,expires_at:expiresAt,redirect_to:safeRedirect(options.redirectTo),site_id:options.siteId??null,created_at:sqlDateTime(),updated_at:sqlDateTime()}).execute();const stored=await db.primary.selectFrom("magic_link_tokens").where("email","=",normalized).whereNull("consumed_at").select(["token","user_id"]).execute();if(stored.length!==1||stored[0]?.token!==token||String(stored[0]?.user_id)!==String(userId))throw Error("[auth] Magic-link credential could not be stored.");return{raw,ttl}},getDatabaseDialect()==="sqlite"?{retries:3}:void 0);if(!issued)return;const deliver=()=>deliverMagicLink(normalized,issued.raw,issued.ttl,options.siteId);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverMagicLink(normalized,raw,ttl,siteId){const base=await linkBase(siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
1
+ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{findAuthUserRowByEmail,normalizeAuthEmail}from"./credential-user";import{authLinkBase}from"./link-url";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/",isLocal=(value)=>{if(typeof value!=="string"||!value.startsWith("/"))return!1;try{return new URL(value,"https://stacks.invalid").origin==="https://stacks.invalid"}catch{return!1}};return isLocal(candidate)?candidate:isLocal(fallback)?fallback:"/"}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).whereNotNull("verified_at").select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return authLinkBase()}export async function sendMagicLink(email,options={}){const normalized=normalizeAuthEmail(email),rateKey=`magic:${normalized}`;if(await RateLimiter.isRateLimited(rateKey))return;await RateLimiter.recordFailedAttempt(rateKey);let user=await findAuthUserRowByEmail(normalized);if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await findAuthUserRowByEmail(normalized)}if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==normalized)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),token=hashToken(raw),expiresAt=sqlDateTime(new Date(Date.now()+ttl*60000));await db.deleteFrom("magic_link_tokens").where("email","=",normalized).whereNull("consumed_at").execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:userId,token,expires_at:expiresAt,redirect_to:safeRedirect(options.redirectTo),site_id:options.siteId??null,created_at:sqlDateTime(),updated_at:sqlDateTime()}).execute();const stored=await db.primary.selectFrom("magic_link_tokens").where("email","=",normalized).whereNull("consumed_at").select(["token","user_id"]).execute();if(stored.length!==1||stored[0]?.token!==token||String(stored[0]?.user_id)!==String(userId))throw Error("[auth] Magic-link credential could not be stored.");return{raw,ttl}},getDatabaseDialect()==="sqlite"?{sqliteMode:"immediate",retries:3}:void 0);if(!issued)return;const deliver=()=>deliverMagicLink(normalized,issued.raw,issued.ttl,options.siteId);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverMagicLink(normalized,raw,ttl,siteId){const base=await linkBase(siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
2
2
 
3
3
  This link works once and expires in ${ttl} minutes. If you didn't request it, ignore this email.`})}catch(error){log.error(`Magic-link email to ${normalized} failed: ${error.message}`)}}export async function consumeMagicLink(raw){const result=await withMagicLink(raw,async(grant)=>grant);return result.ok?result.value:result}export async function withMagicLink(raw,complete){if(!raw||raw.length>255)return{ok:!1,reason:"invalid"};const hashed=hashToken(raw),pending=await db.primary.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","consumed_at","expires_at"]).executeTakeFirst();if(!pending)return{ok:!1,reason:"invalid"};if(pending.consumed_at!=null)return{ok:!1,reason:"used"};if((parseSqlDateTime(pending.expires_at)?.getTime()??0)<=Date.now())return{ok:!1,reason:"expired"};const outcome=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",pending.user_id).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=pending.user_id==null?void 0:await ownerQuery.executeTakeFirst(),grant=await claimMagicLink(hashed);if(!grant.ok)return{result:grant};if(!owner||String(owner.id)!==String(grant.userId)||String(owner.email).trim().toLowerCase()!==grant.email.trim().toLowerCase())return{result:{ok:!1,reason:"no-user"}};try{return{result:{ok:!0,value:await db.transaction(()=>complete(grant))}}}catch(error){return{error}}});if("error"in outcome)throw outcome.error;return outcome.result}async function claimMagicLink(hashed){const now=sqlDateTime(new Date),claim=await db.updateTable("magic_link_tokens").set({consumed_at:now,updated_at:now}).where("token","=",hashed).whereNull("consumed_at").where("expires_at",">",now).execute(),updated=mutationCount(claim),row=await db.primary.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","email","expires_at","consumed_at","redirect_to"]).executeTakeFirst();if(!row)return{ok:!1,reason:"invalid"};if(updated<1){if(!row.consumed_at&&(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now())return{ok:!1,reason:"expired"};return{ok:!1,reason:"used"}}if((parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now())return{ok:!1,reason:"expired"};if(!row.user_id)return{ok:!1,reason:"no-user"};return{ok:!0,userId:Number(row.user_id),email:row.email,redirectTo:safeRedirect(row.redirect_to)}}export async function pruneMagicLinkTokens(olderThanDays=7){const cutoff=sqlDateTime(new Date(Date.now()-olderThanDays*86400000));await db.deleteFrom("magic_link_tokens").where("expires_at","<",cutoff).execute()}
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";export{changePassword,revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId,expiresAtOverride){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();if(!await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(expiresAtOverride??new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0))return;return{token,expiresAt:expiresAtOverride??new Date(Date.now()+expireMinutes*60000)}}async function sendEmail(options={}){const purpose=options.purpose==="setup"?"setup":"reset";if(options.expiresAt!==void 0&&(!(options.expiresAt instanceof Date)||!Number.isFinite(options.expiresAt.getTime())||options.expiresAt.getTime()<=Date.now()))throw TypeError("[PasswordReset] expiresAt must be a future Date.");const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await createResetToken(userId,options.expiresAt);if(!issued)return;const delivery={purpose,userId,email,token:issued.token,url:await resetUrlFor(issued.token),expiresAt:issued.expiresAt},deliver=options.deliver?async()=>{await options.deliver(delivery)}:()=>deliverResetEmail(delivery);if(!enqueueAfterCommit(deliver))await deliver()}async function resetUrlFor(token){const base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email));return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}async function deliverResetEmail(delivery){const resetUrl=delivery.url,expireMinutes=Math.max(1,Math.round((delivery.expiresAt.getTime()-Date.now())/60000)),appName=config.app.name||"Stacks",setup=delivery.purpose==="setup",subject=setup?`Set Your ${appName} Password`:`Reset Your ${appName} Password`;let html,text;try{const rendered=await template("password-reset",{subject,variables:{resetUrl,expireMinutes,purpose:delivery.purpose}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=setup?`Choose your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";export{changePassword,revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId,expiresAtOverride){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();if(!await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(expiresAtOverride??new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{sqliteMode:"immediate",retries:3}:void 0))return;return{token,expiresAt:expiresAtOverride??new Date(Date.now()+expireMinutes*60000)}}async function sendEmail(options={}){const purpose=options.purpose==="setup"?"setup":"reset";if(options.expiresAt!==void 0&&(!(options.expiresAt instanceof Date)||!Number.isFinite(options.expiresAt.getTime())||options.expiresAt.getTime()<=Date.now()))throw TypeError("[PasswordReset] expiresAt must be a future Date.");const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await createResetToken(userId,options.expiresAt);if(!issued)return;const delivery={purpose,userId,email,token:issued.token,url:await resetUrlFor(issued.token),expiresAt:issued.expiresAt},deliver=options.deliver?async()=>{await options.deliver(delivery)}:()=>deliverResetEmail(delivery);if(!enqueueAfterCommit(deliver))await deliver()}async function resetUrlFor(token){const base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email));return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}async function deliverResetEmail(delivery){const resetUrl=delivery.url,expireMinutes=Math.max(1,Math.round((delivery.expiresAt.getTime()-Date.now())/60000)),appName=config.app.name||"Stacks",setup=delivery.purpose==="setup",subject=setup?`Set Your ${appName} Password`:`Reset Your ${appName} Password`;let html,text;try{const rendered=await template("password-reset",{subject,variables:{resetUrl,expireMinutes,purpose:delivery.purpose}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=setup?`Choose your password by visiting: ${resetUrl}
2
2
 
3
3
  This link expires in ${expireMinutes} minutes.`:`Reset your password by visiting: ${resetUrl}
4
4
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.238",
5
+ "version": "0.75.240",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -59,19 +59,19 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@stacksjs/bun-router": "^0.1.24",
62
- "@stacksjs/cache": "0.75.238",
63
- "@stacksjs/config": "0.75.238",
64
- "@stacksjs/database": "0.75.238",
65
- "@stacksjs/email": "0.75.238",
66
- "@stacksjs/env": "0.75.238",
67
- "@stacksjs/error-handling": "0.75.238",
68
- "@stacksjs/logging": "0.75.238",
69
- "@stacksjs/orm": "0.75.238",
70
- "@stacksjs/path": "0.75.238",
71
- "@stacksjs/router": "0.75.238",
72
- "@stacksjs/security": "0.75.238",
73
- "@stacksjs/socials": "0.75.238",
74
- "@stacksjs/storage": "0.75.238",
62
+ "@stacksjs/cache": "0.75.240",
63
+ "@stacksjs/config": "0.75.240",
64
+ "@stacksjs/database": "0.75.240",
65
+ "@stacksjs/email": "0.75.240",
66
+ "@stacksjs/env": "0.75.240",
67
+ "@stacksjs/error-handling": "0.75.240",
68
+ "@stacksjs/logging": "0.75.240",
69
+ "@stacksjs/orm": "0.75.240",
70
+ "@stacksjs/path": "0.75.240",
71
+ "@stacksjs/router": "0.75.240",
72
+ "@stacksjs/security": "0.75.240",
73
+ "@stacksjs/socials": "0.75.240",
74
+ "@stacksjs/storage": "0.75.240",
75
75
  "@stacksjs/ts-auth": "^0.4.7",
76
76
  "ts-qr-codes": "^0.1.8"
77
77
  },