@stacksjs/auth 0.74.46 → 0.74.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";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.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().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 validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().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!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().executeTakeFirst();if(!result)return null;const token=result;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:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);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=options?.expiresAt??new Date(Date.now()+accessTtlMs),expiresInMinutes=Math.max(1,Math.floor((expiresAt.getTime()-Date.now())/60000)),refreshExpiresInDays=options?.refreshExpiresInDays??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresInMinutes,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 requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();return}}const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=await this.getTokenFromId(accessToken.id)??void 0;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id"]).executeTakeFirst();if(!accessToken)return;const token=await this.getTokenFromId(Number(accessToken.id));if(token&&state)state.currentToken=token;return token??void 0}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.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).selectAll().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:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}))}static async revokeToken(token){const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(token)).select(["id"]).executeTakeFirst();if(accessToken)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("token","=",hashToken(token)).execute()}static async revokeTokenById(tokenId){await this.revokeRefreshTokensFor(tokenId);await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("id","=",tokenId).execute()}static async revokeRefreshTokensFor(accessTokenId){await db.updateTable("oauth_refresh_tokens").set({revoked:!0}).where("access_token_id","=",accessTokenId).execute()}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;const accessTokens=await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).select(["id"]).execute();for(const accessToken of accessTokens)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).execute()}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("id","!=",currentToken.id).execute()}static async pruneExpiredTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;const remainingMs=existing.expiresAt?existing.expiresAt.getTime()-Date.now():config.auth.tokenExpiry??3600000,expiresInMinutes=Math.max(1,Math.floor(remainingMs/60000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresInMinutes,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",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),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}}}
1
+ import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";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.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().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 validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().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!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().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:parseSqlDateTime(token.expires_at),createdAt:parseSqlDateTime(token.created_at)??new Date,updatedAt:parseSqlDateTime(token.updated_at)??new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);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();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);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??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,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 requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();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)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at&&(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(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))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.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).selectAll().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:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,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(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;if((await db.selectFrom("oauth_access_tokens").where("id","=",existing.id).select("tokenable_type").executeTakeFirst())?.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const expiresAt=existing.expiresAt??new Date(Date.now()+(config.auth.tokenExpiry??3600000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresAt,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",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),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/client.js CHANGED
@@ -1 +1 @@
1
- import{randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{formatDate}from"@stacksjs/orm";import{err,HttpError,ok}from"@stacksjs/error-handling";import{makeHash}from"@stacksjs/security";export async function createPersonalAccessClient(){if((await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id"]).executeTakeFirst())?.id)return err({code:"already-exists",message:"A personal access client already exists. There is no CLI command to revoke one yet - delete its row from `oauth_clients` (or use the existing client) before creating another."});const secret=randomBytes(40).toString("hex"),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"});await db.insertInto("oauth_clients").values({name:"Personal Access Client",secret:hashedSecret,provider:"local",redirect:"http://localhost",personal_access_client:!0,password_client:!1,revoked:!1,created_at:formatDate(new Date)}).execute();if(!(await db.selectFrom("oauth_clients").where("secret","=",hashedSecret).select(["id"]).executeTakeFirst())?.id)throw new HttpError(500,"Failed to create personal access client");return ok(secret)}
1
+ import{randomBytes}from"node:crypto";import{db}from"@stacksjs/database/runtime";import{formatDate}from"@stacksjs/orm";import{err,HttpError,ok}from"@stacksjs/error-handling";import{makeHash}from"@stacksjs/security";export async function createPersonalAccessClient(){if((await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id"]).executeTakeFirst())?.id)return err({code:"already-exists",message:"A personal access client already exists. There is no CLI command to revoke one yet - delete its row from `oauth_clients` (or use the existing client) before creating another."});const secret=randomBytes(40).toString("hex"),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"});await db.insertInto("oauth_clients").values({name:"Personal Access Client",secret:hashedSecret,provider:"local",redirect:"http://localhost",personal_access_client:!0,password_client:!1,revoked:!1,created_at:formatDate(new Date)}).execute();if(!(await db.selectFrom("oauth_clients").where("secret","=",hashedSecret).select(["id"]).executeTakeFirst())?.id)throw new HttpError(500,"Failed to create personal access client");return ok(secret)}
@@ -1 +1 @@
1
- import{authCookieToken,clearAuthCookie}from"./cookie";import{Auth}from"./authentication";export{authCookie,authCookieName,authCookieToken,clearAuthCookie,shouldSecureAuthCookie}from"./cookie";export async function userFromCookie(request,options={}){const token=authCookieToken(request,options);if(!token)return;return Auth.getUserFromToken(token)}export async function cookieCheck(request,options={}){return Boolean(await userFromCookie(request,options))}export async function logoutCookie(request,options={}){const token=authCookieToken(request,options);if(token)try{await Auth.revokeToken(token)}catch{}return clearAuthCookie(options)}
1
+ import{authCookieToken,clearAuthCookie}from"./cookie";import{Auth}from"./authentication";export{authCookie,authCookieName,authCookieToken,clearAuthCookie,shouldSecureAuthCookie}from"./cookie";export async function userFromCookie(request,options={}){const token=authCookieToken(request,options);if(!token)return;return Auth.getUserFromToken(token)}export async function cookieCheck(request,options={}){return Boolean(await userFromCookie(request,options))}export async function logoutCookie(request,options={}){const token=authCookieToken(request,options);if(token)await Auth.revokeToken(token);return clearAuthCookie(options)}
package/dist/cookie.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a cookie name - set config.auth.cookie.name instead (stacksjs/stacks#2236).`)}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS - the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}return}
1
+ import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a cookie name - set config.auth.cookie.name instead (stacksjs/stacks#2236).`)}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS - the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;try{const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}catch{return}}return}
@@ -1,3 +1,3 @@
1
- import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - email-verification HMAC requires a real APP_KEY. Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");return appKey}function generateVerificationToken(userId){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,token,storedHash){const payload=`${userId}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){const base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.emailVerification?.url??"/verify-email/{id}/{token}").replace("{id}",String(userId)).replace("{token}",token);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){const{token,hash}=generateVerificationToken(user.id),expiryMinutes=getExpiryMinutes(),expiresAt=new Date(Date.now()+expiryMinutes*60*1000);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt)}).executeTakeFirst();const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
1
+ import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - email-verification HMAC requires a real APP_KEY. Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");return appKey}function generateVerificationToken(userId){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,token,storedHash){const payload=`${userId}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){const base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.emailVerification?.url??"/verify-email/{id}/{token}").replace("{id}",String(userId)).replace("{token}",token);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){const{token,hash}=generateVerificationToken(user.id),expiryMinutes=getExpiryMinutes(),expiresAt=new Date(Date.now()+expiryMinutes*60*1000);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt)}).executeTakeFirst();const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
2
2
 
3
3
  This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){const record=await db.selectFrom("email_verifications").where("user_id","=",userId).selectAll().executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||new Date>expiresAt){await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}if(!verifyToken(userId,token,record.token))return{success:!1,message:"Invalid verification link."};await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).executeTakeFirst();await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!0,message:"Email verified successfully."}}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const existing=await db.selectFrom("email_verifications").where("user_id","=",user.id).selectAll().executeTakeFirst();if(existing){const createdAt=new Date(existing.created_at),secondsSince=(Date.now()-createdAt.getTime())/1000;if(Number.isNaN(secondsSince))return{success:!1,message:"Please wait a moment before requesting another verification email."};if(secondsSince<60)return{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}await sendVerificationEmail(user);return{success:!0,message:"Verification email sent."}}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
@@ -1,3 +1,3 @@
1
- import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
1
+ import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
2
2
 
3
3
  This link works once and expires in ${ttl} minutes. If you didn't request it, ignore this email.`})}catch(error){log.error(`Magic-link email to ${normalized} failed: ${error.message}`)}}export async function consumeMagicLink(raw){if(!raw||raw.length>255)return{ok:!1,reason:"invalid"};const hashed=hashToken(raw),now=sqlDateTime(new Date),claim=await db.updateTable("magic_link_tokens").set({consumed_at:now,updated_at:now}).where("token","=",hashed).where("consumed_at","is",null).where("expires_at",">",now).execute(),updated=typeof claim==="number"?claim:Number(claim?.numUpdatedRows??claim?.changes??0),row=await db.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","email","expires_at","consumed_at","redirect_to"]).executeTakeFirst();if(!row)return{ok:!1,reason:"invalid"};if(updated<1){if(!row.consumed_at&&new Date(row.expires_at).getTime()<=Date.now())return{ok:!1,reason:"expired"};return{ok:!1,reason:"used"}}if(!row.user_id)return{ok:!1,reason:"no-user"};return{ok:!0,userId:Number(row.user_id),email:row.email,redirectTo:safeRedirect(row.redirect_to)}}export async function pruneMagicLinkTokens(olderThanDays=7){const cutoff=sqlDateTime(new Date(Date.now()-olderThanDays*86400000));await db.deleteFrom("magic_link_tokens").where("expires_at","<",cutoff).execute()}
package/dist/passkey.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { User } from '@stacksjs/orm';
2
- import type { Insertable } from '@stacksjs/database';
2
+ import type { Insertable } from '@stacksjs/database/runtime';
3
3
  import type { VerifiedRegistrationResponse } from '@stacksjs/ts-auth';
4
4
  // Re-export WebAuthn types from ts-auth
5
5
  export type {
package/dist/passkey.js CHANGED
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{User}from"@stacksjs/orm";export{generateRegistrationOptions,generateAuthenticationOptions,verifyRegistrationResponse,verifyAuthenticationResponse,startRegistration,startAuthentication,browserSupportsWebAuthn,browserSupportsWebAuthnAutofill,platformAuthenticatorIsAvailable}from"@stacksjs/ts-auth";export function passkeyDescriptors(passkeys){return passkeys.map((passkey)=>({id:passkey.id,type:"public-key",transports:parseTransports(passkey.transports)}))}function parseTransports(stored){if(!stored)return["internal"];try{const parsed=JSON.parse(stored);return Array.isArray(parsed)&&parsed.length?parsed:["internal"]}catch{return["internal"]}}export async function getUserPasskeys(userId){return await db.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){const passkey=await getUserPasskey(userId,passkeyId);if(!passkey)return!1;const stored=Number(passkey.counter??0);if(newCounter<=stored&&!(newCounter===0&&stored===0))return!1;await db.updateTable("passkeys").set({counter:newCounter,last_used_at:formatDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0}export async function setCurrentRegistrationOptions(user,verified){const credentialId=verified.registrationInfo?.credential.id,credentialPublicKey=verified.registrationInfo?.credential.publicKey;if(!credentialId)throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");if(!credentialPublicKey)throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");const passkeyData={id:credentialId,cred_public_key:JSON.stringify(credentialPublicKey),user_id:user.id,webauthn_user_id:user.email||"",counter:verified.registrationInfo?.credential.counter||0,credential_type:verified.registrationInfo?.credentialType||"",device_type:verified.registrationInfo?.credentialDeviceType||"",backup_eligible:!1,backup_status:verified.registrationInfo?.credentialBackedUp||!1,transports:JSON.stringify(["internal"]),last_used_at:formatDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}function formatDateTime(){const date=new Date,pad=(num)=>String(num).padStart(2,"0"),year=date.getFullYear(),month=pad(date.getMonth()+1),day=pad(date.getDate()),hours=pad(date.getHours()),minutes=pad(date.getMinutes()),seconds=pad(date.getSeconds());return`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`}const DEFAULT_CHALLENGE_TTL_SECONDS=300;export async function storeWebAuthnChallenge(userId,challenge,purpose,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000)),encodedChallenge=typeof challenge==="string"?challenge:Buffer.from(challenge).toString("base64url");await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();await db.insertInto("webauthn_challenges").values({user_id:userId,challenge:encodedChallenge,purpose,expires_at:expiresAt}).execute()}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Buffer.from(String(row.challenge),"base64url")}
1
+ import{Buffer}from"node:buffer";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{User}from"@stacksjs/orm";export{generateRegistrationOptions,generateAuthenticationOptions,verifyRegistrationResponse,verifyAuthenticationResponse,startRegistration,startAuthentication,browserSupportsWebAuthn,browserSupportsWebAuthnAutofill,platformAuthenticatorIsAvailable}from"@stacksjs/ts-auth";export function passkeyDescriptors(passkeys){return passkeys.map((passkey)=>({id:passkey.id,type:"public-key",transports:parseTransports(passkey.transports)}))}function parseTransports(stored){if(!stored)return["internal"];try{const parsed=JSON.parse(stored);return Array.isArray(parsed)&&parsed.length?parsed:["internal"]}catch{return["internal"]}}export async function getUserPasskeys(userId){return await db.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){const passkey=await getUserPasskey(userId,passkeyId);if(!passkey)return!1;const stored=Number(passkey.counter??0);if(newCounter<=stored&&!(newCounter===0&&stored===0))return!1;await db.updateTable("passkeys").set({counter:newCounter,last_used_at:formatDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0}export async function setCurrentRegistrationOptions(user,verified){const credentialId=verified.registrationInfo?.credential.id,credentialPublicKey=verified.registrationInfo?.credential.publicKey;if(!credentialId)throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");if(!credentialPublicKey)throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");const passkeyData={id:credentialId,cred_public_key:JSON.stringify(credentialPublicKey),user_id:user.id,webauthn_user_id:user.email||"",counter:verified.registrationInfo?.credential.counter||0,credential_type:verified.registrationInfo?.credentialType||"",device_type:verified.registrationInfo?.credentialDeviceType||"",backup_eligible:!1,backup_status:verified.registrationInfo?.credentialBackedUp||!1,transports:JSON.stringify(["internal"]),last_used_at:formatDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}function formatDateTime(){const date=new Date,pad=(num)=>String(num).padStart(2,"0"),year=date.getFullYear(),month=pad(date.getMonth()+1),day=pad(date.getDate()),hours=pad(date.getHours()),minutes=pad(date.getMinutes()),seconds=pad(date.getSeconds());return`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`}const DEFAULT_CHALLENGE_TTL_SECONDS=300;export async function storeWebAuthnChallenge(userId,challenge,purpose,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000)),encodedChallenge=typeof challenge==="string"?challenge:Buffer.from(challenge).toString("base64url");await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();await db.insertInto("webauthn_challenges").values({user_id:userId,challenge:encodedChallenge,purpose,expires_at:expiresAt}).execute()}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Buffer.from(String(row.challenge),"base64url")}
@@ -1,3 +1,3 @@
1
- import{randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{formatDate}from"@stacksjs/orm";import{makeHash,verifyHash}from"@stacksjs/security";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(typeof explicit==="string"||explicit instanceof Date)return new Date(explicit).getTime()>Date.now();const created=row.created_at;if(typeof created==="string"||created instanceof Date){const expireMinutes=getTokenExpireMinutes();return new Date(created).getTime()+expireMinutes*60000>Date.now()}return!1}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes(),expiresAt=sqlDateTime(new Date(Date.now()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt}).executeTakeFirst();return token}async function sendEmail(){if(!await db.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())return;const token=await createResetToken(),expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.passwordReset?.url??"/password/reset/{token}?email={email}").replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{formatDate}from"@stacksjs/orm";import{makeHash,verifyHash}from"@stacksjs/security";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(typeof explicit==="string"||explicit instanceof Date)return new Date(explicit).getTime()>Date.now();const created=row.created_at;if(typeof created==="string"||created instanceof Date){const expireMinutes=getTokenExpireMinutes();return new Date(created).getTime()+expireMinutes*60000>Date.now()}return!1}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes(),expiresAt=sqlDateTime(new Date(Date.now()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt}).executeTakeFirst();return token}async function sendEmail(){if(!await db.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())return;const token=await createResetToken(),expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.passwordReset?.url??"/password/reset/{token}?email={email}").replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
2
2
 
3
3
  This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,resetRecord=await trx.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const user=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});try{await trx.updateTable("users").set({password:hashedPassword,password_changed_at:formatDate(new Date)}).where("email","=",email).executeTakeFirst()}catch(err){const message=err instanceof Error?err.message:String(err);if(/password_changed_at|no such column|unknown column/i.test(message)){log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");await trx.updateTable("users").set({password:hashedPassword}).where("email","=",email).executeTakeFirst()}else throw err}await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!0,userId:Number(user.id)}});if(result.success){await revokeAllTokens(result.userId);await sessionDestroyAll(result.userId);sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database";import{isUniqueViolation}from"@stacksjs/orm";export{isUniqueViolation};export function swallowDuplicate(err){if(!isUniqueViolation(err))throw err}export function toRecord(row){if(!row)return null;return{id:Number(row.id),name:String(row.name),guard_name:String(row.guard_name),description:row.description==null?void 0:String(row.description),created_at:row.created_at==null?void 0:String(row.created_at),updated_at:row.updated_at==null?void 0:String(row.updated_at)}}async function insertAndFetch(table,name,guardName,description){await db.insertInto(table).values({name,guard_name:guardName,description:description??null}).execute();const row=await db.selectFrom(table).selectAll().where("name","=",name).where("guard_name","=",guardName).orderBy("id","desc").limit(1).executeTakeFirst(),rec=toRecord(row);if(!rec)throw Error(`[rbac] insertAndFetch(${table}, ${name}, ${guardName}) succeeded but follow-up SELECT returned nothing`);return rec}async function pivotAttach(table,cols){let q=db.selectFrom(table).select("user_id"in cols?"user_id":"role_id");for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);if(await q.limit(1).executeTakeFirst())return;try{await db.insertInto(table).values(cols).execute()}catch(err){swallowDuplicate(err)}}async function pivotDetach(table,cols){let q=db.deleteFrom(table);for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);await q.execute()}async function pivotDetachAll(table,column,value){await db.deleteFrom(table).where(column,"=",value).execute()}async function pivotSync(table,ownerColumn,ownerId,targetColumn,targetIds){const unique=Array.from(new Set(targetIds));await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.deleteFrom(table).where(ownerColumn,"=",ownerId).execute();if(unique.length===0)return;const rows=unique.map((id)=>({[ownerColumn]:ownerId,[targetColumn]:id}));try{await trx.insertInto(table).values(rows).execute()}catch(err){swallowDuplicate(err);for(const id of unique)try{await trx.insertInto(table).values({[ownerColumn]:ownerId,[targetColumn]:id}).execute()}catch(innerErr){swallowDuplicate(innerErr)}}})}export function createBqbRbacStore(){return{async findRoleByName(name,guardName="web"){const row=await db.selectFrom("roles").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.selectFrom("roles").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createRole(name,guardName="web",description){return await insertAndFetch("roles",name,guardName,description)},async deleteRole(id){await db.deleteFrom("user_roles").where("role_id","=",id).execute();await db.deleteFrom("role_permissions").where("role_id","=",id).execute();await db.deleteFrom("roles").where("id","=",id).execute()},async getAllRoles(guardName){let q=db.selectFrom("roles").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async findPermissionByName(name,guardName="web"){const row=await db.selectFrom("permissions").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.selectFrom("permissions").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createPermission(name,guardName="web",description){return await insertAndFetch("permissions",name,guardName,description)},async deletePermission(id){await db.deleteFrom("user_permissions").where("permission_id","=",id).execute();await db.deleteFrom("role_permissions").where("permission_id","=",id).execute();await db.deleteFrom("permissions").where("id","=",id).execute()},async getAllPermissions(guardName){let q=db.selectFrom("permissions").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async getUserRoles(userId){return(await db.selectFrom("roles").innerJoin("user_roles","user_roles.role_id","=","roles.id").select(["roles.id as id","roles.name as name","roles.guard_name as guard_name","roles.description as description","roles.created_at as created_at","roles.updated_at as updated_at"]).where("user_roles.user_id","=",userId).orderBy("roles.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignRoleToUser(userId,roleId){return pivotAttach("user_roles",{user_id:userId,role_id:roleId})},removeRoleFromUser(userId,roleId){return pivotDetach("user_roles",{user_id:userId,role_id:roleId})},removeAllRolesFromUser(userId){return pivotDetachAll("user_roles","user_id",userId)},syncUserRoles(userId,roleIds){return pivotSync("user_roles","user_id",userId,"role_id",roleIds)},async getUserDirectPermissions(userId){return(await db.selectFrom("permissions").innerJoin("user_permissions","user_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("user_permissions.user_id","=",userId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToUser(userId,permissionId){return pivotAttach("user_permissions",{user_id:userId,permission_id:permissionId})},removePermissionFromUser(userId,permissionId){return pivotDetach("user_permissions",{user_id:userId,permission_id:permissionId})},removeAllPermissionsFromUser(userId){return pivotDetachAll("user_permissions","user_id",userId)},syncUserPermissions(userId,permissionIds){return pivotSync("user_permissions","user_id",userId,"permission_id",permissionIds)},async getRolePermissions(roleId){return(await db.selectFrom("permissions").innerJoin("role_permissions","role_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("role_permissions.role_id","=",roleId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToRole(roleId,permissionId){return pivotAttach("role_permissions",{role_id:roleId,permission_id:permissionId})},removePermissionFromRole(roleId,permissionId){return pivotDetach("role_permissions",{role_id:roleId,permission_id:permissionId})},syncRolePermissions(roleId,permissionIds){return pivotSync("role_permissions","role_id",roleId,"permission_id",permissionIds)}}}
1
+ import{db}from"@stacksjs/database/runtime";import{isUniqueViolation}from"@stacksjs/orm";export{isUniqueViolation};export function swallowDuplicate(err){if(!isUniqueViolation(err))throw err}export function toRecord(row){if(!row)return null;return{id:Number(row.id),name:String(row.name),guard_name:String(row.guard_name),description:row.description==null?void 0:String(row.description),created_at:row.created_at==null?void 0:String(row.created_at),updated_at:row.updated_at==null?void 0:String(row.updated_at)}}async function insertAndFetch(table,name,guardName,description){await db.insertInto(table).values({name,guard_name:guardName,description:description??null}).execute();const row=await db.selectFrom(table).selectAll().where("name","=",name).where("guard_name","=",guardName).orderBy("id","desc").limit(1).executeTakeFirst(),rec=toRecord(row);if(!rec)throw Error(`[rbac] insertAndFetch(${table}, ${name}, ${guardName}) succeeded but follow-up SELECT returned nothing`);return rec}async function pivotAttach(table,cols){let q=db.selectFrom(table).select("user_id"in cols?"user_id":"role_id");for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);if(await q.limit(1).executeTakeFirst())return;try{await db.insertInto(table).values(cols).execute()}catch(err){swallowDuplicate(err)}}async function pivotDetach(table,cols){let q=db.deleteFrom(table);for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);await q.execute()}async function pivotDetachAll(table,column,value){await db.deleteFrom(table).where(column,"=",value).execute()}async function pivotSync(table,ownerColumn,ownerId,targetColumn,targetIds){const unique=Array.from(new Set(targetIds));await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.deleteFrom(table).where(ownerColumn,"=",ownerId).execute();if(unique.length===0)return;const rows=unique.map((id)=>({[ownerColumn]:ownerId,[targetColumn]:id}));try{await trx.insertInto(table).values(rows).execute()}catch(err){swallowDuplicate(err);for(const id of unique)try{await trx.insertInto(table).values({[ownerColumn]:ownerId,[targetColumn]:id}).execute()}catch(innerErr){swallowDuplicate(innerErr)}}})}export function createBqbRbacStore(){return{async findRoleByName(name,guardName="web"){const row=await db.selectFrom("roles").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.selectFrom("roles").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createRole(name,guardName="web",description){return await insertAndFetch("roles",name,guardName,description)},async deleteRole(id){await db.deleteFrom("user_roles").where("role_id","=",id).execute();await db.deleteFrom("role_permissions").where("role_id","=",id).execute();await db.deleteFrom("roles").where("id","=",id).execute()},async getAllRoles(guardName){let q=db.selectFrom("roles").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async findPermissionByName(name,guardName="web"){const row=await db.selectFrom("permissions").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.selectFrom("permissions").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createPermission(name,guardName="web",description){return await insertAndFetch("permissions",name,guardName,description)},async deletePermission(id){await db.deleteFrom("user_permissions").where("permission_id","=",id).execute();await db.deleteFrom("role_permissions").where("permission_id","=",id).execute();await db.deleteFrom("permissions").where("id","=",id).execute()},async getAllPermissions(guardName){let q=db.selectFrom("permissions").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async getUserRoles(userId){return(await db.selectFrom("roles").innerJoin("user_roles","user_roles.role_id","=","roles.id").select(["roles.id as id","roles.name as name","roles.guard_name as guard_name","roles.description as description","roles.created_at as created_at","roles.updated_at as updated_at"]).where("user_roles.user_id","=",userId).orderBy("roles.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignRoleToUser(userId,roleId){return pivotAttach("user_roles",{user_id:userId,role_id:roleId})},removeRoleFromUser(userId,roleId){return pivotDetach("user_roles",{user_id:userId,role_id:roleId})},removeAllRolesFromUser(userId){return pivotDetachAll("user_roles","user_id",userId)},syncUserRoles(userId,roleIds){return pivotSync("user_roles","user_id",userId,"role_id",roleIds)},async getUserDirectPermissions(userId){return(await db.selectFrom("permissions").innerJoin("user_permissions","user_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("user_permissions.user_id","=",userId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToUser(userId,permissionId){return pivotAttach("user_permissions",{user_id:userId,permission_id:permissionId})},removePermissionFromUser(userId,permissionId){return pivotDetach("user_permissions",{user_id:userId,permission_id:permissionId})},removeAllPermissionsFromUser(userId){return pivotDetachAll("user_permissions","user_id",userId)},syncUserPermissions(userId,permissionIds){return pivotSync("user_permissions","user_id",userId,"permission_id",permissionIds)},async getRolePermissions(roleId){return(await db.selectFrom("permissions").innerJoin("role_permissions","role_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("role_permissions.role_id","=",roleId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToRole(roleId,permissionId){return pivotAttach("role_permissions",{role_id:roleId,permission_id:permissionId})},removePermissionFromRole(roleId,permissionId){return pivotDetach("role_permissions",{role_id:roleId,permission_id:permissionId})},syncRolePermissions(roleId,permissionIds){return pivotSync("role_permissions","role_id",roleId,"permission_id",permissionIds)}}}
package/dist/referrals.js CHANGED
@@ -1 +1 @@
1
- import{randomBytes}from"node:crypto";export function normalizeReferralCode(value){if(typeof value!=="string")return null;const code=value.trim().toLowerCase();return/^[a-f0-9]{24}$/.test(code)?code:null}function userId(value){if(!Number.isSafeInteger(value)||value<1)throw TypeError("A positive integer user ID is required.")}async function connection(database){return database??(await import("@stacksjs/database")).db}async function rows(database,sql,bindings){const result=await database.unsafe(sql,bindings).execute();if(Array.isArray(result))return result;if(result&&typeof result==="object"&&"rows"in result&&Array.isArray(result.rows))return result.rows;throw Error("The referral query returned an unsupported result.")}async function duplicate(error){const{isUniqueViolation}=await import("@stacksjs/orm");return isUniqueViolation(error)}export async function createReferralCode(ownerId,database){userId(ownerId);const db=await connection(database);for(let attempt=0;attempt<5;attempt++){const existing=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]);if(existing[0])return existing[0].code;const code=randomBytes(12).toString("hex");try{await db.unsafe("INSERT INTO referral_codes (user_id, code) VALUES (?, ?)",[ownerId,code]).execute();return code}catch(error){if(!await duplicate(error))throw error}}throw Error("Could not allocate a referral code. Please retry.")}export async function attributeReferral(newUserId,input,database){userId(newUserId);const code=normalizeReferralCode(input);if(!code)return!1;const db=await connection(database),owner=(await rows(db,"SELECT code, user_id FROM referral_codes WHERE code = ?",[code]))[0];if(!owner||Number(owner.user_id)===newUserId)return!1;try{await db.unsafe("INSERT INTO referrals (referrer_id, referred_user_id, code, status) VALUES (?, ?, ?, ?)",[Number(owner.user_id),newUserId,code,"registered"]).execute();return!0}catch(error){if(await duplicate(error))return!1;throw error}}export async function qualifyReferral(referredUserId,database){userId(referredUserId);const db=await connection(database),now=new Date().toISOString().slice(0,19).replace("T"," ");await db.unsafe("UPDATE referrals SET status = ?, qualified_at = ?, updated_at = ? WHERE referred_user_id = ? AND status = ?",["qualified",now,now,referredUserId,"registered"]).execute()}export async function referralSummary(ownerId,database){userId(ownerId);const db=await connection(database),codes=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]),counts=await rows(db,"SELECT COUNT(*) AS total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS qualified FROM referrals WHERE referrer_id = ?",["qualified",ownerId]);return{code:codes[0]?.code??null,referred:Number(counts[0]?.total??0),qualified:Number(counts[0]?.qualified??0)}}
1
+ import{randomBytes}from"node:crypto";export function normalizeReferralCode(value){if(typeof value!=="string")return null;const code=value.trim().toLowerCase();return/^[a-f0-9]{24}$/.test(code)?code:null}function userId(value){if(!Number.isSafeInteger(value)||value<1)throw TypeError("A positive integer user ID is required.")}async function connection(database){return database??(await import("@stacksjs/database/runtime")).db}async function rows(database,sql,bindings){const result=await database.unsafe(sql,bindings).execute();if(Array.isArray(result))return result;if(result&&typeof result==="object"&&"rows"in result&&Array.isArray(result.rows))return result.rows;throw Error("The referral query returned an unsupported result.")}async function duplicate(error){const{isUniqueViolation}=await import("@stacksjs/orm");return isUniqueViolation(error)}export async function createReferralCode(ownerId,database){userId(ownerId);const db=await connection(database);for(let attempt=0;attempt<5;attempt++){const existing=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]);if(existing[0])return existing[0].code;const code=randomBytes(12).toString("hex");try{await db.unsafe("INSERT INTO referral_codes (user_id, code) VALUES (?, ?)",[ownerId,code]).execute();return code}catch(error){if(!await duplicate(error))throw error}}throw Error("Could not allocate a referral code. Please retry.")}export async function attributeReferral(newUserId,input,database){userId(newUserId);const code=normalizeReferralCode(input);if(!code)return!1;const db=await connection(database),owner=(await rows(db,"SELECT code, user_id FROM referral_codes WHERE code = ?",[code]))[0];if(!owner||Number(owner.user_id)===newUserId)return!1;try{await db.unsafe("INSERT INTO referrals (referrer_id, referred_user_id, code, status) VALUES (?, ?, ?, ?)",[Number(owner.user_id),newUserId,code,"registered"]).execute();return!0}catch(error){if(await duplicate(error))return!1;throw error}}export async function qualifyReferral(referredUserId,database){userId(referredUserId);const db=await connection(database),now=new Date().toISOString().slice(0,19).replace("T"," ");await db.unsafe("UPDATE referrals SET status = ?, qualified_at = ?, updated_at = ? WHERE referred_user_id = ? AND status = ?",["qualified",now,now,referredUserId,"registered"]).execute()}export async function referralSummary(ownerId,database){userId(ownerId);const db=await connection(database),codes=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]),counts=await rows(db,"SELECT COUNT(*) AS total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS qualified FROM referrals WHERE referrer_id = ?",["qualified",ownerId]);return{code:codes[0]?.code??null,referred:Number(counts[0]?.total??0),qualified:Number(counts[0]?.qualified??0)}}
package/dist/register.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials){const{email,password,name}=credentials;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if(await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
1
+ import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials){const{email,password,name}=credentials;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if(await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
@@ -36,7 +36,7 @@ export declare function sessionLogout(sessionId: string): Promise<void>;
36
36
  * session cookie survives a password reset for up to 24h
37
37
  * (stacksjs/stacks#1947).
38
38
  *
39
- * Unlike `sessionLogout`, real failures propagate (fail loud): a reset
39
+ * Like `sessionLogout`, real failures propagate (fail loud): a reset
40
40
  * that reports success while the attacker's session lives would be a
41
41
  * lie. A missing `sessions` table alone is a benign no-op — no
42
42
  * framework migration creates it (only userland adopting session-auth
@@ -1 +1 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){log.debug("[auth] Session destroyed");try{await db.deleteFrom("sessions").where("id","=",sessionId).execute()}catch(err){log.debug(`[auth] Session destroy failed: ${err.message}`)}}export async function sessionDestroyAll(userId){try{await db.deleteFrom("sessions").where("user_id","=",userId).execute()}catch(err){const message=err instanceof Error?err.message:String(err);if(message.includes("sessions")&&/no such table|does not exist|doesn't exist/i.test(message))return;throw err}}export async function sessionUser(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;const newExpiry=new Date(Date.now()+ttlMs);await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId).execute();return!0}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
1
+ import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){await db.deleteFrom("sessions").where("id","=",sessionId).execute();log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.deleteFrom("sessions").where("user_id","=",userId).execute()}catch(err){const message=err instanceof Error?err.message:String(err);if(message.includes("sessions")&&/no such table|does not exist|doesn't exist/i.test(message))return;throw err}}export async function sessionUser(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;const newExpiry=new Date(Date.now()+ttlMs),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId).returning("id").executeTakeFirst();return Boolean(result)}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
package/dist/socials.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";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 row=await db.selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();return row?Number(row.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}from"@stacksjs/database/runtime";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function defaultStore(){return{async findLink(provider,providerUserId){const row=await db.selectFrom("social_accounts").select(["user_id"]).where("provider","=",provider).where("provider_user_id","=",providerUserId).executeTakeFirst();return row?{userId:Number(row.user_id)}:void 0},async createLink(link){await db.insertInto("social_accounts").values({user_id:link.userId,provider:link.provider,provider_user_id:link.providerUserId,provider_email:link.providerEmail,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()},async findUserIdByEmail(email){const row=await db.selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();return row?Number(row.id):void 0},async createUser(attrs){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),created=await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id)return Number(created.id);const row=await db.insertInto("users").values({name:attrs.name,email:attrs.email,password,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).executeTakeFirst();return Number(row?.insertId??0)}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore()){const providerUserId=String(identity.id??"").trim();if(!provider||!providerUserId)throw TypeError("[auth/socials] a provider name and provider user id are required.");const existing=await store.findLink(provider,providerUserId);if(existing)return{userId:existing.userId,createdUser:!1,linked:!1};const matching=configuredMatching();if(matching==="refuse")throw new SocialSignInRefusedError("no-linked-account",`No ${provider} account is linked here. Sign in with your password, then connect ${provider} from your account settings.`);const email=identity.email?.trim().toLowerCase()||null,displayName=identity.name?.trim()||identity.nickname?.trim()||(email??`${provider} user`);if(matching==="link"&&email){const matchedUserId=await store.findUserIdByEmail(email);if(matchedUserId!==void 0){if(identity.emailVerified!==!0)throw new SocialSignInRefusedError("unverified-provider-email",`${provider} did not verify ${email}, and an account with that address already exists. Sign in with your password, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email});return{userId:matchedUserId,createdUser:!1,linked:!0}}}if(!email)throw new SocialSignInRefusedError("no-email-to-create-with",`${provider} shared no email address, so an account cannot be created. Grant the email permission at ${provider}, or register with an email first.`);if(matching==="create"&&await store.findUserIdByEmail(email)!==void 0)throw new SocialSignInRefusedError("email-already-registered",`An account with ${email} already exists. Sign in with your password, then connect ${provider} from your account settings.`);const userId=await store.createUser({name:displayName,email});await store.createLink({userId,provider,providerUserId,providerEmail:email});return{userId,createdUser:!0,linked:!0}}
package/dist/team.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";import{Auth}from"./authentication";import{sessionUser}from"./session-auth";import{authCookieName}from"./cookie-auth";export const ACTIVE_TEAM_COOKIE="active_team";export function selectActiveTeam(opts){const priority=opts.rolePriority??{owner:0,admin:1},sorted=[...opts.memberships??[]].sort((a,b)=>(priority[a.role??""]??2)-(priority[b.role??""]??2)),active=opts.activeTeamId==null?null:Number(opts.activeTeamId);if(active!=null&&Number.isFinite(active)){const member=sorted.find((x)=>Number(x.team_id)===active);if(member)return{teamId:active,role:member.role??null};if(opts.allowAnyTeam)return{teamId:active,role:"admin"}}const first=sorted[0];return first?{teamId:Number(first.team_id),role:first.role??null}:{teamId:null,role:null}}export function getActiveTeamPreference(request){const raw=request.cookies?.get(ACTIVE_TEAM_COOKIE);if(!raw)return null;const n=Number(raw);return Number.isFinite(n)&&n>0?n:null}export function buildActiveTeamCookie(teamId,opts={}){const maxAge=Math.max(0,Math.floor(opts.maxAgeSeconds??31536000)),parts=[`${ACTIVE_TEAM_COOKIE}=${encodeURIComponent(String(teamId))}`,"Path=/","HttpOnly","SameSite=Lax",`Max-Age=${maxAge}`];if(opts.secure)parts.push("Secure");return parts.join("; ")}export function clearActiveTeamCookie(){return`${ACTIVE_TEAM_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`}export async function resolveAuthenticatedMembership(request){const user=await resolveAuthenticatedUser(request);if(!user?.id)return null;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute();if(memberships.length===0)return null;const picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:m.role==null?null:String(m.role)})),activeTeamId:getActiveTeamPreference(request)});return picked.teamId!=null?{teamId:picked.teamId,role:picked.role??""}:null}export async function resolveTeamContext(request,opts={}){const empty={user:null,teamId:null,role:null,teams:[],activeTeamId:null},guardName=config.auth?.default||"api",user=(config.auth?.guards?.[guardName]?.driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);if(!user?.id)return empty;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute(),allowAny=opts.allowAnyTeam?!!opts.allowAnyTeam(user):!1,activeTeamId=getActiveTeamPreference(request),picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:String(m.role)})),activeTeamId,allowAnyTeam:allowAny}),roleByTeam=new Map(memberships.map((m)=>[Number(m.team_id),String(m.role)]));let teamRows=[];if(allowAny)teamRows=await db.selectFrom("teams").select(["id","name"]).execute();else{const ids=[...roleByTeam.keys()];teamRows=ids.length?await db.selectFrom("teams").whereIn("id",ids).select(["id","name"]).execute():[]}const teams=teamRows.map((t)=>({id:Number(t.id),name:String(t.name),role:roleByTeam.get(Number(t.id))||"viewer"})).sort((a,b)=>a.name.localeCompare(b.name));return{user,teamId:picked.teamId,role:picked.role,teams,activeTeamId}}export async function resolveAuthenticatedTeamId(request){const membership=await resolveAuthenticatedMembership(request);return membership?membership.teamId:null}export async function resolveAuthenticatedUser(request){const guardName=config.auth?.default||"api",user=((config.auth?.guards?.[guardName]||{driver:"token"}).driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);return user?.id?user:void 0}async function resolveSessionUser(request){const sessionId=request.cookies?.get("session_id");if(!sessionId)return;return sessionUser(sessionId)}async function resolveTokenUser(request){const cookieName=authCookieName(),bearer=(typeof request.bearerToken==="function"?request.bearerToken():void 0)??request.cookies?.get(cookieName);if(!bearer)return;return Auth.getUserFromToken(bearer)}
1
+ import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{Auth}from"./authentication";import{sessionUser}from"./session-auth";import{authCookieName}from"./cookie-auth";export const ACTIVE_TEAM_COOKIE="active_team";export function selectActiveTeam(opts){const priority=opts.rolePriority??{owner:0,admin:1},sorted=[...opts.memberships??[]].sort((a,b)=>(priority[a.role??""]??2)-(priority[b.role??""]??2)),active=opts.activeTeamId==null?null:Number(opts.activeTeamId);if(active!=null&&Number.isFinite(active)){const member=sorted.find((x)=>Number(x.team_id)===active);if(member)return{teamId:active,role:member.role??null};if(opts.allowAnyTeam)return{teamId:active,role:"admin"}}const first=sorted[0];return first?{teamId:Number(first.team_id),role:first.role??null}:{teamId:null,role:null}}export function getActiveTeamPreference(request){const raw=request.cookies?.get(ACTIVE_TEAM_COOKIE);if(!raw)return null;const n=Number(raw);return Number.isFinite(n)&&n>0?n:null}export function buildActiveTeamCookie(teamId,opts={}){const maxAge=Math.max(0,Math.floor(opts.maxAgeSeconds??31536000)),parts=[`${ACTIVE_TEAM_COOKIE}=${encodeURIComponent(String(teamId))}`,"Path=/","HttpOnly","SameSite=Lax",`Max-Age=${maxAge}`];if(opts.secure)parts.push("Secure");return parts.join("; ")}export function clearActiveTeamCookie(){return`${ACTIVE_TEAM_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`}export async function resolveAuthenticatedMembership(request){const user=await resolveAuthenticatedUser(request);if(!user?.id)return null;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute();if(memberships.length===0)return null;const picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:m.role==null?null:String(m.role)})),activeTeamId:getActiveTeamPreference(request)});return picked.teamId!=null?{teamId:picked.teamId,role:picked.role??""}:null}export async function resolveTeamContext(request,opts={}){const empty={user:null,teamId:null,role:null,teams:[],activeTeamId:null},guardName=config.auth?.default||"api",user=(config.auth?.guards?.[guardName]?.driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);if(!user?.id)return empty;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute(),allowAny=opts.allowAnyTeam?!!opts.allowAnyTeam(user):!1,activeTeamId=getActiveTeamPreference(request),picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:String(m.role)})),activeTeamId,allowAnyTeam:allowAny}),roleByTeam=new Map(memberships.map((m)=>[Number(m.team_id),String(m.role)]));let teamRows=[];if(allowAny)teamRows=await db.selectFrom("teams").select(["id","name"]).execute();else{const ids=[...roleByTeam.keys()];teamRows=ids.length?await db.selectFrom("teams").whereIn("id",ids).select(["id","name"]).execute():[]}const teams=teamRows.map((t)=>({id:Number(t.id),name:String(t.name),role:roleByTeam.get(Number(t.id))||"viewer"})).sort((a,b)=>a.name.localeCompare(b.name));return{user,teamId:picked.teamId,role:picked.role,teams,activeTeamId}}export async function resolveAuthenticatedTeamId(request){const membership=await resolveAuthenticatedMembership(request);return membership?membership.teamId:null}export async function resolveAuthenticatedUser(request){const guardName=config.auth?.default||"api",user=((config.auth?.guards?.[guardName]||{driver:"token"}).driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);return user?.id?user:void 0}async function resolveSessionUser(request){const sessionId=request.cookies?.get("session_id");if(!sessionId)return;return sessionUser(sessionId)}async function resolveTokenUser(request){const cookieName=authCookieName(),bearer=(typeof request.bearerToken==="function"?request.bearerToken():void 0)??request.cookies?.get(cookieName);if(!bearer)return;return Auth.getUserFromToken(bearer)}
@@ -0,0 +1,4 @@
1
+ /** Revoke one access/refresh pair without partially applying a failed logout. */
2
+ export declare function revokeTokenPair(selector: { id: number } | { hash: string }): Promise<void>;
3
+ /** Revoke an owner's access/refresh pairs, optionally keeping one session. */
4
+ export declare function revokeTokenPairs(userId: number, ownerType: string, exceptId?: number): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import{db,getDatabaseDialect,markContextWrote,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";export async function revokeTokenPair(selector){await db.transaction(async(trx)=>{const sql=sqlHelpers(getDatabaseDialect()),byId="id"in selector,row=(await trx.unsafe(`
2
+ SELECT id FROM oauth_access_tokens
3
+ WHERE ${byId?"id":"token"} = ${sql.param(1)}${sql.isSqlite?"":" FOR UPDATE"}
4
+ `,[byId?selector.id:selector.hash]))[0];if(!row)return;markContextWrote();await trx.unsafe(`
5
+ UPDATE oauth_refresh_tokens SET revoked = ${sql.boolTrue}
6
+ WHERE access_token_id = ${sql.param(1)}
7
+ `,[row.id]);await trx.unsafe(`
8
+ UPDATE oauth_access_tokens SET revoked = ${sql.boolTrue}, updated_at = ${sqlDateTimeLiteral()}
9
+ WHERE id = ${sql.param(1)}
10
+ `,[row.id])},{retries:2,sqlStates:["40001","40P01"]})}export async function revokeTokenPairs(userId,ownerType,exceptId){await db.transaction(async(trx)=>{const sql=sqlHelpers(getDatabaseDialect()),processed=new Set,bindings=[userId,ownerType];let where=`tokenable_id = ${sql.param(1)} AND tokenable_type = ${sql.param(2)}`;if(exceptId!==void 0){bindings.push(exceptId);where+=` AND id != ${sql.param(bindings.length)}`}for(;;){const pending=(await trx.unsafe(`
11
+ SELECT id FROM oauth_access_tokens WHERE ${where}
12
+ ORDER BY id${sql.isSqlite?"":" FOR UPDATE"}
13
+ `,bindings)).filter((row)=>!processed.has(String(row.id))).map((row)=>row.id);if(pending.length===0)break;for(let offset=0;offset<pending.length;offset+=100){const ids=pending.slice(offset,offset+100),placeholders=sql.params(...ids).sql;await trx.unsafe(`
14
+ UPDATE oauth_refresh_tokens SET revoked = ${sql.boolTrue}
15
+ WHERE access_token_id IN (${placeholders})
16
+ `,ids);await trx.unsafe(`
17
+ UPDATE oauth_access_tokens SET revoked = ${sql.boolTrue}, updated_at = ${sqlDateTimeLiteral()}
18
+ WHERE id IN (${placeholders})
19
+ `,ids);for(const id of ids)processed.add(String(id))}}},{retries:2,sqlStates:["40001","40P01"]});markContextWrote()}
package/dist/tokens.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AccessToken, CreateClientOptions, CreateClientResult, DatabaseDriver, OAuthClient, PersonalAccessTokenResult, RefreshTokenResult, TokenScopes } from '@stacksjs/types';
2
- export declare function getPasswordChangedAt(userId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }): Promise<Date | null>;
2
+ export declare function getPasswordChangedAt(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<Date | null>;
3
3
  /**
4
4
  * True when a credential issued at `createdAt` predates the user's last
5
5
  * password change (`changedAt`) and must therefore be rejected.
@@ -96,6 +96,7 @@ export declare function tokenAbilities(): Promise<string[]>;
96
96
  * @param scopes - Array of scopes/abilities for the token
97
97
  * @param options - Additional options
98
98
  * @param options.expiresInMinutes - Token expiry in minutes (default: 60)
99
+ * @param options.expiresAt - Absolute deadline, taking precedence over expiresInMinutes
99
100
  * @param options.withRefreshToken - Whether to create a refresh token (default: true)
100
101
  * @param options.refreshExpiresInDays - Refresh token expiry in days (default: 30)
101
102
  *
@@ -107,6 +108,7 @@ export declare function tokenAbilities(): Promise<string[]>;
107
108
  */
108
109
  export declare function createToken(userId: number, name?: string, scopes?: string[], options?: {
109
110
  expiresInMinutes?: number
111
+ expiresAt?: Date
110
112
  withRefreshToken?: boolean
111
113
  refreshExpiresInDays?: number
112
114
  /**
package/dist/tokens.js CHANGED
@@ -1,6 +1,14 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(userId,q=db){if(userId===null||userId===void 0)return null;try{const value=(await q.unsafe(`
2
- SELECT password_changed_at FROM users WHERE id = ${param(1)} LIMIT 1
3
- `,[userId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch{return null}}export function isIssuedBeforePasswordChange(createdAt,changedAt){if(!changedAt)return!1;if(createdAt===null||createdAt===void 0)return!1;const created=parseSqlDateTime(createdAt);if(!created)return!1;return created.getTime()<changedAt.getTime()}export async function tokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){return(await db.unsafe(`
1
+ import{createHash,randomBytes}from"node:crypto";import{db,markContextWrote,mutationCount}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
2
+ SELECT 1 FROM information_schema.columns
3
+ WHERE table_schema = DATABASE() AND table_name = ?
4
+ AND column_name = 'password_changed_at'
5
+ `,[ownerType])).length===0)return null}else if(isPostgres&&q!==db){if((await q.unsafe(`
6
+ SELECT 1 FROM pg_attribute
7
+ WHERE attrelid = to_regclass($1) AND attname = 'password_changed_at'
8
+ AND attnum > 0 AND NOT attisdropped
9
+ `,[table])).length===0)return null}const value=(await q.unsafe(`
10
+ SELECT password_changed_at FROM ${table} WHERE id = ${param(1)} LIMIT 1
11
+ `,[ownerId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch{return null}}export function isIssuedBeforePasswordChange(createdAt,changedAt){if(!changedAt)return!1;if(createdAt===null||createdAt===void 0)return!1;const created=parseSqlDateTime(createdAt);if(!created)return!1;return created.getTime()<changedAt.getTime()}export async function tokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){return(await db.unsafe(`
4
12
  SELECT t.*, c.provider as client_provider
5
13
  FROM oauth_access_tokens t
6
14
  LEFT JOIN oauth_clients c ON t.oauth_client_id = c.id
@@ -14,31 +22,34 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
14
22
  AND revoked = ${boolFalse}
15
23
  AND (expires_at IS NULL OR expires_at > ${appNow()})
16
24
  LIMIT 1
17
- `,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.user_id)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at?new Date(row.expires_at):null,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return attached;const bearerToken=request.bearerToken?.();if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,client=(await db.unsafe(`
18
- SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} LIMIT 1
19
- `))[0];if(!client)throw new HttpError(500,"No personal access client found. Run ./buddy auth:setup first.");const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),expiresAt=new Date;expiresAt.setMinutes(expiresAt.getMinutes()+expiresInMinutes);const agent=userAgent?String(userAgent).slice(0,255):null,address=ipAddress?String(ipAddress).slice(0,45):null;if(isPostgres)await db.unsafe(`
20
- INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
21
- VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, $8, $9, ${appNow()}, ${appNow()})
22
- `,[tokenableType,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);else await db.unsafe(`
23
- INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
24
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
25
- `,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await db.unsafe(`
26
- SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
27
- `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await db.unsafe(`
28
- INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
29
- VALUES ($1, $2, false, $3, ${appNow()})
30
- `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await db.unsafe(`
31
- INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
32
- VALUES (?, ?, 0, ?, ${appNow()})
33
- `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)])}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:expiresInMinutes*60}}export async function refreshToken(refreshTokenPlain,options={}){const{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain);return await db.transaction(async(rawTrx)=>{const trx=rawTrx,forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
25
+ `,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return attached;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000),result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
26
+ SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} AND revoked = ${boolFalse} LIMIT 1
27
+ `))[0];if(!client)throw new HttpError(500,"No personal access client found. Run ./buddy auth:setup first.");const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),agent=userAgent?String(userAgent).slice(0,255):null,address=ipAddress?String(ipAddress).slice(0,45):null;if(isPostgres)await trx.unsafe(`
28
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
29
+ VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, $8, $9, ${appNow()}, ${appNow()})
30
+ `,[tokenableType,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);else await trx.unsafe(`
31
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
32
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
33
+ `,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await trx.unsafe(`
34
+ SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
35
+ `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
36
+ INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
37
+ VALUES ($1, $2, false, $3, ${appNow()})
38
+ `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
39
+ INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
40
+ VALUES (?, ?, 0, ?, ${appNow()})
41
+ `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)])}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
34
42
  SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.name, t.scopes
35
43
  FROM oauth_refresh_tokens r
36
44
  JOIN oauth_access_tokens t ON r.access_token_id = t.id
37
45
  WHERE r.token = ${param(1)}
38
46
  AND r.revoked = ${boolFalse}
47
+ AND EXISTS (
48
+ SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
49
+ )
39
50
  AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
40
51
  LIMIT 1${forUpdate}
41
- `,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
52
+ `,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
42
53
  UPDATE oauth_refresh_tokens
43
54
  SET revoked = ${boolTrue}
44
55
  WHERE id = ${param(1)}
@@ -46,7 +57,7 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
46
57
  UPDATE oauth_access_tokens
47
58
  SET revoked = ${boolTrue}
48
59
  WHERE id = ${param(1)}
49
- `,[refreshRow.access_token_id]);const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),expiresAt=new Date;expiresAt.setMinutes(expiresAt.getMinutes()+expiresInMinutes);if(isPostgres)await trx.unsafe(`
60
+ `,[refreshRow.access_token_id]);const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),issuedAt=Date.now(),expiresAt=new Date(issuedAt+expiresInMinutes*60000);if(isPostgres)await trx.unsafe(`
50
61
  INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
51
62
  VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, ${appNow()}, ${appNow()})
52
63
  `,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);else await trx.unsafe(`
@@ -54,108 +65,62 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
54
65
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
55
66
  `,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);const row=(await trx.unsafe(`
56
67
  SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
57
- `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.user_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
68
+ `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
58
69
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
59
70
  VALUES ($1, $2, false, $3, ${appNow()})
60
71
  `,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
61
72
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
62
73
  VALUES (?, ?, 0, ?, ${appNow()})
63
- `,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);return{accessToken,plainTextToken,refreshToken:newRefreshTokenPlain,expiresIn:expiresInMinutes*60}})}export async function validateRefreshToken(refreshTokenPlain){const hashedRefreshToken=hashToken(refreshTokenPlain);return(await db.unsafe(`
64
- SELECT id FROM oauth_refresh_tokens
65
- WHERE token = ${param(1)}
66
- AND revoked = ${boolFalse}
67
- AND (expires_at IS NULL OR expires_at > ${appNow()})
74
+ `,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);return{accessToken,plainTextToken,refreshToken:newRefreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function validateRefreshToken(refreshTokenPlain){const hashedRefreshToken=hashToken(refreshTokenPlain);return(await db.unsafe(`
75
+ SELECT r.id FROM oauth_refresh_tokens r
76
+ JOIN oauth_access_tokens t ON r.access_token_id = t.id
77
+ WHERE r.token = ${param(1)}
78
+ AND r.revoked = ${boolFalse}
79
+ AND EXISTS (
80
+ SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
81
+ )
82
+ AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
68
83
  LIMIT 1
69
84
  `,[hashedRefreshToken])).length>0}export async function revokeRefreshToken(refreshTokenPlain){const hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
70
85
  UPDATE oauth_refresh_tokens
71
86
  SET revoked = ${boolTrue}
72
87
  WHERE token = ${param(1)}
73
- `,[hashedRefreshToken])}export async function revokeAllRefreshTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await db.unsafe(`
88
+ `,[hashedRefreshToken]);markContextWrote()}export async function revokeAllRefreshTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await db.unsafe(`
74
89
  UPDATE oauth_refresh_tokens
75
90
  SET revoked = ${boolTrue}
76
91
  WHERE access_token_id IN (
77
92
  SELECT id FROM oauth_access_tokens WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
78
93
  )
79
- `,[userId,tokenableType])}export async function deleteExpiredRefreshTokens(){const written=await db.unsafe(`
94
+ `,[userId,tokenableType]);markContextWrote()}export async function deleteExpiredRefreshTokens(){const result=await db.unsafe(`
80
95
  DELETE FROM oauth_refresh_tokens
81
96
  WHERE expires_at < ${appNow()}
82
- `);return Number(written?.changes??written?.rowCount??0)}export async function deleteRevokedRefreshTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const written=await db.unsafe(`
97
+ `);markContextWrote();return mutationCount(result)}export async function deleteRevokedRefreshTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const result=await db.unsafe(`
83
98
  DELETE FROM oauth_refresh_tokens
84
99
  WHERE revoked = ${boolTrue} AND created_at < ${param(1)}
85
- `,[sqlDateTime(cutoffDate)]);return Number(written?.changes??written?.rowCount??0)}export async function revokeToken(plainTextToken){const hashedToken=bearerLookupHash(plainTextToken);await db.unsafe(`
86
- UPDATE oauth_refresh_tokens
87
- SET revoked = ${boolTrue}
88
- WHERE access_token_id IN (
89
- SELECT id FROM oauth_access_tokens WHERE token = ${param(1)}
90
- )
91
- `,[hashedToken]);await db.unsafe(`
92
- UPDATE oauth_access_tokens
93
- SET revoked = ${boolTrue}, updated_at = ${appNow()}
94
- WHERE token = ${param(1)}
95
- `,[hashedToken])}export async function revokeTokenById(tokenId){await db.unsafe(`
96
- UPDATE oauth_refresh_tokens
97
- SET revoked = ${boolTrue}
98
- WHERE access_token_id = ${param(1)}
99
- `,[tokenId]);await db.unsafe(`
100
- UPDATE oauth_access_tokens
101
- SET revoked = ${boolTrue}, updated_at = ${appNow()}
102
- WHERE id = ${param(1)}
103
- `,[tokenId])}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeAllRefreshTokens(userId);await db.unsafe(`
104
- UPDATE oauth_access_tokens
105
- SET revoked = ${boolTrue}, updated_at = ${appNow()}
106
- WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
107
- `,[userId,tokenableType])}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId);if(isPostgres){await db.unsafe(`
108
- UPDATE oauth_refresh_tokens
109
- SET revoked = true
110
- WHERE access_token_id IN (
111
- SELECT id FROM oauth_access_tokens WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
112
- )
113
- `,[userId,current.id,tokenableType]);await db.unsafe(`
114
- UPDATE oauth_access_tokens
115
- SET revoked = true, updated_at = ${appNow()}
116
- WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
117
- `,[userId,current.id])}else{await db.unsafe(`
118
- UPDATE oauth_refresh_tokens
119
- SET revoked = 1
120
- WHERE access_token_id IN (
121
- SELECT id FROM oauth_access_tokens WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
122
- )
123
- `,[userId,tokenableType,current.id]);await db.unsafe(`
124
- UPDATE oauth_access_tokens
125
- SET revoked = 1, updated_at = ${appNow()}
126
- WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
127
- `,[userId,current.id])}}export async function deleteExpiredTokens(){await db.unsafe(`
100
+ `,[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(){await db.unsafe(`
128
101
  DELETE FROM oauth_refresh_tokens
129
102
  WHERE access_token_id IN (
130
103
  SELECT id FROM oauth_access_tokens WHERE expires_at < ${appNow()}
131
104
  )
132
- `);const written=await db.unsafe(`
105
+ `);markContextWrote();const result=await db.unsafe(`
133
106
  DELETE FROM oauth_access_tokens
134
107
  WHERE expires_at < ${appNow()}
135
- `);return Number(written?.changes??written?.rowCount??0)}export async function deleteRevokedTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);await db.unsafe(`
108
+ `);return mutationCount(result)}export async function deleteRevokedTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);await db.unsafe(`
136
109
  DELETE FROM oauth_refresh_tokens
137
110
  WHERE access_token_id IN (
138
111
  SELECT id FROM oauth_access_tokens WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
139
112
  )
140
- `,[sqlDateTime(cutoffDate)]);const written=await db.unsafe(`
113
+ `,[sqlDateTime(cutoffDate)]);markContextWrote();const result=await db.unsafe(`
141
114
  DELETE FROM oauth_access_tokens
142
115
  WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
143
- `,[sqlDateTime(cutoffDate)]);return Number(written?.changes??written?.rowCount??0)}export async function clients(userId){return(await db.unsafe(`
116
+ `,[sqlDateTime(cutoffDate)]);return mutationCount(result)}export async function clients(userId){return(await db.unsafe(`
144
117
  SELECT * FROM oauth_clients
145
118
  WHERE user_id = ${param(1)} AND revoked = ${boolFalse}
146
119
  ORDER BY created_at DESC
147
120
  `,[userId])).map(mapToOAuthClient)}export async function findClient(clientId){const row=(await db.unsafe(`
148
121
  SELECT * FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
149
- `,[clientId]))[0];return row?mapToOAuthClient(row):null}export async function createClient(options){const secret=generateSecureToken(40);if(isPostgres)await db.unsafe(`
150
- INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
151
- VALUES ($1, $2, 'local', $3, $4, $5, false, ${appNow()})
152
- `,[options.name,secret,options.redirect,options.personalAccessClient||!1,options.passwordClient||!1]);else await db.unsafe(`
153
- INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
154
- VALUES (?, ?, 'local', ?, ?, ?, 0, ${appNow()})
155
- `,[options.name,secret,options.redirect,options.personalAccessClient?1:0,options.passwordClient?1:0]);const createdClient=(await db.unsafe(`
156
- SELECT * FROM oauth_clients WHERE secret = ${param(1)} LIMIT 1
157
- `,[secret]))[0];if(!createdClient)throw new HttpError(500,"Failed to read back the OAuth client that was just created.");return{client:mapToOAuthClient(createdClient),plainTextSecret:secret}}export async function revokeClient(clientId){await db.unsafe(`
122
+ `,[clientId]))[0];return row?mapToOAuthClient(row):null}export async function createClient(options){const secret=generateSecureToken(40),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"}),createdClient=await db.transaction(async(trx)=>{await trx.insertInto("oauth_clients").values({user_id:options.userId??null,name:options.name,secret:hashedSecret,provider:options.provider??"local",redirect:options.redirect,personal_access_client:!!options.personalAccessClient,password_client:!!options.passwordClient,revoked:!1,created_at:sqlDateTime()}).execute();const inserted=await trx.selectFrom("oauth_clients").where("secret","=",hashedSecret).executeTakeFirst();if(!inserted)throw new HttpError(500,"Failed to read back the OAuth client that was just created.");return inserted});markContextWrote();return{client:mapToOAuthClient(createdClient),plainTextSecret:secret}}export async function revokeClient(clientId){await db.unsafe(`
158
123
  UPDATE oauth_clients
159
124
  SET revoked = ${boolTrue}, updated_at = ${appNow()}
160
125
  WHERE id = ${param(1)}
161
- `,[clientId])}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:row.created_at?new Date(row.created_at):new Date,updatedAt:row.updated_at?new Date(row.updated_at):null}}
126
+ `,[clientId]);markContextWrote()}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:row.created_at?new Date(row.created_at):new Date,updatedAt:row.updated_at?new Date(row.updated_at):null}}
@@ -1 +1 @@
1
- import{randomBytes}from"node:crypto";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{generateTwoFactorSecret,generateTwoFactorUri,twoFactorQrCode,verifyTwoFactorCode}from"./authenticator";import{RateLimiter}from"./rate-limiter";const DEFAULT_CHALLENGE_TTL_SECONDS=300,TWO_FACTOR_RATE_LIMIT_PREFIX="2fa:",TWO_FACTOR_STEP_SECONDS=30;function currentTotpStep(){return Math.floor(Date.now()/1000/TWO_FACTOR_STEP_SECONDS)}async function getLastUsedTwoFactorStep(userId){try{const value=(await db.selectFrom("users").where("id","=",userId).selectAll().executeTakeFirst())?.two_factor_last_used_step;return value==null?null:Number(value)}catch{return null}}async function setLastUsedTwoFactorStep(userId,step){try{await db.updateTable("users").set({two_factor_last_used_step:step}).where("id","=",userId).executeTakeFirst()}catch{}}export async function getTwoFactorState(userId){const row=await db.selectFrom("users").where("id","=",userId).select(["two_factor_secret","two_factor_enabled"]).executeTakeFirst();return{secret:row?.two_factor_secret??null,enabled:Boolean(row?.two_factor_enabled)}}export function isTwoFactorEnabled(user){return Boolean(user.two_factor_enabled)}export function generateTwoFactorSetup(email,serviceName){const secret=generateTwoFactorSecret(),uri=generateTwoFactorUri(email,serviceName,secret);return{secret,uri,qr:twoFactorQrCode(uri)}}const PENDING_SECRET_TTL_SECONDS=600;export async function stashPendingTwoFactorSecret(userId,secret,ttlSeconds=PENDING_SECRET_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();await db.insertInto("two_factor_pending_secrets").values({user_id:userId,secret,expires_at:expiresAt}).execute()}export async function consumePendingTwoFactorSecret(userId){const row=await db.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return String(row.secret)}export async function enableTwoFactor(userId,secret,code){if(!await verifyTwoFactorCode(code,secret))return!1;await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:!0}).where("id","=",userId).executeTakeFirst();return!0}export async function disableTwoFactor(userId){await db.updateTable("users").set({two_factor_secret:null,two_factor_enabled:!1}).where("id","=",userId).executeTakeFirst()}export async function verifyTwoFactorLoginCode(userId,code){const rateKey=`${TWO_FACTOR_RATE_LIMIT_PREFIX}${userId}`;await RateLimiter.validateAttempt(rateKey);const{secret,enabled}=await getTwoFactorState(userId);if(!enabled||!secret)return!1;if(!await verifyTwoFactorCode(code,secret)){await RateLimiter.recordFailedAttempt(rateKey);return!1}const step=currentTotpStep(),lastStep=await getLastUsedTwoFactorStep(userId);if(lastStep!==null&&step<=lastStep)return!1;await setLastUsedTwoFactorStep(userId,step);await RateLimiter.resetAttempts(rateKey);return!0}export async function createTwoFactorChallenge(userId,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const id=randomBytes(32).toString("hex"),expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_challenges").where("user_id","=",userId).execute();await db.insertInto("two_factor_challenges").values({id,user_id:userId,expires_at:expiresAt}).execute();return id}export async function consumeTwoFactorChallenge(challengeToken){const row=await db.selectFrom("two_factor_challenges").where("id","=",challengeToken).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_challenges").where("id","=",challengeToken).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Number(row.user_id)}export const TwoFactor={isEnabled:isTwoFactorEnabled,getState:getTwoFactorState,generateSetup:generateTwoFactorSetup,stashPendingSecret:stashPendingTwoFactorSecret,consumePendingSecret:consumePendingTwoFactorSecret,enable:enableTwoFactor,disable:disableTwoFactor,verifyLoginCode:verifyTwoFactorLoginCode,createChallenge:createTwoFactorChallenge,consumeChallenge:consumeTwoFactorChallenge};
1
+ import{randomBytes}from"node:crypto";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{generateTwoFactorSecret,generateTwoFactorUri,twoFactorQrCode,verifyTwoFactorCode}from"./authenticator";import{RateLimiter}from"./rate-limiter";const DEFAULT_CHALLENGE_TTL_SECONDS=300,TWO_FACTOR_RATE_LIMIT_PREFIX="2fa:",TWO_FACTOR_STEP_SECONDS=30;function currentTotpStep(){return Math.floor(Date.now()/1000/TWO_FACTOR_STEP_SECONDS)}async function getLastUsedTwoFactorStep(userId){try{const value=(await db.selectFrom("users").where("id","=",userId).selectAll().executeTakeFirst())?.two_factor_last_used_step;return value==null?null:Number(value)}catch{return null}}async function setLastUsedTwoFactorStep(userId,step){try{await db.updateTable("users").set({two_factor_last_used_step:step}).where("id","=",userId).executeTakeFirst()}catch{}}export async function getTwoFactorState(userId){const row=await db.selectFrom("users").where("id","=",userId).select(["two_factor_secret","two_factor_enabled"]).executeTakeFirst();return{secret:row?.two_factor_secret??null,enabled:Boolean(row?.two_factor_enabled)}}export function isTwoFactorEnabled(user){return Boolean(user.two_factor_enabled)}export function generateTwoFactorSetup(email,serviceName){const secret=generateTwoFactorSecret(),uri=generateTwoFactorUri(email,serviceName,secret);return{secret,uri,qr:twoFactorQrCode(uri)}}const PENDING_SECRET_TTL_SECONDS=600;export async function stashPendingTwoFactorSecret(userId,secret,ttlSeconds=PENDING_SECRET_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();await db.insertInto("two_factor_pending_secrets").values({user_id:userId,secret,expires_at:expiresAt}).execute()}export async function consumePendingTwoFactorSecret(userId){const row=await db.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return String(row.secret)}export async function enableTwoFactor(userId,secret,code){if(!await verifyTwoFactorCode(code,secret))return!1;await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:!0}).where("id","=",userId).executeTakeFirst();return!0}export async function disableTwoFactor(userId){await db.updateTable("users").set({two_factor_secret:null,two_factor_enabled:!1}).where("id","=",userId).executeTakeFirst()}export async function verifyTwoFactorLoginCode(userId,code){const rateKey=`${TWO_FACTOR_RATE_LIMIT_PREFIX}${userId}`;await RateLimiter.validateAttempt(rateKey);const{secret,enabled}=await getTwoFactorState(userId);if(!enabled||!secret)return!1;if(!await verifyTwoFactorCode(code,secret)){await RateLimiter.recordFailedAttempt(rateKey);return!1}const step=currentTotpStep(),lastStep=await getLastUsedTwoFactorStep(userId);if(lastStep!==null&&step<=lastStep)return!1;await setLastUsedTwoFactorStep(userId,step);await RateLimiter.resetAttempts(rateKey);return!0}export async function createTwoFactorChallenge(userId,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const id=randomBytes(32).toString("hex"),expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_challenges").where("user_id","=",userId).execute();await db.insertInto("two_factor_challenges").values({id,user_id:userId,expires_at:expiresAt}).execute();return id}export async function consumeTwoFactorChallenge(challengeToken){const row=await db.selectFrom("two_factor_challenges").where("id","=",challengeToken).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_challenges").where("id","=",challengeToken).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Number(row.user_id)}export const TwoFactor={isEnabled:isTwoFactorEnabled,getState:getTwoFactorState,generateSetup:generateTwoFactorSetup,stashPendingSecret:stashPendingTwoFactorSecret,consumePendingSecret:consumePendingTwoFactorSecret,enable:enableTwoFactor,disable:disableTwoFactor,verifyLoginCode:verifyTwoFactorLoginCode,createChallenge:createTwoFactorChallenge,consumeChallenge:consumeTwoFactorChallenge};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.46",
5
+ "version": "0.74.48",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.18",
61
- "@stacksjs/cache": "0.74.46",
62
- "@stacksjs/config": "0.74.46",
63
- "@stacksjs/database": "0.74.46",
64
- "@stacksjs/email": "0.74.46",
65
- "@stacksjs/env": "0.74.46",
66
- "@stacksjs/error-handling": "0.74.46",
67
- "@stacksjs/logging": "0.74.46",
68
- "@stacksjs/orm": "0.74.46",
69
- "@stacksjs/path": "0.74.46",
70
- "@stacksjs/router": "0.74.46",
71
- "@stacksjs/security": "0.74.46",
72
- "@stacksjs/storage": "0.74.46",
61
+ "@stacksjs/cache": "0.74.48",
62
+ "@stacksjs/config": "0.74.48",
63
+ "@stacksjs/database": "0.74.48",
64
+ "@stacksjs/email": "0.74.48",
65
+ "@stacksjs/env": "0.74.48",
66
+ "@stacksjs/error-handling": "0.74.48",
67
+ "@stacksjs/logging": "0.74.48",
68
+ "@stacksjs/orm": "0.74.48",
69
+ "@stacksjs/path": "0.74.48",
70
+ "@stacksjs/router": "0.74.48",
71
+ "@stacksjs/security": "0.74.48",
72
+ "@stacksjs/storage": "0.74.48",
73
73
  "@stacksjs/ts-auth": "^0.4.4",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },