@stacksjs/auth 0.75.464 → 0.75.465

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.
@@ -0,0 +1,34 @@
1
+ import { browserSessionIdentifier } from './session-credential';
2
+ /**
3
+ * Finish a verified browser sign-in with a durable, hashed session credential.
4
+ * Call only from the server's completed password/MFA/SSO issuance path. This is
5
+ * not a public bearer-to-session exchange endpoint. API clients keep their
6
+ * existing short-lived token/refresh contract.
7
+ *
8
+ * The one-use sign-in token and its refresh partner are revoked in the same
9
+ * transaction. The browser receives no refresh secret and normal reads never
10
+ * rotate cookies or extend this session's absolute deadline.
11
+ */
12
+ export declare function startBrowserSession(signInToken: string, options?: { lifetimeMs?: number, previousSession?: string, previousAccessToken?: string, fingerprint?: { ip?: string | null, userAgent?: string | null } }): Promise<IssuedBrowserSession | null>;
13
+ export declare function browserSessionCookie(session: IssuedBrowserSession): string;
14
+ export declare function clearedBrowserSessionCookie(): string;
15
+ /** Owner-scoped metadata. Saved identifiers cannot be replayed as cookies. */
16
+ export declare function browserSessions(userId: number, currentCredential?: string): Promise<BrowserSessionDescription[]>;
17
+ /** Revoke a displayed identifier only within the authenticated owner's scope. */
18
+ export declare function revokeBrowserSession(userId: number, identifier: string): Promise<void>;
19
+ export declare interface IssuedBrowserSession {
20
+ sessionId: string
21
+ identifier: string
22
+ expiresIn: number
23
+ expiresAt: string
24
+ }
25
+ export declare interface BrowserSessionDescription {
26
+ id: string
27
+ current: boolean
28
+ userAgent: string | null
29
+ ipAddress: string | null
30
+ createdAt: string | null
31
+ lastSeenAt: string | null
32
+ expiresAt: string | null
33
+ }
34
+ export { browserSessionIdentifier } from './session-credential';
@@ -0,0 +1 @@
1
+ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{authCookie,authCookieToken}from"./cookie";import{readRequestFingerprint,sessionLogout}from"./session-auth";import{browserSessionIdentifier,createBrowserSessionCredential}from"./session-credential";import{findToken,parseScopes,revokeToken,revokeTokenById}from"./tokens";export{browserSessionIdentifier}from"./session-credential";function userIdentifier(userId){if(typeof userId!=="number"||!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] Browser sessions require a positive integer user id.")}export async function startBrowserSession(signInToken,options={}){const lifetime=options.lifetimeMs??config.auth?.session?.lifetime??86400000;if(!Number.isFinite(lifetime)||lifetime<1000)throw TypeError("[auth] Database browser-session lifetime must be at least one second in milliseconds.");const observed=await findToken(signInToken);if(!observed||observed.subjectType!=="users"||observed.grantId||!observed.scopes.includes("*"))return null;const userId=observed.subjectId;userIdentifier(userId);const{Auth}=await import("./authentication"),request=getCurrentRequest(),previousSession=options.previousSession??(request?authCookieToken(request,{name:"session_id"}):void 0),previousAccessToken=options.previousAccessToken??(request?authCookieToken(request):void 0);return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select("id");if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();if(!await ownerQuery.executeTakeFirst())return null;const current=await findToken(signInToken);if(!current||current.id!==observed.id||current.subjectId!==userId||current.subjectType!=="users"||current.grantId||!current.scopes.includes("*")||!await Auth.validateToken(signInToken))return null;const sessionId=createBrowserSessionCredential(),identifier=browserSessionIdentifier(sessionId),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetime);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const expiresIn=Math.floor((expiresAt.getTime()-Date.now())/1000);if(!Number.isSafeInteger(expiresIn)||expiresIn<1)throw TypeError("[auth] Browser-session lifetime cannot be represented by this database and cookie.");const fingerprint=readRequestFingerprint(options.fingerprint);await db.insertInto("auth_browser_sessions").values({id:identifier,user_id:userId,ip_address:fingerprint.ip,user_agent:fingerprint.userAgent,payload:JSON.stringify({browserSession:1,createdAt:createdAt.toISOString()}),last_activity:Math.floor(createdAt.getTime()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const saved=await db.primary.selectFrom("auth_browser_sessions").where("id","=",identifier).select(["user_id","expires_at"]).executeTakeFirst();if(!saved||Number(saved.user_id)!==userId||parseSqlDateTime(saved.expires_at)?.getTime()!==expiresAt.getTime()||expiresAt.getTime()<=Date.now())throw new HttpError(500,"The browser session could not be persisted.");await revokeToken(signInToken);if(await findToken(signInToken))throw new HttpError(500,"The sign-in credential could not be retired.");if(previousAccessToken&&previousAccessToken!==signInToken)await revokeToken(previousAccessToken);if(previousSession)await sessionLogout(previousSession);Auth.clearState();return{sessionId,identifier,expiresIn,expiresAt:expiresAt.toISOString()}})}export function browserSessionCookie(session){if(browserSessionIdentifier(session.sessionId)!==session.identifier)throw TypeError("[auth] Invalid issued browser session.");return authCookie(session.sessionId,{name:"session_id",maxAge:session.expiresIn})}export function clearedBrowserSessionCookie(){return authCookie("",{name:"session_id",maxAge:0})}export async function browserSessions(userId,currentCredential=""){userIdentifier(userId);const current=browserSessionIdentifier(currentCredential);let query=db.primary.selectFrom("auth_browser_sessions").where("user_id","=",userId).where("id","like","bs1:%").where("expires_at",">",sqlDateTime(new Date));const idle=config.auth?.idleTimeout??0;if(idle>0)query=query.where("last_activity",">=",Math.ceil((Date.now()-idle)/1000));const descriptions=(await query.select(["id","user_agent","ip_address","payload","last_activity","expires_at"]).orderBy("last_activity","desc").execute()).flatMap((row)=>{const expires=parseSqlDateTime(row.expires_at);if(!expires||expires.getTime()<=Date.now())return[];let createdAt=null;try{createdAt=JSON.parse(String(row.payload)).createdAt??null}catch{}return[{id:String(row.id),current:row.id===current,userAgent:row.user_agent==null?null:String(row.user_agent),ipAddress:row.ip_address==null?null:String(row.ip_address),createdAt,lastSeenAt:new Date(Number(row.last_activity)*1000).toISOString(),expiresAt:expires.toISOString()}]}),legacyCurrent=!current&&currentCredential?await findToken(currentCredential):null,legacyDescriptions=(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_type","=","users").where("tokenable_id","=",userId).where("revoked","=",!1).whereNull("oauth_grant_id").select(["id","scopes","user_agent","ip_address","created_at","updated_at","expires_at"]).execute()).flatMap((row)=>{const id=Number(row.id),expires=row.expires_at==null?null:parseSqlDateTime(row.expires_at),created=parseSqlDateTime(row.created_at),seen=parseSqlDateTime(row.updated_at??row.created_at);if(!Number.isSafeInteger(id)||id<=0||!parseScopes(row.scopes).includes("*")||row.expires_at!=null&&(!expires||expires.getTime()<=Date.now())||idle>0&&seen&&Date.now()-seen.getTime()>idle)return[];return[{id:`access:${id}`,current:legacyCurrent?.id===id&&legacyCurrent.subjectId===userId,userAgent:row.user_agent==null?null:String(row.user_agent),ipAddress:row.ip_address==null?null:String(row.ip_address),createdAt:created?.toISOString()??null,lastSeenAt:seen?.toISOString()??null,expiresAt:expires?.toISOString()??null}]});return[...descriptions,...legacyDescriptions].sort((left,right)=>(Date.parse(right.lastSeenAt??right.createdAt??"")||0)-(Date.parse(left.lastSeenAt??left.createdAt??"")||0))}export async function revokeBrowserSession(userId,identifier){userIdentifier(userId);const legacyId=/^access:([1-9]\d*)$/.exec(identifier);if(legacyId){const id=Number(legacyId[1]);if(!Number.isSafeInteger(id))return;await db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",id).where("tokenable_type","=","users").where("tokenable_id","=",userId).whereNull("oauth_grant_id").select(["id","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const token=await query.executeTakeFirst();if(token&&parseScopes(token.scopes).includes("*"))await revokeTokenById(id)});return}if(!/^bs1:[a-f0-9]{64}$/.test(identifier))return;await db.transaction(async()=>{await db.deleteFrom("auth_browser_sessions").where("user_id","=",userId).where("id","=",identifier).execute();if(await db.primary.selectFrom("auth_browser_sessions").where("user_id","=",userId).where("id","=",identifier).select("id").executeTakeFirst())throw new HttpError(500,"The browser session could not be revoked.")})}
package/dist/index.d.ts CHANGED
@@ -56,6 +56,7 @@ export * from './session-auth';
56
56
  // Cookie-carried access tokens, for server-rendered pages.
57
57
  export * from './cookie-auth';
58
58
  export * from './browser-session';
59
+ export * from './browser-session-store';
59
60
  export * from './request-token';
60
61
  // The stx page gate (`middleware: ['auth' | 'guest']`), token-validating.
61
62
  export * from './page-gate';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./authentication";export*from"./impersonation";export{requestImpersonation}from"./impersonation-auth";export*from"./impersonation-store";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-introspection";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"./passkeys";export*from"./password/reset";export*from"./password/reset-token";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{adminEmails,databaseAdminAccounts,nameFromEmail,provisionAdmins}from"./admins";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*from"./session-handoff";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-userinfo";export*from"./oauth-server";
1
+ export*from"./authentication";export*from"./impersonation";export{requestImpersonation}from"./impersonation-auth";export*from"./impersonation-store";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-introspection";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"./passkeys";export*from"./password/reset";export*from"./password/reset-token";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{adminEmails,databaseAdminAccounts,nameFromEmail,provisionAdmins}from"./admins";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./browser-session-store";export*from"./request-token";export*from"./page-gate";export*from"./socials";export*from"./session-handoff";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-userinfo";export*from"./oauth-server";
@@ -53,10 +53,9 @@ export declare function sessionLogout(sessionId: string): Promise<void>;
53
53
  *
54
54
  * Like `sessionLogout`, real failures propagate (fail loud): a reset
55
55
  * that reports success while the attacker's session lives would be a
56
- * lie. A missing `sessions` table alone is a benign no-op — no
57
- * framework migration creates it (only userland adopting session-auth
58
- * does), and without the table `sessionCheck` can never validate a
59
- * session, so there is no credential left to revoke.
56
+ * lie. An absent legacy `sessions` table or an unapplied browser-session
57
+ * migration has no credential to revoke. A missing trigger dependency is
58
+ * still a real failure and rolls back both credential families.
60
59
  */
61
60
  export declare function sessionDestroyAll(userId: number, options?: { exceptId?: string }): Promise<void>;
62
61
  /** Get the authenticated user from a session ID. */
@@ -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{clientAddress,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("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null,inRequest:!1};const headers=req.headers,ip=clientAddress(req),userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent,inRequest:!0}}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&&stored.userAgent!=null&&current.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;if(enforceUa&&stored.userAgent!=null&&current.userAgent==null&&current.inRequest===!0)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,options={}){const{exceptId}=options;try{await db.transaction(async()=>{let removal=db.deleteFrom("sessions").where("user_id","=",userId);if(exceptId!==void 0)removal=removal.where("id","!=",exceptId);await removal.execute();let check=db.primary.selectFrom("sessions").where("user_id","=",userId);if(exceptId!==void 0)check=check.where("id","!=",exceptId);if(await check.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}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0)return;let touch=db.updateTable("sessions").set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);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}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).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(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);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}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).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(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);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};
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{clientAddress,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";import{sessionCredentialIdentifier}from"./session-credential";function sessionTable(identifier){return identifier.startsWith("bs1:")?"auth_browser_sessions":"sessions"}function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null,inRequest:!1};const headers=req.headers,ip=clientAddress(req),userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent,inRequest:!0}}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&&stored.userAgent!=null&&current.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;if(enforceUa&&stored.userAgent!=null&&current.userAgent==null&&current.inRequest===!0)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){const identifier=sessionCredentialIdentifier(sessionId);if(!identifier)return;sessionId=identifier;await db.transaction(async()=>{let observed=db.primary.selectFrom(sessionTable(sessionId)).where("id","=",sessionId).select("id");if(getDatabaseDialect()!=="sqlite")observed=observed.lockForUpdate();const current=await observed.executeTakeFirst();if(!current||String(current.id)!==sessionId)return;await db.deleteFrom(sessionTable(sessionId)).where("id","=",sessionId).execute();if(await db.primary.selectFrom(sessionTable(sessionId)).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,options={}){const exceptId=options.exceptId?.startsWith("bs1_")?sessionCredentialIdentifier(options.exceptId):options.exceptId;if(options.exceptId?.startsWith("bs1_")&&!exceptId)throw TypeError("[auth] Invalid browser session credential to preserve.");await db.transaction(async()=>{for(const table of["sessions","auth_browser_sessions"])try{await db.transaction(async()=>{let removal=db.deleteFrom(table).where("user_id","=",userId);if(exceptId!==void 0)removal=removal.where("id","!=",exceptId);await removal.execute();let check=db.primary.selectFrom(table).where("user_id","=",userId);if(exceptId!==void 0)check=check.where("id","!=",exceptId);if(await check.select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(error){const message=error&&typeof error==="object"&&"message"in error?String(error.message):String(error);if(!/^(?:no such table: (?:main\.)?(sessions|auth_browser_sessions)|relation "(sessions|auth_browser_sessions)" does not exist|Table '(?:[^'.]+\.)?(sessions|auth_browser_sessions)' doesn't exist)$/i.exec(message)?.slice(1).some((name)=>name?.toLowerCase()===table))throw error}})}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom(sessionTable(sessionId)).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(sessionTable(sessionId)).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}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom(sessionTable(sessionId)).where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0&&!sessionId.startsWith("bs1:"))return;let touch=db.updateTable(sessionTable(sessionId)).set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){const identifier=sessionCredentialIdentifier(sessionId);if(!identifier)return;sessionId=identifier;try{const query=db.primary.selectFrom(sessionTable(sessionId)).where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["id","user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["id","user_id","expires_at","last_activity"]).executeTakeFirst();if(!session||String(session.id)!==sessionId)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);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}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){const identifier=sessionCredentialIdentifier(sessionId);if(!identifier)return!1;sessionId=identifier;try{const query=db.primary.selectFrom(sessionTable(sessionId)).where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["id","user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["id","user_id","expires_at","last_activity"]).executeTakeFirst();if(!session||String(session.id)!==sessionId)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);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}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){const identifier=sessionCredentialIdentifier(sessionId);if(!identifier)return!1;sessionId=identifier;if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom(sessionTable(sessionId)).where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["id","user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["id","user_id","expires_at","last_activity"]).executeTakeFirst();if(!session||String(session.id)!==sessionId)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){const current=await db.primary.selectFrom(sessionTable(sessionId)).where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst();if(!current||String(current.id)!==sessionId)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(sessionTable(sessionId)).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(sessionTable(sessionId)).where("id","=",sessionId).select(["id","user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.id)!==sessionId||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};
@@ -0,0 +1,5 @@
1
+ /** Browser credentials are distinct from the non-secret identifier saved in SQL. */
2
+ export declare function createBrowserSessionCredential(): string;
3
+ export declare function browserSessionIdentifier(credential: string): string | undefined;
4
+ /** Keep legacy session credentials readable, but never accept a saved browser identifier. */
5
+ export declare function sessionCredentialIdentifier(credential: string): string | undefined;
@@ -0,0 +1 @@
1
+ import{createHash,randomBytes}from"node:crypto";const CREDENTIAL_PREFIX="bs1_",IDENTIFIER_PREFIX="bs1:";export function createBrowserSessionCredential(){return`${CREDENTIAL_PREFIX}${randomBytes(40).toString("hex")}`}export function browserSessionIdentifier(credential){if(!/^bs1_[a-f0-9]{80}$/.test(credential))return;return`${IDENTIFIER_PREFIX}${createHash("sha256").update(credential).digest("hex")}`}export function sessionCredentialIdentifier(credential){if(!credential||/^(?:bs1|access):/i.test(credential))return;if(credential.startsWith(CREDENTIAL_PREFIX))return browserSessionIdentifier(credential);return credential}
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.464",
5
+ "version": "0.75.465",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -59,19 +59,19 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@stacksjs/bun-router": "^0.1.24",
62
- "@stacksjs/cache": "0.75.464",
63
- "@stacksjs/config": "0.75.464",
64
- "@stacksjs/database": "0.75.464",
65
- "@stacksjs/email": "0.75.464",
66
- "@stacksjs/env": "0.75.464",
67
- "@stacksjs/error-handling": "0.75.464",
68
- "@stacksjs/logging": "0.75.464",
69
- "@stacksjs/orm": "0.75.464",
70
- "@stacksjs/path": "0.75.464",
71
- "@stacksjs/router": "0.75.464",
72
- "@stacksjs/security": "0.75.464",
73
- "@stacksjs/socials": "0.75.464",
74
- "@stacksjs/storage": "0.75.464",
62
+ "@stacksjs/cache": "0.75.465",
63
+ "@stacksjs/config": "0.75.465",
64
+ "@stacksjs/database": "0.75.465",
65
+ "@stacksjs/email": "0.75.465",
66
+ "@stacksjs/env": "0.75.465",
67
+ "@stacksjs/error-handling": "0.75.465",
68
+ "@stacksjs/logging": "0.75.465",
69
+ "@stacksjs/orm": "0.75.465",
70
+ "@stacksjs/path": "0.75.465",
71
+ "@stacksjs/router": "0.75.465",
72
+ "@stacksjs/security": "0.75.465",
73
+ "@stacksjs/socials": "0.75.465",
74
+ "@stacksjs/storage": "0.75.465",
75
75
  "@stacksjs/ts-auth": "^0.4.7",
76
76
  "ts-qr-codes": "^0.1.8"
77
77
  },