@stacksjs/auth 0.72.38 → 0.72.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cookie.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a `+"cookie name \u2014 set config.auth.cookie.name instead (stacksjs/stacks#2236).")}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS \u2014 the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}return}
1
+ import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a cookie name - set config.auth.cookie.name instead (stacksjs/stacks#2236).`)}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS - the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}return}
@@ -1,3 +1,3 @@
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";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}
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 - 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";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
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){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};
@@ -1,3 +1,3 @@
1
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.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)}}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}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});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=`Reset your password by visiting: ${resetUrl}
2
2
 
3
- This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}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}}
3
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}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 - 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}}
@@ -15,7 +15,7 @@ export declare function seedDefaultRoles(): Promise<SeedDefaultRolesResult>;
15
15
  * predicates (`isAdmin`, `isDev`, `isClient`) check these names verbatim,
16
16
  * so renaming them in a project effectively unbinds the composable from
17
17
  * its defaults — that's fine, but obvious to readers.
18
- * @defaultValue `[ { name: 'admin', guard_name: 'web', description: 'Full access. Sees every dashboard surface, every model, every infra control.', }, { name: 'dev', guard_name: 'web', description: 'Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management.', }, { name: 'client', guard_name: 'web', description: 'End user / client. Sees content, orders, profile, billing — no dev tools, no infra surfaces.', }, ]`
18
+ * @defaultValue `[ { name: 'admin', guard_name: 'web', description: 'Full access. Sees every dashboard surface, every model, every infra control.', }, { name: 'dev', guard_name: 'web', description: 'Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management.', }, { name: 'client', guard_name: 'web', description: 'End user / client. Sees content, orders, profile, billing - no dev tools, no infra surfaces.', }, ]`
19
19
  */
20
20
  export declare const DEFAULT_ROLE_PACKS: Array<{ name: string, guard_name: string, description: string }>;
21
21
  export declare interface SeedDefaultRolesResult {
package/dist/rbac-seed.js CHANGED
@@ -1 +1 @@
1
- import{createRole,findRole}from"./rbac";export const DEFAULT_ROLE_PACKS=[{name:"admin",guard_name:"web",description:"Full access. Sees every dashboard surface, every model, every infra control."},{name:"dev",guard_name:"web",description:"Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management."},{name:"client",guard_name:"web",description:"End user / client. Sees content, orders, profile, billing \u2014 no dev tools, no infra surfaces."}];export async function seedDefaultRoles(){const created=[],skipped=[];for(const pack of DEFAULT_ROLE_PACKS){if(await findRole(pack.name,pack.guard_name)){skipped.push({name:pack.name,guard_name:pack.guard_name,reason:"already_exists"});continue}const role=await createRole(pack.name,pack.guard_name,pack.description);created.push(role)}return{created,skipped}}
1
+ import{createRole,findRole}from"./rbac";export const DEFAULT_ROLE_PACKS=[{name:"admin",guard_name:"web",description:"Full access. Sees every dashboard surface, every model, every infra control."},{name:"dev",guard_name:"web",description:"Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management."},{name:"client",guard_name:"web",description:"End user / client. Sees content, orders, profile, billing - no dev tools, no infra surfaces."}];export async function seedDefaultRoles(){const created=[],skipped=[];for(const pack of DEFAULT_ROLE_PACKS){if(await findRole(pack.name,pack.guard_name)){skipped.push({name:pack.name,guard_name:pack.guard_name,reason:"already_exists"});continue}const role=await createRole(pack.name,pack.guard_name,pack.description);created.push(role)}return{created,skipped}}
@@ -1 +1 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){log.debug("[auth] Session destroyed");try{await db.deleteFrom("sessions").where("id","=",sessionId).execute()}catch(err){log.debug(`[auth] Session destroy failed: ${err.message}`)}}export async function sessionDestroyAll(userId){try{await db.deleteFrom("sessions").where("user_id","=",userId).execute()}catch(err){const message=err instanceof Error?err.message:String(err);if(message.includes("sessions")&&/no such table|does not exist|doesn't exist/i.test(message))return;throw err}}export async function sessionUser(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch \u2014 rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;const newExpiry=new Date(Date.now()+ttlMs);await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId).execute();return!0}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
1
+ import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){log.debug("[auth] Session destroyed");try{await db.deleteFrom("sessions").where("id","=",sessionId).execute()}catch(err){log.debug(`[auth] Session destroy failed: ${err.message}`)}}export async function sessionDestroyAll(userId){try{await db.deleteFrom("sessions").where("user_id","=",userId).execute()}catch(err){const message=err instanceof Error?err.message:String(err);if(message.includes("sessions")&&/no such table|does not exist|doesn't exist/i.test(message))return;throw err}}export async function sessionUser(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;const newExpiry=new Date(Date.now()+ttlMs);await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId).execute();return!0}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
package/dist/tokens.js CHANGED
@@ -23,7 +23,7 @@ import process from"node:process";import{createHash,randomBytes}from"node:crypto
23
23
  VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
24
24
  `,[userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await db.unsafe(`
25
25
  SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
26
- `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token \u2014 inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:new Date(row.updated_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await db.unsafe(`
26
+ `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:new Date(row.updated_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await db.unsafe(`
27
27
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
28
28
  VALUES ($1, $2, false, $3, ${appNow()})
29
29
  `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await db.unsafe(`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.38",
5
+ "version": "0.72.39",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "better-dx": "^0.2.24",
64
- "@stacksjs/error-handling": "0.72.38",
65
- "@stacksjs/router": "0.72.38"
64
+ "@stacksjs/error-handling": "0.72.39",
65
+ "@stacksjs/router": "0.72.39"
66
66
  }
67
67
  }