@stacksjs/auth 0.75.71 → 0.75.73

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,13 @@
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
+ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime,sqlHelpers}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";async function oauthAuthorizationIntact(tokenId){const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
2
+ SELECT t.id FROM oauth_access_tokens t
3
+ LEFT JOIN oauth_clients c ON c.id = t.oauth_client_id
4
+ LEFT JOIN oauth_grants g ON g.id = t.oauth_grant_id
5
+ WHERE t.id = ${sql.param(1)}
6
+ AND (c.id IS NULL OR c.revoked = ${sql.boolFalse})
7
+ AND (t.oauth_grant_id IS NULL OR (
8
+ g.id IS NOT NULL
9
+ AND g.revoked_at IS NULL
10
+ AND g.client_id = t.oauth_client_id
11
+ ))
12
+ LIMIT 1
13
+ `,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await oauthAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await oauthAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;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}}}
@@ -17,6 +17,17 @@ export declare function withAuthenticatedOAuthTokenClient<T>(clientId: string, c
17
17
  export declare function registerOAuthClient(provider: ResolvedOAuthProviderConfig, ownerId: number, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
18
18
  /** Rotate one active confidential client's secret and reveal the replacement once. */
19
19
  export declare function rotateOAuthClientSecret(ownerId: number, clientId: number): Promise<string | null>;
20
+ /**
21
+ * Revoke a client and everything it was issued, whoever owns it.
22
+ *
23
+ * The administrative counterpart of {@link disableOAuthClient}, and the same
24
+ * cascade: grants, codes, pending requests, refresh and access tokens. The
25
+ * exported `revokeClient()` used to flip only `oauth_clients.revoked`, so the
26
+ * client's access tokens kept authenticating until they expired - a revoked
27
+ * third party went on acting as its users - while their refresh, which does
28
+ * check the client, failed. Returns whether a client by that id existed.
29
+ */
30
+ export declare function revokeOAuthClient(clientId: number): Promise<boolean>;
20
31
  /** Disable one owner-managed client and revoke every credential it issued. */
21
32
  export declare function disableOAuthClient(ownerId: number, clientId: number): Promise<boolean>;
22
33
  export declare interface OAuthClientRegistrationInput {
@@ -72,7 +72,11 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
72
72
  WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)}
73
73
  AND client_type = ${sql.param(5)} AND token_endpoint_auth_method = ${sql.param(6)}
74
74
  AND revoked = ${sql.param(7)}
75
- `,[storedSecret,now,clientId,ownerId,"confidential","client_secret_basic",active]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0}))return null;markContextWrote();return plainTextSecret}export async function disableOAuthClient(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disabled=await db.transaction(async(rawTrx)=>{const trx=rawTrx,rows=await trx.unsafe(`
75
+ `,[storedSecret,now,clientId,ownerId,"confidential","client_secret_basic",active]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0}))return null;markContextWrote();return plainTextSecret}export async function revokeOAuthClient(clientId){if(!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),found=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=sqlDateTime(new Date),revoked=sql.isPostgres?!0:1,changed=await trx.unsafe(`
76
+ UPDATE oauth_clients
77
+ SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
78
+ WHERE id = ${sql.param(3)}
79
+ `,[revoked,now,clientId]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0});markContextWrote();return found}export async function disableOAuthClient(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disabled=await db.transaction(async(rawTrx)=>{const trx=rawTrx,rows=await trx.unsafe(`
76
80
  SELECT id, revoked FROM oauth_clients
77
81
  WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
78
82
  LIMIT 1${lock}
package/dist/policy.js CHANGED
@@ -1 +1 @@
1
- import{AuthorizationResponse}from"./gate";export class BasePolicy{allow(message){return AuthorizationResponse.allow(message)}deny(message,code){return AuthorizationResponse.deny(message,code)}denyIf(condition,message){if(condition)return this.deny(message);return!0}denyUnless(condition,message){if(!condition)return this.deny(message);return!0}allowIf(condition,message){if(condition)return this.allow(message);return!1}}import{log}from"@stacksjs/logging";import*as p from"@stacksjs/path";import{policy as registerPolicy}from"./gate";let policyRegistry=null;async function loadPolicyRegistry(){if(policyRegistry)return policyRegistry;policyRegistry=(async()=>{try{const dir=p.storagePath("framework/auto-imports"),module=await import(`${dir}/policies.ts`);if(!module.policies)return null;const{resolve}=await import("node:path");return Object.fromEntries(Object.entries(module.policies).map(([name,file])=>[name,resolve(dir,file)]))}catch{return null}})();return policyRegistry}function policyDirectories(){return[p.appPath("Policies"),p.storagePath("framework/defaults/app/Policies")]}async function findPolicyFile(name){const registry=await loadPolicyRegistry();if(registry)return registry[name]??null;const{fs}=await import("@stacksjs/storage");for(const dir of policyDirectories()){const candidate=`${dir}/${name}.ts`;if(fs.existsSync(candidate))return candidate}return null}export function resetPolicyRegistry(){policyRegistry=null}export async function discoverPolicies(){const{fs}=await import("@stacksjs/storage"),explicit=new Set,mappings=(await loadGatesModule())?.policies??{};for(const[modelName,config]of Object.entries(mappings)){const policyFile=typeof config==="string"?config:config.policy,policyPath=await findPolicyFile(policyFile);if(!policyPath){log.warn(`[auth] app/Gates.ts maps ${modelName} to ${policyFile}, and no such policy exists`);continue}try{const policyModule=await import(policyPath),PolicyClass=policyModule.default||policyModule[policyFile];if(!PolicyClass){log.warn(`[auth] ${policyPath} has no default export, so ${modelName} has no policy`);continue}registerPolicy(modelName,PolicyClass);explicit.add(modelName);log.debug(`Registered policy: ${policyFile} for ${modelName}`)}catch(error){log.error(`Failed to load policy ${policyFile}:`,error)}}const registry=await loadPolicyRegistry();let discovered;if(registry)discovered=Object.entries(registry).filter(([name])=>name.endsWith("Policy")).map(([policyName,policyPath])=>({policyName,policyPath}));else{const policiesDir=p.appPath("Policies");if(!fs.existsSync(policiesDir)){log.debug("No Policies directory found");return}discovered=fs.readdirSync(policiesDir).filter((file)=>file.endsWith("Policy.ts")).map((file)=>({policyName:file.replace(".ts",""),policyPath:`${policiesDir}/${file}`}))}for(const{policyName,policyPath}of discovered){const modelName=policyName.replace("Policy","");if(explicit.has(modelName))continue;try{const policyModule=await import(policyPath),PolicyClass=policyModule.default||policyModule[policyName];if(PolicyClass){registerPolicy(modelName,PolicyClass);log.debug(`Auto-discovered policy: ${policyName} for ${modelName}`)}}catch(error){log.error(`Failed to load policy ${policyName}:`,error)}}}async function loadGatesModule(){const{fs}=await import("@stacksjs/storage"),gatesPath=p.appPath("Gates.ts");if(!fs.existsSync(gatesPath))return null;const module=await import(gatesPath);return{gates:module.default?.gates??module.gates,policies:module.default?.policies??module.policies,before:module.default?.before??module.before,after:module.default?.after??module.after}}export async function registerGates(){const{define,before,after}=await import("./gate"),authorization=await loadGatesModule();if(!authorization){log.debug("No Gates.ts found");return}for(const[ability,callback]of Object.entries(authorization.gates??{}))if(typeof callback==="function"){define(ability,callback);log.debug(`Registered gate: ${ability}`)}for(const callback of authorization.before??[])if(typeof callback==="function")before(callback);for(const callback of authorization.after??[])if(typeof callback==="function")after(callback);log.debug("Gates registered successfully")}export async function initializeAuthorization(){try{await registerGates()}catch(error){log.error("[auth] app/Gates.ts failed to load - no gates are registered and every Gate check will deny:",error)}try{await discoverPolicies()}catch(error){log.error("[auth] policy discovery failed - model authorization will deny:",error)}}export{AuthorizationResponse};
1
+ import{AuthorizationResponse}from"./gate";export class BasePolicy{allow(message){return AuthorizationResponse.allow(message)}deny(message,code){return AuthorizationResponse.deny(message,code)}denyIf(condition,message){if(condition)return this.deny(message);return!0}denyUnless(condition,message){if(!condition)return this.deny(message);return!0}allowIf(condition,message){if(condition)return this.allow(message);return!1}}import{log}from"@stacksjs/logging";import*as p from"@stacksjs/path";import{policy as registerPolicy}from"./gate";let policyRegistry=null;async function loadPolicyRegistry(){if(policyRegistry)return policyRegistry;policyRegistry=(async()=>{try{const dir=p.storagePath("framework/auto-imports"),module=await import(`${dir}/policies.ts`);if(!module.policies)return null;const{resolve}=await import("node:path");return Object.fromEntries(Object.entries(module.policies).map(([name,file])=>[name,resolve(dir,file)]))}catch{return null}})();return policyRegistry}function policyDirectories(){return[p.appPath("Policies"),p.storagePath("framework/defaults/app/Policies")]}async function findPolicyFile(name){const registry=await loadPolicyRegistry();if(registry)return registry[name]??null;const{fs}=await import("@stacksjs/storage");for(const dir of policyDirectories()){const candidate=`${dir}/${name}.ts`;if(fs.existsSync(candidate))return candidate}return null}export function resetPolicyRegistry(){policyRegistry=null}export async function discoverPolicies(){const{fs}=await import("@stacksjs/storage"),explicit=new Set,mappings=(await loadGatesModule())?.policies??{};for(const[modelName,config]of Object.entries(mappings)){const policyFile=typeof config==="string"?config:config.policy,policyPath=await findPolicyFile(policyFile);if(!policyPath){log.warn(`[auth] app/Gates.ts maps ${modelName} to ${policyFile}, and no such policy exists`);continue}try{const policyModule=await import(policyPath),PolicyClass=policyModule.default||policyModule[policyFile];if(!PolicyClass){log.warn(`[auth] ${policyPath} has no default export, so ${modelName} has no policy`);continue}registerPolicy(modelName,PolicyClass);explicit.add(modelName);log.debug(`Registered policy: ${policyFile} for ${modelName}`)}catch(error){log.error(`Failed to load policy ${policyFile}:`,error)}}const registry=await loadPolicyRegistry();let discovered;if(registry)discovered=Object.entries(registry).filter(([name])=>name.endsWith("Policy")).map(([policyName,policyPath])=>({policyName,policyPath}));else{const policiesDir=p.appPath("Policies");if(!fs.existsSync(policiesDir)){log.debug("No Policies directory found");return}discovered=fs.readdirSync(policiesDir).filter((file)=>file.endsWith("Policy.ts")).map((file)=>({policyName:file.replace(".ts",""),policyPath:`${policiesDir}/${file}`}))}for(const{policyName,policyPath}of discovered){const modelName=policyName.replace(/Policy$/,"");if(explicit.has(modelName))continue;try{const policyModule=await import(policyPath),PolicyClass=policyModule.default||policyModule[policyName];if(PolicyClass){registerPolicy(modelName,PolicyClass);log.debug(`Auto-discovered policy: ${policyName} for ${modelName}`)}}catch(error){log.error(`Failed to load policy ${policyName}:`,error)}}}async function loadGatesModule(){const{fs}=await import("@stacksjs/storage"),gatesPath=p.appPath("Gates.ts");if(!fs.existsSync(gatesPath))return null;const module=await import(gatesPath);return{gates:module.default?.gates??module.gates,policies:module.default?.policies??module.policies,before:module.default?.before??module.before,after:module.default?.after??module.after}}export async function registerGates(){const{define,before,after}=await import("./gate"),authorization=await loadGatesModule();if(!authorization){log.debug("No Gates.ts found");return}for(const[ability,callback]of Object.entries(authorization.gates??{}))if(typeof callback==="function"){define(ability,callback);log.debug(`Registered gate: ${ability}`)}for(const callback of authorization.before??[])if(typeof callback==="function")before(callback);for(const callback of authorization.after??[])if(typeof callback==="function")after(callback);log.debug("Gates registered successfully")}export async function initializeAuthorization(){try{await registerGates()}catch(error){log.error("[auth] app/Gates.ts failed to load - no gates are registered and every Gate check will deny:",error)}try{await discoverPolicies()}catch(error){log.error("[auth] policy discovery failed - model authorization will deny:",error)}}export{AuthorizationResponse};
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 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;
1
+ var {require}=import.meta;import{config}from"@stacksjs/config";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){const entry=this.map.get(key);if(!entry)return;if(entry.expiresAt<=Date.now()){this.map.delete(key);return}return entry.value}has(key){return this.get(key)!==void 0}set(key,value){const ttl=rbacCacheTtl();if(ttl<=0)return this;if(this.map.has(key))this.map.delete(key);this.map.set(key,{value,expiresAt:Date.now()+ttl});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){generation++;return this.map.delete(key)}clear(){generation++;this.map.clear()}}let generation=0;function rbacCacheTtl(){const configured=Number(config.auth?.rbacCacheTtl);return Number.isFinite(configured)&&configured>=0?configured:DEFAULT_RBAC_CACHE_TTL_MS}const DEFAULT_RBAC_CACHE_TTL_MS=30000,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 BoundedMap(RBAC_USER_CACHE_MAX),permissions:new BoundedMap(RBAC_USER_CACHE_MAX)};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}`,cachedRole=cache.roles.get(cacheKey);if(cachedRole)return cachedRole;const observed=generation,role=await getStore().findRoleByName(name,guardName);if(role&&observed===generation)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}`,cachedPermission=cache.permissions.get(cacheKey);if(cachedPermission)return cachedPermission;const observed=generation,permission=await getStore().findPermissionByName(name,guardName);if(permission&&observed===generation)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),cached=cache.userRoles.get(userId);if(cached)return cached;const observed=generation,roles=await getStore().getUserRoles(userId);if(observed===generation)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),cached=cache.userPermissions.get(userId);if(cached)return cached;const observed=generation,rbacStore=getStore();if("getUserPermissions"in rbacStore&&rbacStore.getUserPermissions){const permissions=await rbacStore.getUserPermissions(userId);if(observed===generation)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)}if(observed===generation)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){const cached=cache.rolePermissions.get(roleId);if(cached)return cached;const observed=generation,permissions=await getStore().getRolePermissions(roleId);if(observed===generation)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 +1 @@
1
- import{authCookieToken}from"./cookie";export function requestToken(request){let token=request?.bearerToken?.();if(!token){const header=request?.headers?.get?.("authorization")||request?.headers?.get?.("Authorization");if(typeof header==="string"&&header.startsWith("Bearer "))token=header.substring(7)}if(!token&&request?.headers)token=authCookieToken({headers:request.headers});return token||null}
1
+ import{parseBearerToken}from"@stacksjs/bun-router";import{authCookieToken}from"./cookie";export function requestToken(request){let token=request?.bearerToken?.();const header=request?.headers?.get?.("authorization")??null;if(!token)token=parseBearerToken(header);if(!token&&request?.headers&&!/^\s*bearer\b/i.test(header??""))token=authCookieToken({headers:request.headers});return token||null}
@@ -9,7 +9,7 @@ import { User } from '@stacksjs/orm';
9
9
  * IP/UA fingerprints to compare against). See
10
10
  * stacksjs/stacks#1860 H-6.
11
11
  */
12
- export declare function readRequestFingerprint(override?: { ip?: string | null, userAgent?: string | null }): { ip: string | null, userAgent: string | null };
12
+ export declare function readRequestFingerprint(override?: { ip?: string | null, userAgent?: string | null }): { ip: string | null, userAgent: string | null, inRequest?: boolean };
13
13
  /**
14
14
  * Decide whether a stored session must be rejected because its captured
15
15
  * fingerprint no longer matches the current request (basic hijack detection,
@@ -21,13 +21,17 @@ export declare function readRequestFingerprint(override?: { ip?: string | null,
21
21
  * `{ ip, userAgent }` enforces each independently (userAgent-only is the
22
22
  * safest, since it rarely changes for a real user).
23
23
  *
24
- * A field is compared ONLY when both the stored value and a current-request
25
- * value are present, so a check outside request scope, or a session logged in
26
- * without headers, is never a false "mismatch". On mismatch we reject THIS
27
- * request but leave the session row intact — the real owner (original
24
+ * A session logged in without a value is never compared on that field, and a
25
+ * check outside any request compares nothing. Inside a request, a User-Agent
26
+ * the session stored but the request lacks IS a mismatch: the header is the
27
+ * client's to send or omit, so treating its absence as "nothing to compare"
28
+ * let anyone holding a stolen session id pass the check by leaving it out.
29
+ * A missing address is not, since the client cannot remove its own socket
30
+ * peer - it is missing for every request or none. On mismatch we reject THIS
31
+ * request but leave the session row intact - the real owner (original
28
32
  * fingerprint) keeps working; we don't punish them for an attacker's attempt.
29
33
  */
30
- export declare function fingerprintMismatch(enforce: boolean | { ip?: boolean, userAgent?: boolean } | undefined | null, stored: { ip?: unknown, userAgent?: unknown }, current: { ip: string | null, userAgent: string | null }): boolean;
34
+ export declare function fingerprintMismatch(enforce: boolean | { ip?: boolean, userAgent?: boolean } | undefined | null, stored: { ip?: unknown, userAgent?: unknown }, current: { ip: string | null, userAgent: string | null, inRequest?: boolean }): boolean;
31
35
  /**
32
36
  * Authenticate a user via email and password, creating a session.
33
37
  * Sessions are persisted to the database so they survive server restarts.
@@ -1 +1 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{clientAddress,getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,ip=clientAddress(req),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};
1
+ import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{clientAddress,getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null,inRequest:!1};const headers=req.headers,ip=clientAddress(req),userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent,inRequest:!0}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&stored.userAgent!=null&&current.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;if(enforceUa&&stored.userAgent!=null&&current.userAgent==null&&current.inRequest===!0)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0)return;let touch=db.updateTable("sessions").set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
package/dist/tokens.js CHANGED
@@ -73,7 +73,7 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
73
73
  `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
74
74
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
75
75
  VALUES (?, ?, 0, ?, ${appNow()})
76
- `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const ownerType=param(bindings.length+1);bindings.push(DEFAULT_TOKENABLE_TYPE);const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
76
+ `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}function configuredMinutes(milliseconds,fallback){const value=Number(milliseconds);return(Number.isFinite(value)&&value>0?value:fallback)/60000}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=configuredMinutes(config.auth?.tokenExpiry,3600000),refreshExpiresInDays=configuredMinutes(config.auth?.refreshTokenExpiry,2592000000)/1440}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const ownerType=param(bindings.length+1);bindings.push(DEFAULT_TOKENABLE_TYPE);const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
77
77
  SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.oauth_grant_id, t.name, t.scopes
78
78
  FROM oauth_refresh_tokens r
79
79
  JOIN oauth_access_tokens t ON r.access_token_id = t.id
@@ -173,8 +173,4 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
173
173
  SELECT id, name, secret, provider, redirect, personal_access_client,
174
174
  password_client, revoked, created_at, updated_at
175
175
  FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
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(`
177
- UPDATE oauth_clients
178
- SET revoked = ${boolTrue}, updated_at = ${appNow()}
179
- WHERE id = ${param(1)}
180
- `,[clientId]);markContextWrote()}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:tokenDate(row.created_at),updatedAt:row.updated_at==null?null:tokenDate(row.updated_at)}}
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{revokeOAuthClient}=await import("./oauth-client-registration");await revokeOAuthClient(clientId)}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:tokenDate(row.created_at),updatedAt:row.updated_at==null?null:tokenDate(row.updated_at)}}
@@ -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).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};
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}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){const{valid,counter:step}=await verifyTOTPWithCounter(code,{secret});if(!valid||step===null)return!1;return persistTwoFactorState(userId,secret,!0,step)}export async function disableTwoFactor(userId){await persistTwoFactorState(userId,null,!1,null)}async function persistTwoFactorState(userId,secret,enabled,lastUsedStep){return db.transaction(async()=>{await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:enabled,two_factor_last_used_step:lastUsedStep}).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.71",
5
+ "version": "0.75.73",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -57,19 +57,19 @@
57
57
  "prepublishOnly": "bun run build"
58
58
  },
59
59
  "dependencies": {
60
- "@stacksjs/bun-router": "^0.1.22",
61
- "@stacksjs/cache": "0.75.71",
62
- "@stacksjs/config": "0.75.71",
63
- "@stacksjs/database": "0.75.71",
64
- "@stacksjs/email": "0.75.71",
65
- "@stacksjs/env": "0.75.71",
66
- "@stacksjs/error-handling": "0.75.71",
67
- "@stacksjs/logging": "0.75.71",
68
- "@stacksjs/orm": "0.75.71",
69
- "@stacksjs/path": "0.75.71",
70
- "@stacksjs/router": "0.75.71",
71
- "@stacksjs/security": "0.75.71",
72
- "@stacksjs/storage": "0.75.71",
60
+ "@stacksjs/bun-router": "^0.1.24",
61
+ "@stacksjs/cache": "0.75.73",
62
+ "@stacksjs/config": "0.75.73",
63
+ "@stacksjs/database": "0.75.73",
64
+ "@stacksjs/email": "0.75.73",
65
+ "@stacksjs/env": "0.75.73",
66
+ "@stacksjs/error-handling": "0.75.73",
67
+ "@stacksjs/logging": "0.75.73",
68
+ "@stacksjs/orm": "0.75.73",
69
+ "@stacksjs/path": "0.75.73",
70
+ "@stacksjs/router": "0.75.73",
71
+ "@stacksjs/security": "0.75.73",
72
+ "@stacksjs/storage": "0.75.73",
73
73
  "@stacksjs/ts-auth": "^0.4.5",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },