@stacksjs/auth 0.75.226 → 0.75.230

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.
@@ -34,7 +34,7 @@ export declare class Auth {
34
34
  static revokeToken(token: string): Promise<void>;
35
35
  static revokeTokenById(tokenId: number): Promise<void>;
36
36
  static revokeAllTokens(userId?: number): Promise<void>;
37
- static revokeOtherTokens(userId?: number): Promise<void>;
37
+ static revokeOtherTokens(userId?: number, options?: { exceptTokenId?: number }): Promise<{ keptTokenId: number | null }>;
38
38
  static pruneExpiredTokens(): Promise<number>;
39
39
  static pruneRevokedTokens(): Promise<number>;
40
40
  static rotateToken(oldToken: string): Promise<AuthToken | null>;
@@ -5,4 +5,4 @@ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransactio
5
5
  AND t.oauth_grant_id IS NULL
6
6
  AND (c.id IS NULL OR c.revoked = ${sql.boolFalse})
7
7
  LIMIT 1
8
- `,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate||candidate.grantId!=null)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||existing.grantId!=null||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
8
+ `,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId,options={}){const uid=userId??await this.id();if(!uid)return{keptTokenId:null};const keep=options.exceptTokenId??(await this.currentAccessToken())?.id;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,keep);return{keptTokenId:keep??null}}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate||candidate.grantId!=null)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||existing.grantId!=null||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
package/dist/index.d.ts CHANGED
@@ -55,6 +55,8 @@ export * from './request-token';
55
55
  export * from './page-gate';
56
56
  // Social sign-in: which local user a provider identity resolves to.
57
57
  export * from './socials';
58
+ // One-use, browser-bound codes that move a redirect flow's sign-in into a page.
59
+ export * from './session-handoff';
58
60
  // TOTP (Two-Factor Authentication) - re-export from ts-auth
59
61
  export {
60
62
  generateTOTP,
package/dist/index.js CHANGED
@@ -1 +1 @@
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-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"./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";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
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-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"./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*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-server";
@@ -0,0 +1,55 @@
1
+ export declare function revokeAccountCredentials(userId: number, options?: RevokeAccountCredentialsOptions): Promise<RevokeAccountCredentialsResult>;
2
+ /**
3
+ * Change a password the signed-in user knows, then sweep every other
4
+ * credential the account holds.
5
+ *
6
+ * The current password is verified, and the change commits only if it is
7
+ * still the stored hash at write time (a concurrent reset or change wins, and
8
+ * this returns `invalid-current-password`). The new password's policy (length,
9
+ * strength, breach checks) is the caller's to enforce first.
10
+ */
11
+ export declare function changePassword(userId: number, currentPassword: string, newPassword: string, options?: ChangePasswordOptions): Promise<ChangePasswordResult>;
12
+ /**
13
+ * The framework's "your password was changed" mail. Never throws: the change
14
+ * already happened, and a mail failure must not report it as failed.
15
+ */
16
+ export declare function sendPasswordChangedNotification(userEmail: string): Promise<void>;
17
+ /**
18
+ * One credential sweep for every path that changes how an account proves who
19
+ * it is: a password reset, a password change, an account recovery, an admin
20
+ * forcing a sign-out.
21
+ *
22
+ * Password resets swept everything; anything else that changed a password
23
+ * had to remember the same five revocations and the stamp, and an app that
24
+ * forgot one left a door open (an attacker's session surviving the victim's
25
+ * password change is the case that matters). This is that sweep, exported:
26
+ *
27
+ * - stamps `users.password_changed_at`, which the token paths read to refuse
28
+ * any token issued before it, so even a token this sweep could not see is
29
+ * dead;
30
+ * - revokes pending two-factor login challenges, magic links, OAuth grants
31
+ * and authorization codes, access and refresh tokens, and sessions;
32
+ * - all in one transaction: a failure anywhere rolls the whole sweep back
33
+ * rather than reporting a half-revoked account.
34
+ *
35
+ * `exceptTokenId` / `exceptSessionId` keep the caller's own credential, so a
36
+ * user who changes their password stays signed in where they did it. The kept
37
+ * token (and its refresh token) is re-bound to the new stamp; everything else
38
+ * the account holds is gone.
39
+ */
40
+ export declare interface RevokeAccountCredentialsOptions {
41
+ exceptTokenId?: number
42
+ exceptSessionId?: string
43
+ tx?: unknown
44
+ }
45
+ export declare interface RevokeAccountCredentialsResult {
46
+ passwordChangedAt: Date | null
47
+ keptTokenId: number | null
48
+ }
49
+ export declare interface ChangePasswordOptions {
50
+ keepTokenId?: number
51
+ keepSessionId?: string
52
+ notify?: boolean | ((user: { id: number, email: string, name?: string }) => Promise<void> | void)
53
+ }
54
+ export type ChangePasswordResult = | { success: true, keptTokenId: number | null }
55
+ | { success: false, reason: 'invalid-current-password' | 'user-not-found' | 'invalid-new-password' }
@@ -0,0 +1 @@
1
+ import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute();if(mutationCount(stamped)!==1)throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).select(["id","email","name","password"]).executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}
@@ -1,10 +1,35 @@
1
+ import { revokeAccountCredentials, sendPasswordChangedNotification } from './credentials';
2
+ export type { ChangePasswordOptions, ChangePasswordResult, RevokeAccountCredentialsOptions, RevokeAccountCredentialsResult } from './credentials';
1
3
  export declare function passwordResets(value: string): PasswordResetActions;
2
4
  export declare interface PasswordResetResult {
3
5
  success: boolean
4
6
  message?: string
5
7
  }
8
+ /** What a `deliver` hook receives. `token` and `url` are the live credential: never log them. */
9
+ export declare interface PasswordResetDelivery {
10
+ purpose: PasswordResetPurpose
11
+ userId: number
12
+ email: string
13
+ token: string
14
+ url: string
15
+ expiresAt: Date
16
+ }
17
+ export declare interface SendPasswordResetOptions {
18
+ purpose?: PasswordResetPurpose
19
+ expiresAt?: Date
20
+ deliver?: (delivery: PasswordResetDelivery) => Promise<void> | void
21
+ }
6
22
  export declare interface PasswordResetActions {
7
- sendEmail: () => Promise<void>
23
+ sendEmail: (options?: SendPasswordResetOptions) => Promise<void>
8
24
  verifyToken: (token: string) => Promise<boolean>
9
25
  resetPassword: (token: string, newPassword: string) => Promise<PasswordResetResult>
10
26
  }
27
+ /**
28
+ * Why the link is being sent. `reset` is "I forgot my password"; `setup` is
29
+ * an account someone else created (an invitation, an import, a staff member
30
+ * adding a client) whose holder chooses their first password. The token and
31
+ * redemption are identical; the default mail differs (`password-setup`
32
+ * template) and a `deliver` hook can tell them apart.
33
+ */
34
+ export type PasswordResetPurpose = 'reset' | 'setup';
35
+ export { changePassword, revokeAccountCredentials, sendPasswordChangedNotification } from './credentials';
@@ -1,3 +1,5 @@
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).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?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}
1
+ import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";export{changePassword,revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId,expiresAtOverride){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();if(!await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(expiresAtOverride??new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0))return;return{token,expiresAt:expiresAtOverride??new Date(Date.now()+expireMinutes*60000)}}async function sendEmail(options={}){const purpose=options.purpose==="setup"?"setup":"reset";if(options.expiresAt!==void 0&&(!(options.expiresAt instanceof Date)||!Number.isFinite(options.expiresAt.getTime())||options.expiresAt.getTime()<=Date.now()))throw TypeError("[PasswordReset] expiresAt must be a future Date.");const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await createResetToken(userId,options.expiresAt);if(!issued)return;const delivery={purpose,userId,email,token:issued.token,url:await resetUrlFor(issued.token),expiresAt:issued.expiresAt},deliver=options.deliver?async()=>{await options.deliver(delivery)}:()=>deliverResetEmail(delivery);if(!enqueueAfterCommit(deliver))await deliver()}async function resetUrlFor(token){const base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email));return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}async function deliverResetEmail(delivery){const resetUrl=delivery.url,expireMinutes=Math.max(1,Math.round((delivery.expiresAt.getTime()-Date.now())/60000)),appName=config.app.name||"Stacks",setup=delivery.purpose==="setup",subject=setup?`Set Your ${appName} Password`:`Reset Your ${appName} Password`;let html,text;try{const rendered=await template("password-reset",{subject,variables:{resetUrl,expireMinutes,purpose:delivery.purpose}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=setup?`Choose your password by visiting: ${resetUrl}
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).select(["token","expires_at","created_at"]).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).select(["token","expires_at","created_at"]);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.")}
3
+ This link expires in ${expireMinutes} minutes.`:`Reset your password by visiting: ${resetUrl}
4
+
5
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:delivery.email,subject,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token","expires_at","created_at"]).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).select(["token","expires_at","created_at"]);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 updated=await trx.updateTable("users").set({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 revokeAccountCredentials(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.")}
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Failures before a lockout: 5, multiplied by `config.security.rateLimit.scale`
3
+ * (`STACKS_RATE_LIMIT_SCALE`) so a test suite can sign in often without
4
+ * turning the lockout off.
5
+ */
6
+ export declare function maxFailedAttempts(): number;
1
7
  export declare interface RateLimitEntry {
2
8
  attempts: number
3
9
  lockedUntil: number
@@ -5,12 +11,14 @@ export declare interface RateLimitEntry {
5
11
  /**
6
12
  * Pluggable backing store for the auth rate limiter.
7
13
  *
8
- * Methods may be sync or async — the limiter awaits them either way. The
9
- * default {@link MemoryStore} is process-local (fine for single-instance and
10
- * dev). On a horizontally-scaled deployment the in-memory store is trivially
11
- * bypassed by spreading attempts across instances, so production should swap
12
- * in the atomic Redis store via `RateLimiter.useSharedStore()` or provide a
13
- * custom store whose `recordFailedAttempt` operation is atomic.
14
+ * Methods may be sync or async — the limiter awaits them either way. When
15
+ * `config/cache.ts` uses the Redis driver the limiter counts there (atomic
16
+ * across workers and instances), falling back to this process's memory only
17
+ * while Redis is unreachable. Otherwise it uses the process-local
18
+ * {@link MemoryStore}, which a horizontally-scaled deployment bypasses by
19
+ * spreading attempts across instances: configure Redis, call
20
+ * `RateLimiter.useSharedStore()`, or provide a custom store whose
21
+ * `recordFailedAttempt` operation is atomic.
14
22
  */
15
23
  export declare interface RateLimiterStore {
16
24
  get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
@@ -38,6 +46,18 @@ export declare class RedisRateLimiterStore implements RateLimiterStore {
38
46
  recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
39
47
  close(): Promise<void>;
40
48
  }
49
+ /**
50
+ * The configured shared store, with this process's memory standing in while
51
+ * it errors: a Redis outage weakens the lockout to per-process instead of
52
+ * failing every sign-in. Warns once per outage.
53
+ */
54
+ export declare class FallbackRateLimiterStore implements RateLimiterStore {
55
+ constructor(shared: RateLimiterStore, local?: MemoryStore);
56
+ get(key: string): Promise<RateLimitEntry | undefined>;
57
+ delete(key: string): Promise<void>;
58
+ recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
59
+ close(): Promise<void>;
60
+ }
41
61
  export declare class RateLimiter {
42
62
  static useStore(custom: RateLimiterStore): void;
43
63
  static useSharedStore(options?: RedisRateLimiterStoreOptions): void;
@@ -1,4 +1,4 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=MAX_ATTEMPTS){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}const RECORD_FAILURE_SCRIPT=`
1
+ import{HttpError}from"@stacksjs/error-handling";import{cacheRedisConnection,scaledLimit}from"@stacksjs/router";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;export function maxFailedAttempts(){return scaledLimit(MAX_ATTEMPTS)}function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=maxFailedAttempts()){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}const RECORD_FAILURE_SCRIPT=`
2
2
  local attempts = 0
3
3
  local locked_until = 0
4
4
  local raw = redis.call('GET', KEYS[1])
@@ -23,4 +23,4 @@ if attempts >= maximum then
23
23
  end
24
24
  redis.call('SET', KEYS[1], cjson.encode({ attempts = attempts, lockedUntil = locked_until }), 'PX', ttl)
25
25
  return locked_until
26
- `;function redisUrl(options,configured){const redis=configured.drivers?.redis;if(options.url)return{url:options.url,prefix:options.prefix??configured.prefix??"stacks"};if(configured.driver!=="redis"||!redis)throw Error("Shared auth rate limiting requires the Redis cache driver or an explicit Redis URL.");if(redis.url)return{url:redis.url,prefix:options.prefix??configured.prefix??"stacks"};const protocol=redis.tls?"rediss":"redis",credentials=redis.username?`${encodeURIComponent(redis.username)}:${encodeURIComponent(redis.password??"")}@`:redis.password?`:${encodeURIComponent(redis.password)}@`:"",database=redis.database==null?"":`/${redis.database}`;return{url:`${protocol}://${credentials}${redis.host??"127.0.0.1"}:${redis.port??6379}${database}`,prefix:options.prefix??configured.prefix??"stacks"}}export class RedisRateLimiterStore{options;clientPromise;constructor(options={}){this.options=options}async client(){if(!this.clientPromise)this.clientPromise=(async()=>{const{cache:configured}=await import("@stacksjs/config"),resolved=redisUrl(this.options,configured),client=new RedisClient(resolved.url);await client.connect();return{client,prefix:resolved.prefix}})().catch((error)=>{this.clientPromise=void 0;throw error});return this.clientPromise}async key(key){const{client,prefix}=await this.client();return{client,key:`${prefix}:auth:ratelimit:${key}`}}async get(key){const resolved=await this.key(key),raw=await resolved.client.get(resolved.key);if(raw==null)return;try{const entry=JSON.parse(raw);if(!Number.isSafeInteger(entry.attempts)||entry.attempts<0||!Number.isSafeInteger(entry.lockedUntil)||entry.lockedUntil<0)return;return{attempts:entry.attempts,lockedUntil:entry.lockedUntil}}catch{return}}async set(key,entry,ttlMs){const resolved=await this.key(key);await resolved.client.send("SET",[resolved.key,JSON.stringify(entry),"PX",String(ttlMs)])}async delete(key){const resolved=await this.key(key);await resolved.client.del(resolved.key)}async recordFailedAttempt(key,now,ttlMs){const resolved=await this.key(key);await resolved.client.send("EVAL",[RECORD_FAILURE_SCRIPT,"1",resolved.key,String(now),String(ttlMs),String(MAX_ATTEMPTS)])}async close(){if(!this.clientPromise)return;const{client}=await this.clientPromise;client.close();this.clientPromise=void 0}}let store;function replaceStore(next){const previous=store;store=next;if(previous&&previous!==next&&previous.close)Promise.resolve(previous.close()).catch(()=>{})}async function configuredStore(){if(store)return store;const{cache}=await import("@stacksjs/config");store??=cache.driver==="redis"?new RedisRateLimiterStore:new MemoryStore;return store}export class RateLimiter{static useStore(custom){replaceStore(custom)}static useSharedStore(options={}){replaceStore(new RedisRateLimiterStore(options))}static useMemoryStore(){replaceStore(new MemoryStore)}static useConfiguredStore(){replaceStore(void 0)}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await(await configuredStore()).get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();await(await configuredStore()).recordFailedAttempt(email,now,LOCKOUT_DURATION)}static async resetAttempts(email){await(await configuredStore()).delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
26
+ `;function redisUrl(options,configured){if(options.url)return{url:options.url,prefix:options.prefix??configured?.prefix??"stacks"};const connection=cacheRedisConnection(configured);if(!connection)throw Error("Shared auth rate limiting requires the Redis cache driver or an explicit Redis URL.");return{url:connection.url,prefix:options.prefix??connection.prefix}}export class RedisRateLimiterStore{options;clientPromise;constructor(options={}){this.options=options}async client(){if(!this.clientPromise)this.clientPromise=(async()=>{const{cache:configured}=await import("@stacksjs/config"),resolved=redisUrl(this.options,configured),client=new RedisClient(resolved.url);await client.connect();return{client,prefix:resolved.prefix}})().catch((error)=>{this.clientPromise=void 0;throw error});return this.clientPromise}async key(key){const{client,prefix}=await this.client();return{client,key:`${prefix}:auth:ratelimit:${key}`}}async get(key){const resolved=await this.key(key),raw=await resolved.client.get(resolved.key);if(raw==null)return;try{const entry=JSON.parse(raw);if(!Number.isSafeInteger(entry.attempts)||entry.attempts<0||!Number.isSafeInteger(entry.lockedUntil)||entry.lockedUntil<0)return;return{attempts:entry.attempts,lockedUntil:entry.lockedUntil}}catch{return}}async set(key,entry,ttlMs){const resolved=await this.key(key);await resolved.client.send("SET",[resolved.key,JSON.stringify(entry),"PX",String(ttlMs)])}async delete(key){const resolved=await this.key(key);await resolved.client.del(resolved.key)}async recordFailedAttempt(key,now,ttlMs){const resolved=await this.key(key);await resolved.client.send("EVAL",[RECORD_FAILURE_SCRIPT,"1",resolved.key,String(now),String(ttlMs),String(maxFailedAttempts())])}async close(){if(!this.clientPromise)return;const{client}=await this.clientPromise;client.close();this.clientPromise=void 0}}export class FallbackRateLimiterStore{shared;local;warned=!1;constructor(shared,local=new MemoryStore){this.shared=shared;this.local=local}async attempt(primary,secondary){try{const result=await primary();this.warned=!1;return result}catch(error){if(!this.warned){this.warned=!0;console.warn(`[auth] login lockouts fell back to this process's memory: the shared store is unreachable (${error instanceof Error?error.message:String(error)}).`)}return secondary()}}async get(key){return this.attempt(()=>this.shared.get(key),()=>this.local.get(key))}async delete(key){this.local.delete(key);await this.attempt(()=>this.shared.delete(key),()=>{return})}async recordFailedAttempt(key,now,ttlMs){await this.attempt(()=>this.shared.recordFailedAttempt(key,now,ttlMs),()=>this.local.recordFailedAttempt(key,now))}async close(){await this.shared.close?.()}}let store;function replaceStore(next){const previous=store;store=next;if(previous&&previous!==next&&previous.close)Promise.resolve(previous.close()).catch(()=>{})}async function configuredStore(){if(store)return store;const{cache}=await import("@stacksjs/config");store??=cache.driver==="redis"?new FallbackRateLimiterStore(new RedisRateLimiterStore):new MemoryStore;return store}export class RateLimiter{static useStore(custom){replaceStore(custom)}static useSharedStore(options={}){replaceStore(new RedisRateLimiterStore(options))}static useMemoryStore(){replaceStore(new MemoryStore)}static useConfiguredStore(){replaceStore(void 0)}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await(await configuredStore()).get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();await(await configuredStore()).recordFailedAttempt(email,now,LOCKOUT_DURATION)}static async resetAttempts(email){await(await configuredStore()).delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
@@ -58,7 +58,7 @@ export declare function sessionLogout(sessionId: string): Promise<void>;
58
58
  * does), and without the table `sessionCheck` can never validate a
59
59
  * session, so there is no credential left to revoke.
60
60
  */
61
- export declare function sessionDestroyAll(userId: number): Promise<void>;
61
+ export declare function sessionDestroyAll(userId: number, options?: { exceptId?: string }): Promise<void>;
62
62
  /** Get the authenticated user from a session ID. */
63
63
  export declare function sessionUser(sessionId: string): Promise<UserModel | undefined>;
64
64
  /**
@@ -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){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}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";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};
@@ -0,0 +1,46 @@
1
+ /** Mint a handoff for `userId`. Mint the session itself only on redeem. */
2
+ export declare function createSessionHandoff(userId: number, options: CreateSessionHandoffOptions): Promise<SessionHandoff>;
3
+ /**
4
+ * Redeem a handoff once. `secret` is the binding cookie's value
5
+ * ({@link readSessionHandoffCookie}) or the native client's verifier. Null for
6
+ * an unknown, spent, expired or foreign code.
7
+ */
8
+ export declare function redeemSessionHandoff(code: unknown, secret: string | null | undefined): Promise<RedeemedSessionHandoff | null>;
9
+ /** The binding cookie's value for `code` from a `Cookie` header, or ''. */
10
+ export declare function readSessionHandoffCookie(cookieHeader: string | null | undefined, code: string): string;
11
+ /** A `Set-Cookie` value removing the binding cookie. Send it with every redeem answer. */
12
+ export declare function clearSessionHandoffCookie(code: string, cookiePath?: string): Promise<string>;
13
+ /**
14
+ * A one-use code that moves a server-side sign-in into a browser.
15
+ *
16
+ * A redirect flow (social sign-in, an emailed link, a native app's sign-in
17
+ * sheet) ends on a server that knows who signed in and a browser that does
18
+ * not hold a session yet. Putting the bearer and refresh tokens in the
19
+ * redirect's URL fragment works, but the fragment lands in browser history,
20
+ * and anyone who later reads it (a shared machine, a sync service, a
21
+ * screenshot) holds a live session.
22
+ *
23
+ * Instead the redirect carries this code. It lives 60 seconds, redeems once,
24
+ * and only for the browser that holds the matching HttpOnly cookie, or, for a
25
+ * native app whose sign-in sheet keeps its own cookies, for the client that
26
+ * holds the verifier behind the challenge it supplied (PKCE-style). The page
27
+ * POSTs the code to `/auth/handoff` and receives the session there.
28
+ */
29
+ export declare const SESSION_HANDOFF_PURPOSE: 'session-handoff';
30
+ export declare interface CreateSessionHandoffOptions {
31
+ bindTo: SessionHandoffBinding
32
+ data?: Record<string, unknown>
33
+ cookiePath?: string
34
+ }
35
+ export declare interface SessionHandoff {
36
+ code: string
37
+ cookie: string | null
38
+ expiresAt: Date
39
+ }
40
+ export declare interface RedeemedSessionHandoff {
41
+ userId: number
42
+ data: Record<string, unknown> | null
43
+ }
44
+ export type SessionHandoffBinding = | 'cookie'
45
+ /** The lowercase hex SHA-256 of a verifier the redeeming client holds (native apps). */
46
+ | { challenge: string }
@@ -0,0 +1 @@
1
+ import{clearOAuthFlowCookie,consumeOAuthFlow,issueOAuthFlow,readOAuthFlowCookie}from"@stacksjs/socials";export const SESSION_HANDOFF_PURPOSE="session-handoff";const HANDOFF_TTL_MS=60000;export async function createSessionHandoff(userId,options){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] createSessionHandoff needs a positive integer user id.");const challenge=typeof options.bindTo==="object"?options.bindTo.challenge:void 0,issued=await issueOAuthFlow({purpose:SESSION_HANDOFF_PURPOSE,userId,data:options.data,ttlMs:HANDOFF_TTL_MS,...challenge!==void 0?{challenge}:{bindBrowser:!0},cookie:{path:options.cookiePath??"/",sameSite:"Lax"}});return{code:issued.state,cookie:issued.cookie,expiresAt:issued.expiresAt}}export async function redeemSessionHandoff(code,secret){const flow=await consumeOAuthFlow(code,{purpose:SESSION_HANDOFF_PURPOSE,browserSecret:secret});if(!flow||flow.userId===null)return null;return{userId:flow.userId,data:flow.data}}export function readSessionHandoffCookie(cookieHeader,code){return readOAuthFlowCookie(cookieHeader,code)}export function clearSessionHandoffCookie(code,cookiePath="/"){return clearOAuthFlowCookie(code,{path:cookiePath,sameSite:"Lax"})}
package/dist/socials.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { db } from '@stacksjs/database/runtime';
2
+ /** Read `users.email_verified_at` for one user, on `tx` when given. */
3
+ export declare function isUserEmailVerified(userId: number, tx?: SocialSignInTransaction): Promise<boolean>;
4
+ /** The store {@link resolveSocialSignIn} uses when none is passed. */
5
+ export declare function defaultSocialSignInStore(): SocialSignInStore;
1
6
  /**
2
7
  * Resolve a provider identity to a local user id, creating the user and/or
3
8
  * the link row according to the configured matching policy.
@@ -5,7 +10,7 @@
5
10
  * Throws {@link SocialSignInRefusedError} when the policy says no; every
6
11
  * other failure mode (db down, etc.) throws its own error untouched.
7
12
  */
8
- export declare function resolveSocialSignIn(provider: string, identity: SocialIdentity, store?: SocialSignInStore): Promise<SocialSignInResult>;
13
+ export declare function resolveSocialSignIn(provider: string, identity: SocialIdentity, store?: SocialSignInStore, options?: ResolveSocialSignInOptions): Promise<SocialSignInResult>;
9
14
  /**
10
15
  * The social sign-in policy: which local user a provider identity resolves to
11
16
  * (stacksjs/stacks#2276).
@@ -20,7 +25,12 @@ export declare function resolveSocialSignIn(provider: string, identity: SocialId
20
25
  *
21
26
  * That takeover guard is therefore NOT configurable: a provider identity only
22
27
  * links to an existing user by email when the provider explicitly vouches for
23
- * the address (`emailVerified === true`). What IS configurable is what happens
28
+ * the address (`emailVerified === true`) AND the local account has verified
29
+ * it too (`users.email_verified_at` is set). The second half closes the
30
+ * mirror-image attack: an attacker registers the victim's address locally
31
+ * with a password they know, never verifies it, and waits for the victim's
32
+ * first "Sign in with Google" to link the victim's identity onto an account
33
+ * the attacker can still sign in to. What IS configurable is what happens
24
34
  * when no link row exists — `config.auth.socials.matching`:
25
35
  *
26
36
  * - `'link'` (default): a verified-email match links to the existing user;
@@ -49,19 +59,32 @@ export declare interface SocialSignInResult {
49
59
  }
50
60
  /**
51
61
  * Storage the policy runs against. The default store reads and writes the
52
- * `social_accounts` and `users` tables; tests inject their own.
62
+ * `social_accounts` and `users` tables; tests inject their own. Every method
63
+ * receives the `tx` passed to {@link resolveSocialSignIn} (undefined when
64
+ * none was).
53
65
  */
54
66
  export declare interface SocialSignInStore {
55
- findLink: (provider: string, providerUserId: string) => Promise<{ userId: number } | undefined>
56
- createLink: (link: { userId: number, provider: string, providerUserId: string, providerEmail: string | null }) => Promise<void>
57
- findUserIdByEmail: (email: string) => Promise<number | undefined>
58
- createUser: (attrs: { name: string, email: string }) => Promise<number>
67
+ findLink: (provider: string, providerUserId: string, tx?: SocialSignInTransaction) => Promise<{ userId: number } | undefined>
68
+ createLink: (link: { userId: number, provider: string, providerUserId: string, providerEmail: string | null }, tx?: SocialSignInTransaction) => Promise<void>
69
+ findUserIdByEmail: (email: string, tx?: SocialSignInTransaction) => Promise<number | undefined>
70
+ createUser: (attrs: { name: string, email: string, emailVerified: boolean }, tx?: SocialSignInTransaction) => Promise<number>
71
+ isUserEmailVerified?: (userId: number, tx?: SocialSignInTransaction) => Promise<boolean>
72
+ }
73
+ export declare interface ResolveSocialSignInOptions {
74
+ tx?: SocialSignInTransaction
59
75
  }
60
76
  export type SocialMatchingPolicy = 'link' | 'create' | 'refuse';
61
77
  export type SocialRefusalReason = | 'no-linked-account'
62
78
  | 'unverified-provider-email'
79
+ | 'unverified-local-email'
63
80
  | 'no-email-to-create-with'
64
81
  | 'email-already-registered';
82
+ /**
83
+ * A transaction handle (`db.transaction(async tx => ...)`), threaded through
84
+ * to every store method so the lookup, the user and the link commit together.
85
+ */
86
+ export type SocialSignInTransaction = unknown;
87
+ declare type Queryable = typeof db;
65
88
  /** A sign-in the policy declines. `reason` is safe to show a visitor. */
66
89
  export declare class SocialSignInRefusedError extends Error {
67
90
  readonly reason: SocialRefusalReason;
package/dist/socials.js CHANGED
@@ -1 +1 @@
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}}
1
+ import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{findAuthUserByEmail}from"./credential-user";import{isEmailVerified}from"./email-verification";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function on(tx){return tx??db}export async function isUserEmailVerified(userId,tx){const row=await(tx?on(tx):db.primary).selectFrom("users").select(["email_verified_at"]).where("id","=",userId).executeTakeFirst();return row?isEmailVerified(row):!1}export function defaultSocialSignInStore(){return defaultStore()}function defaultStore(){return{async findLink(provider,providerUserId,tx){const row=await on(tx).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,tx){await on(tx).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,tx){if(tx){const row=await on(tx).selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();if(row?.id!==void 0)return Number(row.id)}const user=await findAuthUserByEmail(email);return user?Number(user.id):void 0},isUserEmailVerified,async createUser(attrs,tx){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),verifiedAt=attrs.emailVerified?sqlDateTime(new Date):null,created=tx?void 0:await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id){const id=Number(created.id);if(verifiedAt)await db.updateTable("users").set({email_verified_at:verifiedAt}).where("id","=",id).execute();return id}const queryable=on(tx);await queryable.insertInto("users").values({name:attrs.name,email:attrs.email,password,...verifiedAt?{email_verified_at:verifiedAt}:{},created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();const inserted=await queryable.selectFrom("users").select(["id"]).where("email","=",attrs.email).executeTakeFirst(),id=Number(inserted?.id??0);if(!Number.isSafeInteger(id)||id<=0)throw Error("[auth/socials] the new user could not be read back after insert.");return id}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore(),options={}){const{tx}=options,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,tx);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,tx);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.`);if(await(store.isUserEmailVerified??isUserEmailVerified)(matchedUserId,tx)!==!0)throw new SocialSignInRefusedError("unverified-local-email",`An account with ${email} already exists here, but its address has not been verified. Sign in with your password and verify your email, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email},tx);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,tx)!==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,emailVerified:identity.emailVerified===!0},tx);await store.createLink({userId,provider,providerUserId,providerEmail:email},tx);return{userId,createdUser:!0,linked:!0}}
package/dist/tokens.d.ts CHANGED
@@ -239,13 +239,16 @@ export declare function revokeTokenById(tokenId: number): Promise<void>;
239
239
  */
240
240
  export declare function revokeAllTokens(userId: number, tokenableType?: string): Promise<void>;
241
241
  /**
242
- * Revoke all tokens except the current one
242
+ * Revoke every token except one: `options.exceptTokenId`, or else the token
243
+ * the current request authenticated with. When neither resolves, every token
244
+ * is revoked and `keptTokenId` is null (fail closed). `Auth.revokeOtherTokens`
245
+ * behaves identically.
243
246
  *
244
247
  * @example
245
248
  * import { revokeOtherTokens } from '@stacksjs/auth'
246
- * await revokeOtherTokens(user.id)
249
+ * const { keptTokenId } = await revokeOtherTokens(user.id)
247
250
  */
248
- export declare function revokeOtherTokens(userId: number, tokenableType?: string): Promise<void>;
251
+ export declare function revokeOtherTokens(userId: number, tokenableType?: string, options?: { exceptTokenId?: number }): Promise<{ keptTokenId: number | null }>;
249
252
  /**
250
253
  * Delete expired tokens (cleanup)
251
254
  *
package/dist/tokens.js CHANGED
@@ -141,7 +141,7 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
141
141
  `);markContextWrote();return mutationCount(result)}export async function deleteRevokedRefreshTokens(daysOld=7){const{boolTrue,param}=tokenSql(),cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const result=await db.unsafe(`
142
142
  DELETE FROM oauth_refresh_tokens
143
143
  WHERE revoked = ${boolTrue} AND created_at < ${param(1)}
144
- `,[sqlDateTime(cutoffDate)]);markContextWrote();return mutationCount(result)}export async function revokeToken(plainTextToken){await revokeTokenPair({hash:bearerLookupHash(plainTextToken)})}export async function revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeTokenPairs(userId,tokenableType)}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId,tokenableType);await revokeTokenPairs(userId,tokenableType,current.id)}export async function deleteExpiredTokens(){const{isPostgres,isMysql,boolFalse,param}=tokenSql(),cutoff=appNow();return db.transaction(async(trx)=>{let deleted=0,cursor;while(!0){const rows=await trx.unsafe(`
144
+ `,[sqlDateTime(cutoffDate)]);markContextWrote();return mutationCount(result)}export async function revokeToken(plainTextToken){await revokeTokenPair({hash:bearerLookupHash(plainTextToken)})}export async function revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeTokenPairs(userId,tokenableType)}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE,options={}){const keep=options.exceptTokenId??(await currentAccessToken())?.id;await revokeTokenPairs(userId,tokenableType,keep);return{keptTokenId:keep??null}}export async function deleteExpiredTokens(){const{isPostgres,isMysql,boolFalse,param}=tokenSql(),cutoff=appNow();return db.transaction(async(trx)=>{let deleted=0,cursor;while(!0){const rows=await trx.unsafe(`
145
145
  SELECT t.id FROM oauth_access_tokens t
146
146
  WHERE t.expires_at <= ${cutoff}
147
147
  ${cursor===void 0?"":`AND t.id > ${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.226",
5
+ "version": "0.75.230",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -59,18 +59,19 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@stacksjs/bun-router": "^0.1.24",
62
- "@stacksjs/cache": "0.75.226",
63
- "@stacksjs/config": "0.75.226",
64
- "@stacksjs/database": "0.75.226",
65
- "@stacksjs/email": "0.75.226",
66
- "@stacksjs/env": "0.75.226",
67
- "@stacksjs/error-handling": "0.75.226",
68
- "@stacksjs/logging": "0.75.226",
69
- "@stacksjs/orm": "0.75.226",
70
- "@stacksjs/path": "0.75.226",
71
- "@stacksjs/router": "0.75.226",
72
- "@stacksjs/security": "0.75.226",
73
- "@stacksjs/storage": "0.75.226",
62
+ "@stacksjs/cache": "0.75.230",
63
+ "@stacksjs/config": "0.75.230",
64
+ "@stacksjs/database": "0.75.230",
65
+ "@stacksjs/email": "0.75.230",
66
+ "@stacksjs/env": "0.75.230",
67
+ "@stacksjs/error-handling": "0.75.230",
68
+ "@stacksjs/logging": "0.75.230",
69
+ "@stacksjs/orm": "0.75.230",
70
+ "@stacksjs/path": "0.75.230",
71
+ "@stacksjs/router": "0.75.230",
72
+ "@stacksjs/security": "0.75.230",
73
+ "@stacksjs/socials": "0.75.230",
74
+ "@stacksjs/storage": "0.75.230",
74
75
  "@stacksjs/ts-auth": "^0.4.7",
75
76
  "ts-qr-codes": "^0.1.8"
76
77
  },