@stacksjs/auth 0.75.230 → 0.75.236

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 +1,8 @@
1
- import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute();if(mutationCount(stamped)!==1)throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).select(["id","email","name","password"]).executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export 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.sendOrFail({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.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}
1
+ import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";async function mysqlPasswordStampMatches(userId,stamp){const columns=await db.primary.unsafe(`
2
+ SELECT DATA_TYPE AS kind, DATETIME_PRECISION AS stamp_precision
3
+ FROM information_schema.COLUMNS
4
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_changed_at'
5
+ `).execute(),column=columns[0];if(columns.length!==1||!column||!["datetime","timestamp"].includes(String(column.kind))||!/^[0-6]$/.test(String(column.stamp_precision)))throw Error("[auth] Cannot verify password_changed_at temporal precision.");const precision=Number(column.stamp_precision),rows=await db.primary.unsafe(`
6
+ SELECT (password_changed_at = CAST(? AS DATETIME(${precision}))) AS stamp_matches
7
+ FROM users WHERE id = ?
8
+ `,[stamp,userId]).execute();return rows.length===1&&(rows[0]?.stamp_matches===!0||rows[0]?.stamp_matches===1)}export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute(),changed=mutationCount(stamped);if(changed!==1&&!(changed===0&&getDatabaseDialect()==="mysql"&&await mysqlPasswordStampMatches(userId,stampValue)))throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).select(["id","email","name","password"]).executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export 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.sendOrFail({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.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}
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.230",
5
+ "version": "0.75.236",
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.230",
63
- "@stacksjs/config": "0.75.230",
64
- "@stacksjs/database": "0.75.230",
65
- "@stacksjs/email": "0.75.230",
66
- "@stacksjs/env": "0.75.230",
67
- "@stacksjs/error-handling": "0.75.230",
68
- "@stacksjs/logging": "0.75.230",
69
- "@stacksjs/orm": "0.75.230",
70
- "@stacksjs/path": "0.75.230",
71
- "@stacksjs/router": "0.75.230",
72
- "@stacksjs/security": "0.75.230",
73
- "@stacksjs/socials": "0.75.230",
74
- "@stacksjs/storage": "0.75.230",
62
+ "@stacksjs/cache": "0.75.236",
63
+ "@stacksjs/config": "0.75.236",
64
+ "@stacksjs/database": "0.75.236",
65
+ "@stacksjs/email": "0.75.236",
66
+ "@stacksjs/env": "0.75.236",
67
+ "@stacksjs/error-handling": "0.75.236",
68
+ "@stacksjs/logging": "0.75.236",
69
+ "@stacksjs/orm": "0.75.236",
70
+ "@stacksjs/path": "0.75.236",
71
+ "@stacksjs/router": "0.75.236",
72
+ "@stacksjs/security": "0.75.236",
73
+ "@stacksjs/socials": "0.75.236",
74
+ "@stacksjs/storage": "0.75.236",
75
75
  "@stacksjs/ts-auth": "^0.4.7",
76
76
  "ts-qr-codes": "^0.1.8"
77
77
  },