@stacksjs/auth 0.75.32 → 0.75.33

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,getDatabaseDialect,isInTransaction,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,sessionUser}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{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";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")}async function userTokenOwnerExists(userId){if(userId===null||userId===void 0)return!1;return await db.primary.selectFrom("users").where("id","=",userId).select("id").executeTakeFirst()!==void 0}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at===null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,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.primary.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 verifiedClient(clientId,clientSecret){const client=await db.primary.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 null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).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:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(!await userTokenOwnerExists(accessToken.tokenable_id))return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(!await userTokenOwnerExists(accessToken.tokenable_id))return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(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.primary.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:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).selectAll();if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
1
+ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,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,sessionUser}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{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";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")}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at==null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,getPasswordChangedAt,getTokenOwnerCredentialState,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.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
@@ -1,3 +1,3 @@
1
- import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{normalizeAuthEmail}from"./credential-user";import{authLinkBase}from"./link-url";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - email-verification HMAC requires a real APP_KEY. Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");return appKey}function generateVerificationToken(userId,email){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${normalizeAuthEmail(email)}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,email,token,storedHash){const payload=`${userId}:${normalizeAuthEmail(email)}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){const base=authLinkBase(),filled=(config.auth.emailVerification?.url??"/verify-email/{id}/{token}").replace("{id}",String(userId)).replace("{token}",token);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){await deliverAfterCommit(await prepareVerificationEmail(user))}async function prepareVerificationEmail(user){const expiryMinutes=getExpiryMinutes(),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+expiryMinutes*60*1000),prepared=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||typeof owner.email!=="string"||!owner.email)throw Error("[auth] Email verification owner no longer exists or has no email.");const{token,hash}=generateVerificationToken(user.id,owner.email);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt),created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("email_verifications").where("user_id","=",user.id).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hash)throw Error("[auth] Email verification could not replace the token.");return{currentUser:{id:Number(owner.id),email:owner.email,name:typeof owner.name==="string"?owner.name:user.name},token}});return()=>deliverVerificationEmail(prepared.currentUser,prepared.token,expiryMinutes)}async function deliverAfterCommit(deliver){if(!enqueueAfterCommit(deliver))await deliver()}async function deliverVerificationEmail(user,token,expiryMinutes){const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
1
+ import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{normalizeAuthEmail}from"./credential-user";import{authLinkBase}from"./link-url";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - email-verification HMAC requires a real APP_KEY. Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");return appKey}function generateVerificationToken(userId,email){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${normalizeAuthEmail(email)}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,email,token,storedHash){const payload=`${userId}:${normalizeAuthEmail(email)}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){const base=authLinkBase(),filled=(config.auth.emailVerification?.url??"/verify-email/{id}/{token}").replace("{id}",String(userId)).replace("{token}",token);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){await deliverAfterCommit(await prepareVerificationEmail(user))}async function prepareVerificationEmail(user){const expiryMinutes=getExpiryMinutes(),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+expiryMinutes*60*1000),prepared=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||typeof owner.email!=="string"||!owner.email)throw Error("[auth] Email verification owner no longer exists or has no email.");const{token,hash}=generateVerificationToken(user.id,owner.email);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt),created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("email_verifications").where("user_id","=",user.id).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hash)throw Error("[auth] Email verification could not replace the token.");return{currentUser:{id:Number(owner.id),email:owner.email,name:typeof owner.name==="string"?owner.name:user.name},token}});return()=>deliverVerificationEmail(prepared.currentUser,prepared.token,expiryMinutes)}async function deliverAfterCommit(deliver){if(!enqueueAfterCommit(deliver))await deliver()}async function deliverVerificationEmail(user,token,expiryMinutes){const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
2
2
 
3
- This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){return db.transaction(async()=>{const record=await db.primary.selectFrom("email_verifications").where("user_id","=",userId).selectAll().executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const observed=db.deleteFrom("email_verifications").where("user_id","=",userId).where("id","=",record.id).where("token","=",record.token),claim=record.expires_at==null?observed.whereNull("expires_at"):observed.where("expires_at","=",record.expires_at),expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||Date.now()>=expiresAt.getTime()){await claim.execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}let owner=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();const user=await owner.executeTakeFirst();if(!user||typeof user.email!=="string"||!user.email)return{success:!1,message:"Invalid verification link."};if(!verifyToken(userId,user.email,token,record.token))return{success:!1,message:"Invalid verification link."};if(mutationCount(await claim.execute())!==1)return{success:!1,message:"Invalid verification link."};if(Date.now()>=expiresAt.getTime())return{success:!1,message:"Verification link has expired. Please request a new one."};const updated=await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).execute();if(mutationCount(updated)!==1&&user.email_verified_at==null)throw Error("[auth] Email verification could not update the user.");return{success:!0,message:"Email verified successfully."}})}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const outcome=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)return{result:{success:!1,message:"User not found."}};if(isEmailVerified(owner))return{result:{success:!1,message:"Email is already verified."}};let existingQuery=db.primary.selectFrom("email_verifications").where("user_id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")existingQuery=existingQuery.lockForUpdate();const existing=await existingQuery.executeTakeFirst();if(existing){const createdAt=parseSqlDateTime(existing.created_at),secondsSince=createdAt?(Date.now()-createdAt.getTime())/1000:Number.NaN;if(Number.isNaN(secondsSince))return{result:{success:!1,message:"Please wait a moment before requesting another verification email."}};if(secondsSince<60)return{result:{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}}const deliver=await prepareVerificationEmail(user);return{result:{success:!0,message:"Verification email sent."},deliver}},getDatabaseDialect()==="sqlite"?{retries:3}:void 0);if(outcome.deliver)await deliverAfterCommit(outcome.deliver);return outcome.result}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
3
+ This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){return db.transaction(async()=>{const record=await db.primary.selectFrom("email_verifications").where("user_id","=",userId).select(["id","token","expires_at"]).executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const observed=db.deleteFrom("email_verifications").where("user_id","=",userId).where("id","=",record.id).where("token","=",record.token),claim=record.expires_at==null?observed.whereNull("expires_at"):observed.where("expires_at","=",record.expires_at),expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||Date.now()>=expiresAt.getTime()){await claim.execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}let owner=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();const user=await owner.executeTakeFirst();if(!user||typeof user.email!=="string"||!user.email)return{success:!1,message:"Invalid verification link."};if(!verifyToken(userId,user.email,token,record.token))return{success:!1,message:"Invalid verification link."};if(mutationCount(await claim.execute())!==1)return{success:!1,message:"Invalid verification link."};if(Date.now()>=expiresAt.getTime())return{success:!1,message:"Verification link has expired. Please request a new one."};const updated=await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).execute();if(mutationCount(updated)!==1&&user.email_verified_at==null)throw Error("[auth] Email verification could not update the user.");return{success:!0,message:"Email verified successfully."}})}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const outcome=await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",user.id).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)return{result:{success:!1,message:"User not found."}};if(isEmailVerified(owner))return{result:{success:!1,message:"Email is already verified."}};let existingQuery=db.primary.selectFrom("email_verifications").where("user_id","=",user.id).select(["created_at"]);if(getDatabaseDialect()!=="sqlite")existingQuery=existingQuery.lockForUpdate();const existing=await existingQuery.executeTakeFirst();if(existing){const createdAt=parseSqlDateTime(existing.created_at),secondsSince=createdAt?(Date.now()-createdAt.getTime())/1000:Number.NaN;if(Number.isNaN(secondsSince))return{result:{success:!1,message:"Please wait a moment before requesting another verification email."}};if(secondsSince<60)return{result:{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}}const deliver=await prepareVerificationEmail(user);return{result:{success:!0,message:"Verification email sent."},deliver}},getDatabaseDialect()==="sqlite"?{retries:3}:void 0);if(outcome.deliver)await deliverAfterCommit(outcome.deliver);return outcome.result}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
@@ -56,7 +56,12 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
56
56
  token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
57
57
  password_client, revoked, created_at
58
58
  ) VALUES (${values.sql})${returning}
59
- `,values.values),idRows=sql.isMysql?await trx.unsafe("SELECT LAST_INSERT_ID() AS id"):inserted,id=Number(idRows[0]?.id);if(!Number.isSafeInteger(id)||id<=0)throw Error("Failed to resolve the registered OAuth client identifier.");const row=(await trx.unsafe(`SELECT * FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
59
+ `,values.values),idRows=sql.isMysql?await trx.unsafe("SELECT LAST_INSERT_ID() AS id"):inserted,id=Number(idRows[0]?.id);if(!Number.isSafeInteger(id)||id<=0)throw Error("Failed to resolve the registered OAuth client identifier.");const row=(await trx.unsafe(`
60
+ SELECT id, user_id, name, secret, redirect, client_type, redirect_uris,
61
+ grant_types, token_endpoint_auth_method, allowed_scopes, allowed_resources,
62
+ personal_access_client, password_client, revoked, created_at
63
+ FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
64
+ `,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
60
65
  SELECT client_type, token_endpoint_auth_method, revoked
61
66
  FROM oauth_clients
62
67
  WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
package/dist/passkey.js CHANGED
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";import{db,getDatabaseDialect,mutationCount,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.primary.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.primary.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){if(!Number.isSafeInteger(newCounter)||newCounter<0)return!1;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;if(newCounter===0)return db.transaction(async()=>{let query=db.primary.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).where("counter","=",0).where("cred_public_key","=",passkey.cred_public_key);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();if(!await query.executeTakeFirst())return!1;await db.updateTable("passkeys").set({last_used_at:sqlDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0});const claimed=await db.updateTable("passkeys").set({counter:newCounter,last_used_at:sqlDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).where("counter","=",stored).where("cred_public_key","=",passkey.cred_public_key).execute();return mutationCount(claimed)===1}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:sqlDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}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.transaction(async()=>{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();const stored=await db.primary.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).select("challenge").execute();if(stored.length!==1||stored[0]?.challenge!==encodedChallenge)throw Error("[auth] WebAuthn challenge could not be stored.")})}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.primary.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).selectAll().executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).where("challenge","=",row.challenge).where("expires_at","=",row.expires_at).execute();if(mutationCount(claimed)!==1)return null;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,getDatabaseDialect,mutationCount,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.primary.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.primary.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){if(!Number.isSafeInteger(newCounter)||newCounter<0)return!1;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;if(newCounter===0)return db.transaction(async()=>{let query=db.primary.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).where("counter","=",0).where("cred_public_key","=",passkey.cred_public_key);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();if(!await query.executeTakeFirst())return!1;await db.updateTable("passkeys").set({last_used_at:sqlDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0});const claimed=await db.updateTable("passkeys").set({counter:newCounter,last_used_at:sqlDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).where("counter","=",stored).where("cred_public_key","=",passkey.cred_public_key).execute();return mutationCount(claimed)===1}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:sqlDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}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.transaction(async()=>{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();const stored=await db.primary.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).select("challenge").execute();if(stored.length!==1||stored[0]?.challenge!==encodedChallenge)throw Error("[auth] WebAuthn challenge could not be stored.")})}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.primary.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).select(["challenge","expires_at"]).executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).where("challenge","=",row.challenge).where("expires_at","=",row.expires_at).execute();if(mutationCount(claimed)!==1)return null;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,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const token=await createResetToken(userId);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const token=await createResetToken(userId);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
2
2
 
3
- This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const owner=await findAuthUserRowByEmail(email);if(!owner)return{success:!1,message:"Invalid or expired reset token"};const ownerId=Number(owner.id);if(!Number.isSafeInteger(ownerId)||ownerId<=0)return{success:!1,message:"Invalid or expired reset token"};const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("id","=",ownerId).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user||normalizeAuthEmail(String(user.email??""))!==email)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeOAuthSubjectAuthorizationState("users",Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
3
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token","expires_at","created_at"]).executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const owner=await findAuthUserRowByEmail(email);if(!owner)return{success:!1,message:"Invalid or expired reset token"};const ownerId=Number(owner.id);if(!Number.isSafeInteger(ownerId)||ownerId<=0)return{success:!1,message:"Invalid or expired reset token"};const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("id","=",ownerId).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user||normalizeAuthEmail(String(user.email??""))!==email)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).select(["token","expires_at","created_at"]);if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeOAuthSubjectAuthorizationState("users",Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
@@ -1 +1 @@
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.primary.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.primary.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.primary.selectFrom("roles").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.primary.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.primary.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.primary.selectFrom("permissions").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.primary.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.primary.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.primary.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.primary.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.primary.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.primary.selectFrom(table).select(["id","name","guard_name","description","created_at","updated_at"]).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.primary.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.primary.selectFrom("roles").select(["id","name","guard_name","description","created_at","updated_at"]).where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.primary.selectFrom("roles").select(["id","name","guard_name","description","created_at","updated_at"]).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.primary.selectFrom("roles").select(["id","name","guard_name","description","created_at","updated_at"]);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.primary.selectFrom("permissions").select(["id","name","guard_name","description","created_at","updated_at"]).where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.primary.selectFrom("permissions").select(["id","name","guard_name","description","created_at","updated_at"]).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.primary.selectFrom("permissions").select(["id","name","guard_name","description","created_at","updated_at"]);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.primary.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 getUserPermissions(userId){return(await db.primary.selectFrom("permissions").leftJoin("user_permissions","user_permissions.permission_id","=","permissions.id").leftJoin("role_permissions","role_permissions.permission_id","=","permissions.id").leftJoin("user_roles","user_roles.role_id","=","role_permissions.role_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).orWhere("user_roles.user_id","=",userId).distinct().orderBy("permissions.id","asc").execute()).map((row)=>toRecord(row)).filter(Boolean)},async getUserDirectPermissions(userId){return(await db.primary.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.primary.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/rbac.d.ts CHANGED
@@ -221,6 +221,7 @@ export declare interface RbacStore {
221
221
  removeRoleFromUser(userId: number, roleId: number): Promise<void>
222
222
  removeAllRolesFromUser(userId: number): Promise<void>
223
223
  syncUserRoles(userId: number, roleIds: number[]): Promise<void>
224
+ getUserPermissions?(userId: number): Promise<PermissionRecord[]>
224
225
  getUserDirectPermissions(userId: number): Promise<PermissionRecord[]>
225
226
  assignPermissionToUser(userId: number, permissionId: number): Promise<void>
226
227
  removePermissionFromUser(userId: number, permissionId: number): Promise<void>
package/dist/rbac.js CHANGED
@@ -1 +1 @@
1
- var {require}=import.meta;export class RbacEntityNotFoundError extends Error{entity;value;guardName;constructor(entity,value,guardName){const label=entity==="role"?"Role":"Permission";super(`${label} '${value}' not found.`);this.entity=entity;this.value=value;this.guardName=guardName;this.name="RbacEntityNotFoundError"}}class BoundedMap{max;map=new Map;constructor(max){this.max=max}get(key){return this.map.get(key)}has(key){return this.map.has(key)}set(key,value){if(this.map.has(key))this.map.delete(key);this.map.set(key,value);if(this.map.size>this.max){const oldest=this.map.keys().next().value;if(oldest!==void 0)this.map.delete(oldest)}return this}delete(key){return this.map.delete(key)}clear(){this.map.clear()}}const RBAC_USER_CACHE_MAX=1e4,cache={userRoles:new BoundedMap(RBAC_USER_CACHE_MAX),userPermissions:new BoundedMap(RBAC_USER_CACHE_MAX),rolePermissions:new BoundedMap(RBAC_USER_CACHE_MAX),roles:new Map,permissions:new Map};let store=null;export function setRbacStore(rbacStore){store=rbacStore;flushRbacCache()}function getStore(){if(!store){const{createBqbRbacStore}=require("./rbac-store-bqb");store=createBqbRbacStore()}return store}export function flushRbacCache(){cache.userRoles.clear();cache.userPermissions.clear();cache.rolePermissions.clear();cache.roles.clear();cache.permissions.clear()}function getUserId(user){if(typeof user==="number"){if(!Number.isFinite(user)||user<=0)throw TypeError("RBAC user id must be a positive number");return user}const raw=user.id;if(typeof raw!=="number"&&typeof raw!=="string"&&typeof raw!=="bigint")throw TypeError(`RBAC user id must be a positive number, got ${typeof raw} (${String(raw)})`);const id=Number(raw);if(!Number.isInteger(id)||id<=0)throw TypeError(`RBAC user id must be a positive number, got ${typeof raw} (${String(raw)})`);if(!Number.isSafeInteger(id))throw TypeError(`RBAC user id exceeds the safe integer range, got ${String(raw)}`);return id}export async function createRole(name,guardName="web",description){const role=await getStore().createRole(name,guardName,description);cache.roles.set(`${name}:${guardName}`,role);return role}export async function findRole(name,guardName="web"){const cacheKey=`${name}:${guardName}`;if(cache.roles.has(cacheKey))return cache.roles.get(cacheKey);const role=await getStore().findRoleByName(name,guardName);if(role)cache.roles.set(cacheKey,role);return role}export async function deleteRole(name,guardName="web"){const role=await findRole(name,guardName);if(role){await getStore().deleteRole(role.id);cache.roles.delete(`${name}:${guardName}`);flushRbacCache()}}export async function getAllRoles(guardName){return getStore().getAllRoles(guardName)}export async function createPermission(name,guardName="web",description){const permission=await getStore().createPermission(name,guardName,description);cache.permissions.set(`${name}:${guardName}`,permission);return permission}export async function findPermission(name,guardName="web"){const cacheKey=`${name}:${guardName}`;if(cache.permissions.has(cacheKey))return cache.permissions.get(cacheKey);const permission=await getStore().findPermissionByName(name,guardName);if(permission)cache.permissions.set(cacheKey,permission);return permission}export async function deletePermission(name,guardName="web"){const permission=await findPermission(name,guardName);if(permission){await getStore().deletePermission(permission.id);cache.permissions.delete(`${name}:${guardName}`);flushRbacCache()}}export async function getAllPermissions(guardName){return getStore().getAllPermissions(guardName)}export async function getUserRoles(user){const userId=getUserId(user);if(cache.userRoles.has(userId))return cache.userRoles.get(userId);const roles=await getStore().getUserRoles(userId);cache.userRoles.set(userId,roles);return roles}export async function assignRole(user,roleName,guardName="web"){const userId=getUserId(user),role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);await getStore().assignRoleToUser(userId,role.id);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function removeRole(user,roleName,guardName="web"){const userId=getUserId(user),role=await findRole(roleName,guardName);if(!role)return;await getStore().removeRoleFromUser(userId,role.id);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function removeAllRoles(user){const userId=getUserId(user);await getStore().removeAllRolesFromUser(userId);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function syncRoles(user,roleNames,guardName="web"){const userId=getUserId(user),preservedRoleIds=(await getUserRoles(userId)).filter((role)=>role.guard_name!==guardName).map((role)=>role.id),roleIds=[];for(const name of new Set(roleNames)){const role=await findRole(name,guardName);if(!role)throw new RbacEntityNotFoundError("role",name,guardName);roleIds.push(role.id)}await getStore().syncUserRoles(userId,[...preservedRoleIds,...roleIds]);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function hasRole(user,roleName,guardName="web"){return(await getUserRoles(user)).some((r)=>r.name===roleName&&r.guard_name===guardName)}export async function hasAnyRole(user,roleNames,guardName="web"){const roles=await getUserRoles(user);return roleNames.some((name)=>roles.some((r)=>r.name===name&&r.guard_name===guardName))}export async function hasAllRoles(user,roleNames,guardName="web"){const roles=await getUserRoles(user);return roleNames.every((name)=>roles.some((r)=>r.name===name&&r.guard_name===guardName))}export async function getUserPermissions(user){const userId=getUserId(user);if(cache.userPermissions.has(userId))return cache.userPermissions.get(userId);const directPermissions=await getStore().getUserDirectPermissions(userId),roles=await getUserRoles(user),rolePermissions=[];for(const role of roles){const perms=await getRolePermissions(role.id);rolePermissions.push(...perms)}const seen=new Set,allPermissions=[];for(const perm of[...directPermissions,...rolePermissions])if(!seen.has(perm.id)){seen.add(perm.id);allPermissions.push(perm)}cache.userPermissions.set(userId,allPermissions);return allPermissions}export async function givePermission(user,permissionName,guardName="web"){const userId=getUserId(user),permission=await findPermission(permissionName,guardName);if(!permission)throw new RbacEntityNotFoundError("permission",permissionName,guardName);await getStore().assignPermissionToUser(userId,permission.id);cache.userPermissions.delete(userId)}export async function revokePermission(user,permissionName,guardName="web"){const userId=getUserId(user),permission=await findPermission(permissionName,guardName);if(!permission)return;await getStore().removePermissionFromUser(userId,permission.id);cache.userPermissions.delete(userId)}export async function revokeAllPermissions(user){const userId=getUserId(user);await getStore().removeAllPermissionsFromUser(userId);cache.userPermissions.delete(userId)}export async function syncPermissions(user,permissionNames,guardName="web"){const userId=getUserId(user),preservedPermissionIds=(await getStore().getUserDirectPermissions(userId)).filter((permission)=>permission.guard_name!==guardName).map((permission)=>permission.id),permissionIds=[];for(const name of new Set(permissionNames)){const perm=await findPermission(name,guardName);if(!perm)throw new RbacEntityNotFoundError("permission",name,guardName);permissionIds.push(perm.id)}await getStore().syncUserPermissions(userId,[...preservedPermissionIds,...permissionIds]);cache.userPermissions.delete(userId)}export async function hasPermission(user,permissionName,guardName="web"){return(await getUserPermissions(user)).some((p)=>p.name===permissionName&&p.guard_name===guardName)}export async function hasAnyPermission(user,permissionNames,guardName="web"){const permissions=await getUserPermissions(user);return permissionNames.some((name)=>permissions.some((p)=>p.name===name&&p.guard_name===guardName))}export async function hasAllPermissions(user,permissionNames,guardName="web"){const permissions=await getUserPermissions(user);return permissionNames.every((name)=>permissions.some((p)=>p.name===name&&p.guard_name===guardName))}export async function getRolePermissions(roleId){if(cache.rolePermissions.has(roleId))return cache.rolePermissions.get(roleId);const permissions=await getStore().getRolePermissions(roleId);cache.rolePermissions.set(roleId,permissions);return permissions}export async function givePermissionToRole(roleName,permissionName,guardName="web"){const role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);const permission=await findPermission(permissionName,guardName);if(!permission)throw new RbacEntityNotFoundError("permission",permissionName,guardName);await getStore().assignPermissionToRole(role.id,permission.id);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export async function revokePermissionFromRole(roleName,permissionName,guardName="web"){const role=await findRole(roleName,guardName);if(!role)return;const permission=await findPermission(permissionName,guardName);if(!permission)return;await getStore().removePermissionFromRole(role.id,permission.id);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export async function syncRolePermissions(roleName,permissionNames,guardName="web"){const role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);const permissionIds=[];for(const name of permissionNames){const perm=await findPermission(name,guardName);if(!perm)throw new RbacEntityNotFoundError("permission",name,guardName);permissionIds.push(perm.id)}await getStore().syncRolePermissions(role.id,permissionIds);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export function withRbac(user){const userId=getUserId(user);return Object.assign(user,{hasRole:(roleName,guardName)=>hasRole(userId,roleName,guardName),hasAnyRole:(roleNames,guardName)=>hasAnyRole(userId,roleNames,guardName),hasAllRoles:(roleNames,guardName)=>hasAllRoles(userId,roleNames,guardName),hasPermission:(permissionName,guardName)=>hasPermission(userId,permissionName,guardName),hasAnyPermission:(permissionNames,guardName)=>hasAnyPermission(userId,permissionNames,guardName),hasAllPermissions:(permissionNames,guardName)=>hasAllPermissions(userId,permissionNames,guardName),getRoles:()=>getUserRoles(userId),getPermissions:()=>getUserPermissions(userId),assignRole:(roleName,guardName)=>assignRole(userId,roleName,guardName),removeRole:(roleName,guardName)=>removeRole(userId,roleName,guardName),syncRoles:(roleNames,guardName)=>syncRoles(userId,roleNames,guardName),givePermission:(permissionName,guardName)=>givePermission(userId,permissionName,guardName),revokePermission:(permissionName,guardName)=>revokePermission(userId,permissionName,guardName),syncPermissions:(permissionNames,guardName)=>syncPermissions(userId,permissionNames,guardName)})}export const Rbac={setStore:setRbacStore,flushCache:flushRbacCache,createRole,findRole,deleteRole,getAllRoles,createPermission,findPermission,deletePermission,getAllPermissions,getUserRoles,assignRole,removeRole,removeAllRoles,syncRoles,hasRole,hasAnyRole,hasAllRoles,getUserPermissions,givePermission,revokePermission,revokeAllPermissions,syncPermissions,hasPermission,hasAnyPermission,hasAllPermissions,getRolePermissions,givePermissionToRole,revokePermissionFromRole,syncRolePermissions,withRbac};export default Rbac;
1
+ var {require}=import.meta;export class RbacEntityNotFoundError extends Error{entity;value;guardName;constructor(entity,value,guardName){const label=entity==="role"?"Role":"Permission";super(`${label} '${value}' not found.`);this.entity=entity;this.value=value;this.guardName=guardName;this.name="RbacEntityNotFoundError"}}class BoundedMap{max;map=new Map;constructor(max){this.max=max}get(key){return this.map.get(key)}has(key){return this.map.has(key)}set(key,value){if(this.map.has(key))this.map.delete(key);this.map.set(key,value);if(this.map.size>this.max){const oldest=this.map.keys().next().value;if(oldest!==void 0)this.map.delete(oldest)}return this}delete(key){return this.map.delete(key)}clear(){this.map.clear()}}const RBAC_USER_CACHE_MAX=1e4,cache={userRoles:new BoundedMap(RBAC_USER_CACHE_MAX),userPermissions:new BoundedMap(RBAC_USER_CACHE_MAX),rolePermissions:new BoundedMap(RBAC_USER_CACHE_MAX),roles:new Map,permissions:new Map};let store=null;export function setRbacStore(rbacStore){store=rbacStore;flushRbacCache()}function getStore(){if(!store){const{createBqbRbacStore}=require("./rbac-store-bqb");store=createBqbRbacStore()}return store}export function flushRbacCache(){cache.userRoles.clear();cache.userPermissions.clear();cache.rolePermissions.clear();cache.roles.clear();cache.permissions.clear()}function getUserId(user){if(typeof user==="number"){if(!Number.isFinite(user)||user<=0)throw TypeError("RBAC user id must be a positive number");return user}const raw=user.id;if(typeof raw!=="number"&&typeof raw!=="string"&&typeof raw!=="bigint")throw TypeError(`RBAC user id must be a positive number, got ${typeof raw} (${String(raw)})`);const id=Number(raw);if(!Number.isInteger(id)||id<=0)throw TypeError(`RBAC user id must be a positive number, got ${typeof raw} (${String(raw)})`);if(!Number.isSafeInteger(id))throw TypeError(`RBAC user id exceeds the safe integer range, got ${String(raw)}`);return id}export async function createRole(name,guardName="web",description){const role=await getStore().createRole(name,guardName,description);cache.roles.set(`${name}:${guardName}`,role);return role}export async function findRole(name,guardName="web"){const cacheKey=`${name}:${guardName}`;if(cache.roles.has(cacheKey))return cache.roles.get(cacheKey);const role=await getStore().findRoleByName(name,guardName);if(role)cache.roles.set(cacheKey,role);return role}export async function deleteRole(name,guardName="web"){const role=await findRole(name,guardName);if(role){await getStore().deleteRole(role.id);cache.roles.delete(`${name}:${guardName}`);flushRbacCache()}}export async function getAllRoles(guardName){return getStore().getAllRoles(guardName)}export async function createPermission(name,guardName="web",description){const permission=await getStore().createPermission(name,guardName,description);cache.permissions.set(`${name}:${guardName}`,permission);return permission}export async function findPermission(name,guardName="web"){const cacheKey=`${name}:${guardName}`;if(cache.permissions.has(cacheKey))return cache.permissions.get(cacheKey);const permission=await getStore().findPermissionByName(name,guardName);if(permission)cache.permissions.set(cacheKey,permission);return permission}export async function deletePermission(name,guardName="web"){const permission=await findPermission(name,guardName);if(permission){await getStore().deletePermission(permission.id);cache.permissions.delete(`${name}:${guardName}`);flushRbacCache()}}export async function getAllPermissions(guardName){return getStore().getAllPermissions(guardName)}export async function getUserRoles(user){const userId=getUserId(user);if(cache.userRoles.has(userId))return cache.userRoles.get(userId);const roles=await getStore().getUserRoles(userId);cache.userRoles.set(userId,roles);return roles}export async function assignRole(user,roleName,guardName="web"){const userId=getUserId(user),role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);await getStore().assignRoleToUser(userId,role.id);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function removeRole(user,roleName,guardName="web"){const userId=getUserId(user),role=await findRole(roleName,guardName);if(!role)return;await getStore().removeRoleFromUser(userId,role.id);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function removeAllRoles(user){const userId=getUserId(user);await getStore().removeAllRolesFromUser(userId);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function syncRoles(user,roleNames,guardName="web"){const userId=getUserId(user),preservedRoleIds=(await getUserRoles(userId)).filter((role)=>role.guard_name!==guardName).map((role)=>role.id),roleIds=[];for(const name of new Set(roleNames)){const role=await findRole(name,guardName);if(!role)throw new RbacEntityNotFoundError("role",name,guardName);roleIds.push(role.id)}await getStore().syncUserRoles(userId,[...preservedRoleIds,...roleIds]);cache.userRoles.delete(userId);cache.userPermissions.delete(userId)}export async function hasRole(user,roleName,guardName="web"){return(await getUserRoles(user)).some((r)=>r.name===roleName&&r.guard_name===guardName)}export async function hasAnyRole(user,roleNames,guardName="web"){const roles=await getUserRoles(user);return roleNames.some((name)=>roles.some((r)=>r.name===name&&r.guard_name===guardName))}export async function hasAllRoles(user,roleNames,guardName="web"){const roles=await getUserRoles(user);return roleNames.every((name)=>roles.some((r)=>r.name===name&&r.guard_name===guardName))}export async function getUserPermissions(user){const userId=getUserId(user);if(cache.userPermissions.has(userId))return cache.userPermissions.get(userId);const rbacStore=getStore();if("getUserPermissions"in rbacStore&&rbacStore.getUserPermissions){const permissions=await rbacStore.getUserPermissions(userId);cache.userPermissions.set(userId,permissions);return permissions}const directPermissions=await rbacStore.getUserDirectPermissions(userId),roles=await getUserRoles(user),rolePermissions=[];for(const role of roles){const perms=await getRolePermissions(role.id);rolePermissions.push(...perms)}const seen=new Set,allPermissions=[];for(const perm of[...directPermissions,...rolePermissions])if(!seen.has(perm.id)){seen.add(perm.id);allPermissions.push(perm)}cache.userPermissions.set(userId,allPermissions);return allPermissions}export async function givePermission(user,permissionName,guardName="web"){const userId=getUserId(user),permission=await findPermission(permissionName,guardName);if(!permission)throw new RbacEntityNotFoundError("permission",permissionName,guardName);await getStore().assignPermissionToUser(userId,permission.id);cache.userPermissions.delete(userId)}export async function revokePermission(user,permissionName,guardName="web"){const userId=getUserId(user),permission=await findPermission(permissionName,guardName);if(!permission)return;await getStore().removePermissionFromUser(userId,permission.id);cache.userPermissions.delete(userId)}export async function revokeAllPermissions(user){const userId=getUserId(user);await getStore().removeAllPermissionsFromUser(userId);cache.userPermissions.delete(userId)}export async function syncPermissions(user,permissionNames,guardName="web"){const userId=getUserId(user),preservedPermissionIds=(await getStore().getUserDirectPermissions(userId)).filter((permission)=>permission.guard_name!==guardName).map((permission)=>permission.id),permissionIds=[];for(const name of new Set(permissionNames)){const perm=await findPermission(name,guardName);if(!perm)throw new RbacEntityNotFoundError("permission",name,guardName);permissionIds.push(perm.id)}await getStore().syncUserPermissions(userId,[...preservedPermissionIds,...permissionIds]);cache.userPermissions.delete(userId)}export async function hasPermission(user,permissionName,guardName="web"){return(await getUserPermissions(user)).some((p)=>p.name===permissionName&&p.guard_name===guardName)}export async function hasAnyPermission(user,permissionNames,guardName="web"){const permissions=await getUserPermissions(user);return permissionNames.some((name)=>permissions.some((p)=>p.name===name&&p.guard_name===guardName))}export async function hasAllPermissions(user,permissionNames,guardName="web"){const permissions=await getUserPermissions(user);return permissionNames.every((name)=>permissions.some((p)=>p.name===name&&p.guard_name===guardName))}export async function getRolePermissions(roleId){if(cache.rolePermissions.has(roleId))return cache.rolePermissions.get(roleId);const permissions=await getStore().getRolePermissions(roleId);cache.rolePermissions.set(roleId,permissions);return permissions}export async function givePermissionToRole(roleName,permissionName,guardName="web"){const role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);const permission=await findPermission(permissionName,guardName);if(!permission)throw new RbacEntityNotFoundError("permission",permissionName,guardName);await getStore().assignPermissionToRole(role.id,permission.id);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export async function revokePermissionFromRole(roleName,permissionName,guardName="web"){const role=await findRole(roleName,guardName);if(!role)return;const permission=await findPermission(permissionName,guardName);if(!permission)return;await getStore().removePermissionFromRole(role.id,permission.id);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export async function syncRolePermissions(roleName,permissionNames,guardName="web"){const role=await findRole(roleName,guardName);if(!role)throw new RbacEntityNotFoundError("role",roleName,guardName);const permissionIds=[];for(const name of permissionNames){const perm=await findPermission(name,guardName);if(!perm)throw new RbacEntityNotFoundError("permission",name,guardName);permissionIds.push(perm.id)}await getStore().syncRolePermissions(role.id,permissionIds);cache.rolePermissions.delete(role.id);cache.userPermissions.clear()}export function withRbac(user){const userId=getUserId(user);return Object.assign(user,{hasRole:(roleName,guardName)=>hasRole(userId,roleName,guardName),hasAnyRole:(roleNames,guardName)=>hasAnyRole(userId,roleNames,guardName),hasAllRoles:(roleNames,guardName)=>hasAllRoles(userId,roleNames,guardName),hasPermission:(permissionName,guardName)=>hasPermission(userId,permissionName,guardName),hasAnyPermission:(permissionNames,guardName)=>hasAnyPermission(userId,permissionNames,guardName),hasAllPermissions:(permissionNames,guardName)=>hasAllPermissions(userId,permissionNames,guardName),getRoles:()=>getUserRoles(userId),getPermissions:()=>getUserPermissions(userId),assignRole:(roleName,guardName)=>assignRole(userId,roleName,guardName),removeRole:(roleName,guardName)=>removeRole(userId,roleName,guardName),syncRoles:(roleNames,guardName)=>syncRoles(userId,roleNames,guardName),givePermission:(permissionName,guardName)=>givePermission(userId,permissionName,guardName),revokePermission:(permissionName,guardName)=>revokePermission(userId,permissionName,guardName),syncPermissions:(permissionNames,guardName)=>syncPermissions(userId,permissionNames,guardName)})}export const Rbac={setStore:setRbacStore,flushCache:flushRbacCache,createRole,findRole,deleteRole,getAllRoles,createPermission,findPermission,deletePermission,getAllPermissions,getUserRoles,assignRole,removeRole,removeAllRoles,syncRoles,hasRole,hasAnyRole,hasAllRoles,getUserPermissions,givePermission,revokePermission,revokeAllPermissions,syncPermissions,hasPermission,hasAnyPermission,hasAllPermissions,getRolePermissions,givePermissionToRole,revokePermissionFromRole,syncRolePermissions,withRbac};export default Rbac;
package/dist/register.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db,getDatabaseDialect,sqlHelpers}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{normalizeAuthEmail}from"./credential-user";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,tokenOptions){const{email:rawEmail,password,name}=credentials,email=typeof rawEmail==="string"?normalizeAuthEmail(rawEmail):rawEmail;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,sql=sqlHelpers(getDatabaseDialect());if((await trx.unsafe(`SELECT id FROM users WHERE LOWER(email) = ${sql.param(1)} LIMIT 1`,[email]))[0])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,{...tokenOptions,name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
1
+ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,sqlHelpers}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{normalizeAuthEmail}from"./credential-user";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,tokenOptions){const{email:rawEmail,password,name}=credentials,email=typeof rawEmail==="string"?normalizeAuthEmail(rawEmail):rawEmail;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,sql=sqlHelpers(getDatabaseDialect());if((await trx.unsafe(`SELECT id FROM users WHERE LOWER(email) = ${sql.param(1)} LIMIT 1`,[email]))[0])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.unsafe(`SELECT id FROM users WHERE email = ${sql.param(1)} LIMIT 1`,[email]))[0];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,{...tokenOptions,name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
@@ -1 +1 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&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=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}export async function sessionUser(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
1
+ import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&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=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0)return;let touch=db.updateTable("sessions").set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AccessToken, CreateClientOptions, CreateClientResult, OAuthClient, PersonalAccessTokenResult, RefreshTokenResult, TokenScopes } from '@stacksjs/types';
2
+ export declare function getTokenOwnerCredentialState(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<TokenOwnerCredentialState>;
2
3
  export declare function getPasswordChangedAt(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<Date | null>;
3
4
  /**
4
5
  * True when a credential issued at `createdAt` predates the user's last
@@ -309,3 +310,7 @@ export declare const DEFAULT_TOKENABLE_TYPE: 'users';
309
310
  * const t = await token()
310
311
  */
311
312
  export declare const token: unknown;
313
+ export declare interface TokenOwnerCredentialState {
314
+ exists: boolean
315
+ passwordChangedAt: Date | null
316
+ }
package/dist/tokens.js CHANGED
@@ -1,25 +1,36 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,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{tokenDate,tokenTimestamps}from"./token-dates";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";function tokenSql(){return sqlHelpers(getDatabaseDialect())}function appNow(){return sqlDateTimeLiteral()}function validTokenExpiry(value){if(value==null)return!0;const expiry=parseSqlDateTime(value);return expiry!=null&&expiry.getTime()>Date.now()}async function verifyRefreshTokenInsert(trx,hash,accessTokenId,expiresAt){const{param}=tokenSql(),rows=await trx.unsafe(`
1
+ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,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{tokenDate,tokenTimestamps}from"./token-dates";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";function tokenSql(){return sqlHelpers(getDatabaseDialect())}function appNow(){return sqlDateTimeLiteral()}function validTokenExpiry(value){if(value==null)return!0;const expiry=parseSqlDateTime(value);return expiry!=null&&expiry.getTime()>Date.now()}async function verifyRefreshTokenInsert(trx,hash,accessTokenId,expiresAt){const{param}=tokenSql(),rows=await trx.unsafe(`
2
2
  SELECT access_token_id, revoked, expires_at FROM oauth_refresh_tokens WHERE token = ${param(1)}
3
- `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date(Date.now()+days*24*60*60*1000);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}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";function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();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(`
4
- SELECT 1 FROM information_schema.columns
5
- WHERE table_schema = DATABASE() AND table_name = ?
6
- AND column_name = 'password_changed_at'
7
- `,[ownerType])).length===0)return null}else if(isPostgres&&q!==db){if((await q.unsafe(`
8
- SELECT 1 FROM pg_attribute
9
- WHERE attrelid = to_regclass($1) AND attname = 'password_changed_at'
10
- AND attnum > 0 AND NOT attisdropped
11
- `,[table])).length===0)return null}const value=(await q.unsafe(`
12
- SELECT password_changed_at FROM ${table} WHERE id = ${param(1)} LIMIT 1
13
- `,[ownerId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch(error){const message=error instanceof Error?error.message:String(error),missingColumn=/^(?:no such column: password_changed_at|column "password_changed_at" does not exist)$/im.test(message),missingOwner=message===`no such table: ${ownerType}`||message===`relation "${ownerType}" does not exist`;if(missingColumn||missingOwner)return null;throw error}}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){const{boolFalse,param}=tokenSql();return(await db.unsafe(`
14
- SELECT t.*, c.provider as client_provider
3
+ `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date(Date.now()+days*24*60*60*1000);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}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";function databaseBoolean(value){return value===!0||value===1||value===1n||value==="1"||value==="t"}function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getTokenOwnerCredentialState(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return{exists:!1,passwordChangedAt:null};const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`,readOwner=async(includePasswordStamp)=>{const row=(await q.unsafe(`
4
+ SELECT ${includePasswordStamp?"id, password_changed_at":"id"} FROM ${table} WHERE id = ${param(1)} LIMIT 1
5
+ `,[ownerId]))[0];if(!row)return{exists:!1,passwordChangedAt:null};const value=row.password_changed_at;return{exists:!0,passwordChangedAt:value===null||value===void 0?null:parseSqlDateTime(value)}};try{if(isMysql){const schema=(await q.unsafe(`
6
+ SELECT
7
+ EXISTS(SELECT 1 FROM information_schema.tables
8
+ WHERE table_schema = DATABASE() AND table_name = ?) AS table_exists,
9
+ EXISTS(SELECT 1 FROM information_schema.columns
10
+ WHERE table_schema = DATABASE() AND table_name = ?
11
+ AND column_name = 'password_changed_at') AS stamp_exists
12
+ `,[ownerType,ownerType]))[0];if(!databaseBoolean(schema?.table_exists))return{exists:!1,passwordChangedAt:null};return readOwner(databaseBoolean(schema?.stamp_exists))}else if(isPostgres&&q!==db){const schema=(await q.unsafe(`
13
+ SELECT
14
+ to_regclass($1) IS NOT NULL AS table_exists,
15
+ EXISTS(SELECT 1 FROM pg_attribute
16
+ WHERE attrelid = to_regclass($1) AND attname = 'password_changed_at'
17
+ AND attnum > 0 AND NOT attisdropped) AS stamp_exists
18
+ `,[table]))[0];if(!databaseBoolean(schema?.table_exists))return{exists:!1,passwordChangedAt:null};return readOwner(databaseBoolean(schema?.stamp_exists))}return await readOwner(!0)}catch(error){const message=error instanceof Error?error.message:String(error),missingColumn=/^(?:no such column: password_changed_at|column "password_changed_at" does not exist)$/im.test(message);if(message===`no such table: ${ownerType}`||message===`relation "${ownerType}" does not exist`)return{exists:!1,passwordChangedAt:null};if(missingColumn)try{return await readOwner(!1)}catch(fallbackError){const fallbackMessage=fallbackError instanceof Error?fallbackError.message:String(fallbackError);if(fallbackMessage===`no such table: ${ownerType}`||fallbackMessage===`relation "${ownerType}" does not exist`)return{exists:!1,passwordChangedAt:null};throw fallbackError}throw error}}export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){return(await getTokenOwnerCredentialState(ownerId,q,ownerType)).passwordChangedAt}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){const{boolFalse,param}=tokenSql();return(await db.unsafe(`
19
+ SELECT t.id, t.user_id, t.tokenable_type, t.tokenable_id,
20
+ t.oauth_client_id, t.oauth_grant_id, t.resources, t.audiences,
21
+ t.workspace_id, t.name, t.scopes, t.revoked, t.expires_at,
22
+ t.created_at, t.updated_at, t.user_agent, t.ip_address
15
23
  FROM oauth_access_tokens t
16
- LEFT JOIN oauth_clients c ON t.oauth_client_id = c.id
17
24
  WHERE t.tokenable_id = ${param(1)}
18
25
  AND t.tokenable_type = ${param(2)}
19
26
  AND t.revoked = ${boolFalse}
20
27
  ORDER BY t.created_at DESC
21
28
  `,[userId,tokenableType])).map((row)=>({id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at==null?null:tokenDate(row.expires_at),...tokenTimestamps(row),userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const{boolFalse,param}=tokenSql(),hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
22
- SELECT t.*, g.scopes AS grant_scopes FROM oauth_access_tokens t
29
+ SELECT t.id, t.user_id, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.oauth_grant_id,
30
+ t.resources, t.audiences, t.workspace_id, t.name, t.scopes,
31
+ t.revoked, t.expires_at, t.created_at, t.updated_at,
32
+ g.scopes AS grant_scopes
33
+ FROM oauth_access_tokens t
23
34
  LEFT JOIN oauth_grants g ON g.id = t.oauth_grant_id
24
35
  LEFT JOIN oauth_clients c ON c.id = g.client_id
25
36
  WHERE t.token = ${param(1)}
@@ -40,7 +51,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
40
51
  AND (g.workspace_id = t.workspace_id OR (g.workspace_id IS NULL AND t.workspace_id IS NULL))
41
52
  ))
42
53
  LIMIT 1
43
- `,[hashedToken,DEFAULT_TOKENABLE_TYPE]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
54
+ `,[hashedToken,DEFAULT_TOKENABLE_TYPE]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(row.updated_at??row.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
44
55
  SELECT id FROM oauth_clients
45
56
  WHERE ${options.clientId===void 0?`personal_access_client = ${boolTrue}`:`id = ${param(1)}`}
46
57
  AND revoked = ${boolFalse} LIMIT 1
@@ -53,7 +64,9 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
53
64
  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)
54
65
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
55
66
  `,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await trx.unsafe(`
56
- SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
67
+ SELECT id, user_id, tokenable_type, tokenable_id, oauth_client_id,
68
+ name, scopes, revoked, expires_at, created_at, updated_at
69
+ FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
57
70
  `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");verifyAccessTokenInsert(row,{ownerId:userId,ownerType:tokenableType,clientId:client.id,scopes,expiresAt});const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,...tokenTimestamps(row)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
58
71
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
59
72
  VALUES ($1, $2, false, $3, ${appNow()})
@@ -84,7 +97,9 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
84
97
  INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
85
98
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
86
99
  `,[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(`
87
- SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
100
+ SELECT id, user_id, tokenable_type, tokenable_id, oauth_client_id,
101
+ name, scopes, revoked, expires_at, created_at, updated_at
102
+ FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
88
103
  `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");verifyAccessTokenInsert(row,{ownerId:refreshRow.tokenable_id,ownerType:refreshRow.tokenable_type,clientId:refreshRow.oauth_client_id,scopes:parseScopes(refreshRow.scopes),expiresAt});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,...tokenTimestamps(row)};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=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
89
104
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
90
105
  VALUES ($1, $2, false, $3, ${appNow()})
@@ -149,11 +164,15 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
149
164
  DELETE FROM oauth_access_tokens
150
165
  WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
151
166
  `,[sqlDateTime(cutoffDate)]);return mutationCount(result)},{retries:2,sqlStates:["40001","40P01"]})}export async function clients(userId){const{boolFalse,param}=tokenSql();return(await db.unsafe(`
152
- SELECT * FROM oauth_clients
167
+ SELECT id, name, secret, provider, redirect, personal_access_client,
168
+ password_client, revoked, created_at, updated_at
169
+ FROM oauth_clients
153
170
  WHERE user_id = ${param(1)} AND revoked = ${boolFalse}
154
171
  ORDER BY created_at DESC
155
172
  `,[userId])).map(mapToOAuthClient)}export async function findClient(clientId){const{param}=tokenSql(),row=(await db.unsafe(`
156
- SELECT * FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
173
+ SELECT id, name, secret, provider, redirect, personal_access_client,
174
+ password_client, revoked, created_at, updated_at
175
+ FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
157
176
  `,[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){const{boolTrue,param}=tokenSql();await db.unsafe(`
158
177
  UPDATE oauth_clients
159
178
  SET revoked = ${boolTrue}, updated_at = ${appNow()}
@@ -1 +1 @@
1
- import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{verifyTOTPWithCounter}from"@stacksjs/ts-auth";import{generateTwoFactorSecret,generateTwoFactorUri,twoFactorQrCode,verifyTwoFactorCode}from"./authenticator";import{RateLimiter}from"./rate-limiter";const DEFAULT_CHALLENGE_TTL_SECONDS=300,BASELINE_CHALLENGE_PREFIX="baseline.",REMEMBERED_CHALLENGE_PREFIX="remembered.";function challengeExpiry(ttlSeconds){const deadline=new Date(Date.now()+ttlSeconds*1000);if(getDatabaseDialect()==="mysql")deadline.setUTCMilliseconds(0);return sqlDateTime(deadline)}const TWO_FACTOR_RATE_LIMIT_PREFIX="2fa:";async function getLastUsedTwoFactorStep(userId){const value=(await db.primary.selectFrom("users").where("id","=",userId).select(["two_factor_last_used_step"]).executeTakeFirst())?.two_factor_last_used_step;return value==null?null:Number(value)}async function claimTwoFactorStep(userId,secret,step,lastStep){let claim=db.updateTable("users").set({two_factor_last_used_step:step}).where("id","=",userId).where("two_factor_secret","=",secret).where("two_factor_enabled","=",!0);claim=lastStep===null?claim.whereNull("two_factor_last_used_step"):claim.where("two_factor_last_used_step","=",lastStep);return mutationCount(await claim.execute())===1}export async function getTwoFactorState(userId){const row=await db.primary.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=challengeExpiry(ttlSeconds);await db.transaction(async()=>{await db.upsert("two_factor_pending_secrets",[{user_id:userId,secret,expires_at:expiresAt}],["user_id"],["secret","expires_at"]);const stored=await db.primary.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).select(["secret","expires_at"]).executeTakeFirst();if(stored?.secret!==secret||parseSqlDateTime(stored.expires_at)?.getTime()!==parseSqlDateTime(expiresAt)?.getTime())throw Error("[auth] Pending two-factor setup could not be stored.")})}export async function consumePendingTwoFactorSecret(userId){const row=await db.primary.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).selectAll().executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).where("secret","=",row.secret).where("expires_at","=",row.expires_at).execute();if(mutationCount(claimed)!==1)return null;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;return persistTwoFactorState(userId,secret,!0)}export async function disableTwoFactor(userId){await persistTwoFactorState(userId,null,!1)}async function persistTwoFactorState(userId,secret,enabled){return db.transaction(async()=>{await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:enabled}).where("id","=",userId).execute();const stored=await db.primary.selectFrom("users").where("id","=",userId).select(["two_factor_secret","two_factor_enabled"]).executeTakeFirst();if(!stored)return!1;if(stored.two_factor_secret!==secret||Boolean(stored.two_factor_enabled)!==enabled)throw Error("[auth] Two-factor state could not be stored.");return!0})}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;const{valid,counter:step}=await verifyTOTPWithCounter(code,{secret});if(!valid||step===null){await RateLimiter.recordFailedAttempt(rateKey);return!1}const lastStep=await getLastUsedTwoFactorStep(userId);if(lastStep!==null&&step<=lastStep)return!1;if(!await claimTwoFactorStep(userId,secret,step,lastStep))return!1;await RateLimiter.resetAttempts(rateKey);return!0}export async function createTwoFactorChallenge(userId,options={}){const ttlSeconds=typeof options==="number"?options:options.ttlSeconds??DEFAULT_CHALLENGE_TTL_SECONDS,id=`${typeof options==="object"&&options.remembered===!0?REMEMBERED_CHALLENGE_PREFIX:BASELINE_CHALLENGE_PREFIX}${randomBytes(32).toString("hex")}`,expiresAt=challengeExpiry(ttlSeconds);await db.transaction(async()=>{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();const stored=await db.primary.selectFrom("two_factor_challenges").where("user_id","=",userId).select("id").execute();if(stored.length!==1||stored[0]?.id!==id)throw Error("[auth] Two-factor login challenge could not be stored.")});return id}export async function consumeTwoFactorChallenge(challengeToken){const row=await db.primary.selectFrom("two_factor_challenges").where("id","=",challengeToken).selectAll().executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("two_factor_challenges").where("id","=",challengeToken).execute();if(mutationCount(claimed)!==1)return null;const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>=expiresAt)return null;return Number(row.user_id)}export async function withTwoFactorChallenge(challengeToken,complete){const pending=await db.primary.selectFrom("two_factor_challenges").where("id","=",challengeToken).select("user_id").executeTakeFirst();if(!pending)return null;const userId=Number(pending.user_id),outcome=await db.transaction(async()=>{let owner=db.primary.selectFrom("users").where("id","=",userId).select("id");if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();if(!await owner.executeTakeFirst())return{value:null};if(await consumeTwoFactorChallenge(challengeToken)!==userId)return{value:null};try{return{value:await db.transaction(()=>complete(userId,{remembered:challengeToken.startsWith(REMEMBERED_CHALLENGE_PREFIX)}))}}catch(error){return{error}}});if("error"in outcome)throw outcome.error;return outcome.value}export async function revokeTwoFactorChallenges(userId){try{await db.transaction(async()=>{await db.deleteFrom("two_factor_challenges").where("user_id","=",userId).execute();if(await db.primary.selectFrom("two_factor_challenges").where("user_id","=",userId).select("id").executeTakeFirst())throw Error("[auth] Two-factor login challenges could not be revoked.")})}catch(error){const message=error&&typeof error==="object"&&"message"in error&&typeof error.message==="string"?error.message:String(error);if(/^(?:no such table: (?:main\.)?two_factor_challenges|relation "two_factor_challenges" does not exist|Table '(?:[^'.]+\.)?two_factor_challenges' doesn't exist)$/i.test(message))return;throw error}}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,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{verifyTOTPWithCounter}from"@stacksjs/ts-auth";import{generateTwoFactorSecret,generateTwoFactorUri,twoFactorQrCode,verifyTwoFactorCode}from"./authenticator";import{RateLimiter}from"./rate-limiter";const DEFAULT_CHALLENGE_TTL_SECONDS=300,BASELINE_CHALLENGE_PREFIX="baseline.",REMEMBERED_CHALLENGE_PREFIX="remembered.";function challengeExpiry(ttlSeconds){const deadline=new Date(Date.now()+ttlSeconds*1000);if(getDatabaseDialect()==="mysql")deadline.setUTCMilliseconds(0);return sqlDateTime(deadline)}const TWO_FACTOR_RATE_LIMIT_PREFIX="2fa:";async function getLastUsedTwoFactorStep(userId){const value=(await db.primary.selectFrom("users").where("id","=",userId).select(["two_factor_last_used_step"]).executeTakeFirst())?.two_factor_last_used_step;return value==null?null:Number(value)}async function claimTwoFactorStep(userId,secret,step,lastStep){let claim=db.updateTable("users").set({two_factor_last_used_step:step}).where("id","=",userId).where("two_factor_secret","=",secret).where("two_factor_enabled","=",!0);claim=lastStep===null?claim.whereNull("two_factor_last_used_step"):claim.where("two_factor_last_used_step","=",lastStep);return mutationCount(await claim.execute())===1}export async function getTwoFactorState(userId){const row=await db.primary.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=challengeExpiry(ttlSeconds);await db.transaction(async()=>{await db.upsert("two_factor_pending_secrets",[{user_id:userId,secret,expires_at:expiresAt}],["user_id"],["secret","expires_at"]);const stored=await db.primary.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).select(["secret","expires_at"]).executeTakeFirst();if(stored?.secret!==secret||parseSqlDateTime(stored.expires_at)?.getTime()!==parseSqlDateTime(expiresAt)?.getTime())throw Error("[auth] Pending two-factor setup could not be stored.")})}export async function consumePendingTwoFactorSecret(userId){const row=await db.primary.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).select(["secret","expires_at"]).executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).where("secret","=",row.secret).where("expires_at","=",row.expires_at).execute();if(mutationCount(claimed)!==1)return null;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;return persistTwoFactorState(userId,secret,!0)}export async function disableTwoFactor(userId){await persistTwoFactorState(userId,null,!1)}async function persistTwoFactorState(userId,secret,enabled){return db.transaction(async()=>{await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:enabled}).where("id","=",userId).execute();const stored=await db.primary.selectFrom("users").where("id","=",userId).select(["two_factor_secret","two_factor_enabled"]).executeTakeFirst();if(!stored)return!1;if(stored.two_factor_secret!==secret||Boolean(stored.two_factor_enabled)!==enabled)throw Error("[auth] Two-factor state could not be stored.");return!0})}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;const{valid,counter:step}=await verifyTOTPWithCounter(code,{secret});if(!valid||step===null){await RateLimiter.recordFailedAttempt(rateKey);return!1}const lastStep=await getLastUsedTwoFactorStep(userId);if(lastStep!==null&&step<=lastStep)return!1;if(!await claimTwoFactorStep(userId,secret,step,lastStep))return!1;await RateLimiter.resetAttempts(rateKey);return!0}export async function createTwoFactorChallenge(userId,options={}){const ttlSeconds=typeof options==="number"?options:options.ttlSeconds??DEFAULT_CHALLENGE_TTL_SECONDS,id=`${typeof options==="object"&&options.remembered===!0?REMEMBERED_CHALLENGE_PREFIX:BASELINE_CHALLENGE_PREFIX}${randomBytes(32).toString("hex")}`,expiresAt=challengeExpiry(ttlSeconds);await db.transaction(async()=>{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();const stored=await db.primary.selectFrom("two_factor_challenges").where("user_id","=",userId).select("id").execute();if(stored.length!==1||stored[0]?.id!==id)throw Error("[auth] Two-factor login challenge could not be stored.")});return id}export async function consumeTwoFactorChallenge(challengeToken){const row=await db.primary.selectFrom("two_factor_challenges").where("id","=",challengeToken).select(["user_id","expires_at"]).executeTakeFirst();if(!row)return null;const claimed=await db.deleteFrom("two_factor_challenges").where("id","=",challengeToken).execute();if(mutationCount(claimed)!==1)return null;const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>=expiresAt)return null;return Number(row.user_id)}export async function withTwoFactorChallenge(challengeToken,complete){const pending=await db.primary.selectFrom("two_factor_challenges").where("id","=",challengeToken).select("user_id").executeTakeFirst();if(!pending)return null;const userId=Number(pending.user_id),outcome=await db.transaction(async()=>{let owner=db.primary.selectFrom("users").where("id","=",userId).select("id");if(getDatabaseDialect()!=="sqlite")owner=owner.lockForUpdate();if(!await owner.executeTakeFirst())return{value:null};if(await consumeTwoFactorChallenge(challengeToken)!==userId)return{value:null};try{return{value:await db.transaction(()=>complete(userId,{remembered:challengeToken.startsWith(REMEMBERED_CHALLENGE_PREFIX)}))}}catch(error){return{error}}});if("error"in outcome)throw outcome.error;return outcome.value}export async function revokeTwoFactorChallenges(userId){try{await db.transaction(async()=>{await db.deleteFrom("two_factor_challenges").where("user_id","=",userId).execute();if(await db.primary.selectFrom("two_factor_challenges").where("user_id","=",userId).select("id").executeTakeFirst())throw Error("[auth] Two-factor login challenges could not be revoked.")})}catch(error){const message=error&&typeof error==="object"&&"message"in error&&typeof error.message==="string"?error.message:String(error);if(/^(?:no such table: (?:main\.)?two_factor_challenges|relation "two_factor_challenges" does not exist|Table '(?:[^'.]+\.)?two_factor_challenges' doesn't exist)$/i.test(message))return;throw error}}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.75.32",
5
+ "version": "0.75.33",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.18",
61
- "@stacksjs/cache": "0.75.32",
62
- "@stacksjs/config": "0.75.32",
63
- "@stacksjs/database": "0.75.32",
64
- "@stacksjs/email": "0.75.32",
65
- "@stacksjs/env": "0.75.32",
66
- "@stacksjs/error-handling": "0.75.32",
67
- "@stacksjs/logging": "0.75.32",
68
- "@stacksjs/orm": "0.75.32",
69
- "@stacksjs/path": "0.75.32",
70
- "@stacksjs/router": "0.75.32",
71
- "@stacksjs/security": "0.75.32",
72
- "@stacksjs/storage": "0.75.32",
61
+ "@stacksjs/cache": "0.75.33",
62
+ "@stacksjs/config": "0.75.33",
63
+ "@stacksjs/database": "0.75.33",
64
+ "@stacksjs/email": "0.75.33",
65
+ "@stacksjs/env": "0.75.33",
66
+ "@stacksjs/error-handling": "0.75.33",
67
+ "@stacksjs/logging": "0.75.33",
68
+ "@stacksjs/orm": "0.75.33",
69
+ "@stacksjs/path": "0.75.33",
70
+ "@stacksjs/router": "0.75.33",
71
+ "@stacksjs/security": "0.75.33",
72
+ "@stacksjs/storage": "0.75.33",
73
73
  "@stacksjs/ts-auth": "^0.4.4",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },