@stacksjs/auth 0.75.30 → 0.75.31
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/credential-user.d.ts +2 -0
- package/dist/credential-user.js +1 -1
- package/dist/email-verification.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/magic-link.js +1 -1
- package/dist/password/reset.d.ts +1 -1
- package/dist/password/reset.js +2 -2
- package/dist/session-auth.js +1 -1
- package/dist/socials.js +1 -1
- package/dist/tokens.js +12 -3
- package/package.json +13 -13
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { User } from '@stacksjs/orm';
|
|
2
2
|
export declare function normalizeAuthEmail(value: string): string;
|
|
3
|
+
/** Resolve the stored row without requiring the ORM model runtime to be booted. */
|
|
4
|
+
export declare function findAuthUserRowByEmail(value: string): Promise<Record<string, unknown> | undefined>;
|
|
3
5
|
/** Resolve an email without making existing mixed-case accounts unloginable. */
|
|
4
6
|
export declare function findAuthUserByEmail(value: string): Promise<UserModel | undefined>;
|
|
5
7
|
declare type UserModel = NonNullable<Awaited<ReturnType<typeof User.find>>>;
|
package/dist/credential-user.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db,sql}from"@stacksjs/database/runtime";import{User}from"@stacksjs/orm";export function normalizeAuthEmail(value){return value.trim().toLowerCase()}export async function
|
|
1
|
+
import{db,sql}from"@stacksjs/database/runtime";import{User}from"@stacksjs/orm";export function normalizeAuthEmail(value){return value.trim().toLowerCase()}export async function findAuthUserRowByEmail(value){const email=normalizeAuthEmail(value);if(!email)return;const definition=typeof User.getDefinition==="function"?User.getDefinition():{},table=definition.table||"users";if(!/^[A-Z_][A-Z0-9_]*$/i.test(table))throw TypeError("Auth user table must be a plain SQL identifier.");const softDelete=definition.traits?.useSoftDeletes?sql.raw(" AND deleted_at IS NULL"):sql.raw(""),exactQuery=sql`SELECT * FROM ${sql.raw(table)} WHERE email = ${email}${softDelete} LIMIT 1`,exact=await db.primary.unsafe(exactQuery.sql,exactQuery.parameters);if(exact[0])return exact[0];const fallbackQuery=sql`SELECT * FROM ${sql.raw(table)} WHERE LOWER(email) = ${email}${softDelete} LIMIT 1`;return(await db.primary.unsafe(fallbackQuery.sql,fallbackQuery.parameters))[0]}export async function findAuthUserByEmail(value){const row=await findAuthUserRowByEmail(value);return row?await User.make(row):void 0}
|
|
@@ -1,3 +1,3 @@
|
|
|
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{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){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=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
|
|
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).selectAll().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).selectAll().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."}}
|
|
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).selectAll().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).selectAll();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};
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { SeedDefaultRolesResult } from './rbac-seed';
|
|
|
2
2
|
export * from './authentication';
|
|
3
3
|
export * from './authenticator';
|
|
4
4
|
export * from './client';
|
|
5
|
+
export * from './credential-user';
|
|
5
6
|
export * from './middleware';
|
|
6
7
|
export * from './oauth-authorization';
|
|
7
8
|
export * from './oauth-authorization-endpoint';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";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*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
1
|
+
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";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*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
package/dist/magic-link.js
CHANGED
|
@@ -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{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=email
|
|
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}
|
|
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()}
|
package/dist/password/reset.d.ts
CHANGED
package/dist/password/reset.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
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{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}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()}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)
|
|
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{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";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()}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(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){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();return 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(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).selectAll().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)?token:void 0}async function sendEmail(){const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const token=await createResetToken(userId);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",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)),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.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).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;let userQuery=trx.selectFrom("users").where("
|
|
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.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const owner=await findAuthUserRowByEmail(email);if(!owner)return{success:!1,message:"Invalid or expired reset token"};const ownerId=Number(owner.id);if(!Number.isSafeInteger(ownerId)||ownerId<=0)return{success:!1,message:"Invalid or expired reset token"};const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("id","=",ownerId).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user||normalizeAuthEmail(String(user.email??""))!==email)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.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 hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeOAuthSubjectAuthorizationState("users",Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
|
package/dist/session-auth.js
CHANGED
|
@@ -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,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";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&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.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=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),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);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{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();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}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`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId){
|
|
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,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";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&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.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=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),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);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{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();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}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`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}export async function sessionUser(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
|
package/dist/socials.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function defaultStore(){return{async findLink(provider,providerUserId){const row=await db.selectFrom("social_accounts").select(["user_id"]).where("provider","=",provider).where("provider_user_id","=",providerUserId).executeTakeFirst();return row?{userId:Number(row.user_id)}:void 0},async createLink(link){await db.insertInto("social_accounts").values({user_id:link.userId,provider:link.provider,provider_user_id:link.providerUserId,provider_email:link.providerEmail,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()},async findUserIdByEmail(email){const
|
|
1
|
+
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{findAuthUserByEmail}from"./credential-user";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function defaultStore(){return{async findLink(provider,providerUserId){const row=await db.selectFrom("social_accounts").select(["user_id"]).where("provider","=",provider).where("provider_user_id","=",providerUserId).executeTakeFirst();return row?{userId:Number(row.user_id)}:void 0},async createLink(link){await db.insertInto("social_accounts").values({user_id:link.userId,provider:link.provider,provider_user_id:link.providerUserId,provider_email:link.providerEmail,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()},async findUserIdByEmail(email){const user=await findAuthUserByEmail(email);return user?Number(user.id):void 0},async createUser(attrs){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),created=await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id)return Number(created.id);const row=await db.insertInto("users").values({name:attrs.name,email:attrs.email,password,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).executeTakeFirst();return Number(row?.insertId??0)}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore()){const providerUserId=String(identity.id??"").trim();if(!provider||!providerUserId)throw TypeError("[auth/socials] a provider name and provider user id are required.");const existing=await store.findLink(provider,providerUserId);if(existing)return{userId:existing.userId,createdUser:!1,linked:!1};const matching=configuredMatching();if(matching==="refuse")throw new SocialSignInRefusedError("no-linked-account",`No ${provider} account is linked here. Sign in with your password, then connect ${provider} from your account settings.`);const email=identity.email?.trim().toLowerCase()||null,displayName=identity.name?.trim()||identity.nickname?.trim()||(email??`${provider} user`);if(matching==="link"&&email){const matchedUserId=await store.findUserIdByEmail(email);if(matchedUserId!==void 0){if(identity.emailVerified!==!0)throw new SocialSignInRefusedError("unverified-provider-email",`${provider} did not verify ${email}, and an account with that address already exists. Sign in with your password, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email});return{userId:matchedUserId,createdUser:!1,linked:!0}}}if(!email)throw new SocialSignInRefusedError("no-email-to-create-with",`${provider} shared no email address, so an account cannot be created. Grant the email permission at ${provider}, or register with an email first.`);if(matching==="create"&&await store.findUserIdByEmail(email)!==void 0)throw new SocialSignInRefusedError("email-already-registered",`An account with ${email} already exists. Sign in with your password, then connect ${provider} from your account settings.`);const userId=await store.createUser({name:displayName,email});await store.createLink({userId,provider,providerUserId,providerEmail:email});return{userId,createdUser:!0,linked:!0}}
|
package/dist/tokens.js
CHANGED
|
@@ -25,6 +25,9 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
25
25
|
WHERE t.token = ${param(1)}
|
|
26
26
|
AND t.revoked = ${boolFalse}
|
|
27
27
|
AND (t.expires_at IS NULL OR t.expires_at > ${appNow()})
|
|
28
|
+
AND (t.tokenable_type <> ${param(2)} OR EXISTS (
|
|
29
|
+
SELECT 1 FROM users u WHERE u.id = t.tokenable_id
|
|
30
|
+
))
|
|
28
31
|
AND (t.oauth_grant_id IS NULL OR (
|
|
29
32
|
g.id IS NOT NULL
|
|
30
33
|
AND c.revoked = ${boolFalse}
|
|
@@ -37,7 +40,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
37
40
|
AND (g.workspace_id = t.workspace_id OR (g.workspace_id IS NULL AND t.workspace_id IS NULL))
|
|
38
41
|
))
|
|
39
42
|
LIMIT 1
|
|
40
|
-
`,[hashedToken]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
|
|
43
|
+
`,[hashedToken,DEFAULT_TOKENABLE_TYPE]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
|
|
41
44
|
SELECT id FROM oauth_clients
|
|
42
45
|
WHERE ${options.clientId===void 0?`personal_access_client = ${boolTrue}`:`id = ${param(1)}`}
|
|
43
46
|
AND revoked = ${boolFalse} LIMIT 1
|
|
@@ -55,7 +58,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
55
58
|
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
|
|
56
59
|
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
57
60
|
VALUES (?, ?, 0, ?, ${appNow()})
|
|
58
|
-
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
|
|
61
|
+
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const ownerType=param(bindings.length+1);bindings.push(DEFAULT_TOKENABLE_TYPE);const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
|
|
59
62
|
SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.oauth_grant_id, t.name, t.scopes
|
|
60
63
|
FROM oauth_refresh_tokens r
|
|
61
64
|
JOIN oauth_access_tokens t ON r.access_token_id = t.id
|
|
@@ -65,6 +68,9 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
65
68
|
AND EXISTS (
|
|
66
69
|
SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
|
|
67
70
|
)
|
|
71
|
+
AND (t.tokenable_type <> ${ownerType} OR EXISTS (
|
|
72
|
+
SELECT 1 FROM users u WHERE u.id = t.tokenable_id
|
|
73
|
+
))
|
|
68
74
|
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
69
75
|
LIMIT 1${forUpdate}
|
|
70
76
|
`,bindings))[0];if(!refreshRow||refreshRow.access_token_id==null||!validTokenExpiry(refreshRow.expires_at))throw new HttpError(401,"Invalid or expired refresh token");if(refreshRow.oauth_grant_id!=null)throw new HttpError(401,"Invalid or expired refresh token");const clientLock=isPostgres?" FOR SHARE":isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
|
|
@@ -92,10 +98,13 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
92
98
|
AND EXISTS (
|
|
93
99
|
SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
|
|
94
100
|
)
|
|
101
|
+
AND (t.tokenable_type <> ${param(2)} OR EXISTS (
|
|
102
|
+
SELECT 1 FROM users u WHERE u.id = t.tokenable_id
|
|
103
|
+
))
|
|
95
104
|
AND t.oauth_grant_id IS NULL
|
|
96
105
|
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
97
106
|
LIMIT 1
|
|
98
|
-
`,[hashedRefreshToken]))[0];return row!=null&&validTokenExpiry(row.expires_at)}export async function revokeRefreshToken(refreshTokenPlain){const{boolTrue,param}=tokenSql(),hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
|
|
107
|
+
`,[hashedRefreshToken,DEFAULT_TOKENABLE_TYPE]))[0];return row!=null&&validTokenExpiry(row.expires_at)}export async function revokeRefreshToken(refreshTokenPlain){const{boolTrue,param}=tokenSql(),hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
|
|
99
108
|
UPDATE oauth_refresh_tokens
|
|
100
109
|
SET revoked = ${boolTrue}
|
|
101
110
|
WHERE token = ${param(1)}
|
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.
|
|
5
|
+
"version": "0.75.31",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,18 +58,18 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@stacksjs/bun-router": "^0.1.18",
|
|
61
|
-
"@stacksjs/cache": "0.75.
|
|
62
|
-
"@stacksjs/config": "0.75.
|
|
63
|
-
"@stacksjs/database": "0.75.
|
|
64
|
-
"@stacksjs/email": "0.75.
|
|
65
|
-
"@stacksjs/env": "0.75.
|
|
66
|
-
"@stacksjs/error-handling": "0.75.
|
|
67
|
-
"@stacksjs/logging": "0.75.
|
|
68
|
-
"@stacksjs/orm": "0.75.
|
|
69
|
-
"@stacksjs/path": "0.75.
|
|
70
|
-
"@stacksjs/router": "0.75.
|
|
71
|
-
"@stacksjs/security": "0.75.
|
|
72
|
-
"@stacksjs/storage": "0.75.
|
|
61
|
+
"@stacksjs/cache": "0.75.31",
|
|
62
|
+
"@stacksjs/config": "0.75.31",
|
|
63
|
+
"@stacksjs/database": "0.75.31",
|
|
64
|
+
"@stacksjs/email": "0.75.31",
|
|
65
|
+
"@stacksjs/env": "0.75.31",
|
|
66
|
+
"@stacksjs/error-handling": "0.75.31",
|
|
67
|
+
"@stacksjs/logging": "0.75.31",
|
|
68
|
+
"@stacksjs/orm": "0.75.31",
|
|
69
|
+
"@stacksjs/path": "0.75.31",
|
|
70
|
+
"@stacksjs/router": "0.75.31",
|
|
71
|
+
"@stacksjs/security": "0.75.31",
|
|
72
|
+
"@stacksjs/storage": "0.75.31",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|