@stacksjs/auth 0.74.29 → 0.74.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}import{createToken as createRawToken,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().executeTakeFirst();if(!result)return null;const token=result;return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=options?.expiresAt??new Date(Date.now()+accessTtlMs),expiresInMinutes=Math.max(1,Math.floor((expiresAt.getTime()-Date.now())/60000)),refreshExpiresInDays=options?.refreshExpiresInDays??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresInMinutes,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.user_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();return}}const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=await this.getTokenFromId(accessToken.id)??void 0;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();if(!accessToken?.user_id)return;const user=await User.find(accessToken.user_id);if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.user_id)))return;return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id"]).executeTakeFirst();if(!accessToken)return;const token=await this.getTokenFromId(Number(accessToken.id));if(token&&state)state.currentToken=token;return token??void 0}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.selectFrom("oauth_access_tokens").where("user_id","=",uid).where("revoked","=",!1).selectAll().execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}))}static async revokeToken(token){const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(token)).select(["id"]).executeTakeFirst();if(accessToken)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("token","=",hashToken(token)).execute()}static async revokeTokenById(tokenId){await this.revokeRefreshTokensFor(tokenId);await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("id","=",tokenId).execute()}static async revokeRefreshTokensFor(accessTokenId){await db.updateTable("oauth_refresh_tokens").set({revoked:!0}).where("access_token_id","=",accessTokenId).execute()}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;const accessTokens=await db.selectFrom("oauth_access_tokens").where("user_id","=",uid).select(["id"]).execute();for(const accessToken of accessTokens)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("user_id","=",uid).execute()}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("user_id","=",uid).where("id","!=",currentToken.id).execute()}static async pruneExpiredTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;const remainingMs=existing.expiresAt?existing.expiresAt.getTime()-Date.now():config.auth.tokenExpiry??3600000,expiresInMinutes=Math.max(1,Math.floor(remainingMs/60000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresInMinutes,withRefreshToken:!1})).plainTextToken}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
1
+ import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().executeTakeFirst();if(!result)return null;const token=result;return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=options?.expiresAt??new Date(Date.now()+accessTtlMs),expiresInMinutes=Math.max(1,Math.floor((expiresAt.getTime()-Date.now())/60000)),refreshExpiresInDays=options?.refreshExpiresInDays??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresInMinutes,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();return}}const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=await this.getTokenFromId(accessToken.id)??void 0;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id"]).executeTakeFirst();if(!accessToken)return;const token=await this.getTokenFromId(Number(accessToken.id));if(token&&state)state.currentToken=token;return token??void 0}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).selectAll().execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}))}static async revokeToken(token){const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(token)).select(["id"]).executeTakeFirst();if(accessToken)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("token","=",hashToken(token)).execute()}static async revokeTokenById(tokenId){await this.revokeRefreshTokensFor(tokenId);await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("id","=",tokenId).execute()}static async revokeRefreshTokensFor(accessTokenId){await db.updateTable("oauth_refresh_tokens").set({revoked:!0}).where("access_token_id","=",accessTokenId).execute()}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;const accessTokens=await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).select(["id"]).execute();for(const accessToken of accessTokens)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).execute()}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("id","!=",currentToken.id).execute()}static async pruneExpiredTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;const remainingMs=existing.expiresAt?existing.expiresAt.getTime()-Date.now():config.auth.tokenExpiry??3600000,expiresInMinutes=Math.max(1,Math.floor(remainingMs/60000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresInMinutes,withRefreshToken:!1})).plainTextToken}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
@@ -1 +1 @@
1
- import{Auth}from"./authentication";import{requestToken}from"./request-token";export async function authMiddleware(request){const bearerToken=requestToken(request);if(!bearerToken){const error=Error("No authentication token provided.");error.statusCode=401;throw error}const user=await Auth.getUserFromToken(bearerToken);if(!user){const error=Error("Invalid or expired authentication token.");error.statusCode=401;throw error}Auth.setUser(user);request._authenticatedUser=user;const accessToken=await Auth.currentAccessToken();request._currentAccessToken=accessToken}export const authMiddlewareHandler={name:"auth",handle:authMiddleware};export async function authenticatedUser(request){const cached=request?._authenticatedUser;if(isUserLike(cached))return cached;const macro=request?.user;if(typeof macro==="function"){const resolved=await macro();return isUserLike(resolved)?resolved:void 0}if(isUserLike(macro))return macro;return}function isUserLike(value){if(!value||typeof value!=="object")return!1;const id=value.id;if(typeof id==="number")return Number.isFinite(id)&&id>0;return typeof id==="string"&&id.trim().length>0}
1
+ import{Auth}from"./authentication";import{requestToken}from"./request-token";export async function authMiddleware(request){const bearerToken=requestToken(request);if(!bearerToken){const error=Error("No authentication token provided.");error.statusCode=401;throw error}const user=await Auth.getUserFromToken(bearerToken);if(!user){const error=Error("Invalid or expired authentication token.");error.statusCode=401;throw error}Auth.setUser(user);request._authenticatedUser=user;const accessToken=await Auth.currentAccessToken();request._currentAccessToken=accessToken}export const authMiddlewareHandler={name:"auth",handle:authMiddleware};export async function authenticatedUser(request){const cached=request?._authenticatedUser;if(isUserLike(cached))return cached;const macro=request?.user;if(typeof macro==="function"){const resolved=await macro.call(request);return isUserLike(resolved)?resolved:void 0}if(isUserLike(macro))return macro;return}function isUserLike(value){if(!value||typeof value!=="object")return!1;const id=value.id;if(typeof id==="number")return Number.isFinite(id)&&id>0;return typeof id==="string"&&id.trim().length>0}
package/dist/tokens.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare function isIssuedBeforePasswordChange(createdAt: unknown, changed
20
20
  * import { tokens } from '@stacksjs/auth'
21
21
  * const userTokens = await tokens(user.id)
22
22
  */
23
- export declare function tokens(userId: number): Promise<AccessToken[]>;
23
+ export declare function tokens(userId: number, tokenableType?: string): Promise<AccessToken[]>;
24
24
  /**
25
25
  * Get a specific token by its plain text value
26
26
  * Uses hash comparison for security
@@ -120,6 +120,13 @@ export declare function createToken(userId: number, name?: string, scopes?: stri
120
120
  userAgent?: string | null
121
121
  /** Where the request came from, for the same list. */
122
122
  ipAddress?: string | null
123
+ /**
124
+ * The owner's table, for a token that does not belong to a user.
125
+ *
126
+ * `oauth_access_tokens` is polymorphic; a caller that passes only an id
127
+ * gets `users`, which is what every caller predating the pair meant.
128
+ */
129
+ tokenableType?: string
123
130
  }): Promise<PersonalAccessTokenResult>;
124
131
  /**
125
132
  * Exchange a refresh token for a new access token
@@ -162,7 +169,7 @@ export declare function revokeRefreshToken(refreshTokenPlain: string): Promise<v
162
169
  * import { revokeAllRefreshTokens } from '@stacksjs/auth'
163
170
  * await revokeAllRefreshTokens(user.id)
164
171
  */
165
- export declare function revokeAllRefreshTokens(userId: number): Promise<void>;
172
+ export declare function revokeAllRefreshTokens(userId: number, tokenableType?: string): Promise<void>;
166
173
  /**
167
174
  * Delete expired refresh tokens (cleanup)
168
175
  *
@@ -202,7 +209,7 @@ export declare function revokeTokenById(tokenId: number): Promise<void>;
202
209
  * import { revokeAllTokens } from '@stacksjs/auth'
203
210
  * await revokeAllTokens(user.id)
204
211
  */
205
- export declare function revokeAllTokens(userId: number): Promise<void>;
212
+ export declare function revokeAllTokens(userId: number, tokenableType?: string): Promise<void>;
206
213
  /**
207
214
  * Revoke all tokens except the current one
208
215
  *
@@ -210,7 +217,7 @@ export declare function revokeAllTokens(userId: number): Promise<void>;
210
217
  * import { revokeOtherTokens } from '@stacksjs/auth'
211
218
  * await revokeOtherTokens(user.id)
212
219
  */
213
- export declare function revokeOtherTokens(userId: number): Promise<void>;
220
+ export declare function revokeOtherTokens(userId: number, tokenableType?: string): Promise<void>;
214
221
  /**
215
222
  * Delete expired tokens (cleanup)
216
223
  *
@@ -270,6 +277,30 @@ export declare function parseScopes(scopes: string | string[] | null | undefined
270
277
  declare const dbDriver: DatabaseDriver;
271
278
  /** Cross-database SQL helpers */
272
279
  declare const sql: ReturnType<typeof sqlHelpers>;
280
+ /**
281
+ * Read `users.password_changed_at` for a user.
282
+ *
283
+ * Binds a token's validity to the account's credential state: a token
284
+ * issued before the user last changed their password is no longer
285
+ * trusted, regardless of its own `revoked`/`expires_at` flags. This is
286
+ * the durable, use-time backstop behind the post-reset revocation sweep
287
+ * (#1947) — even a freshly minted pair that the sweep never saw is
288
+ * rejected on first use.
289
+ *
290
+ * Returns `null` on ANY error (missing column / missing table) so a
291
+ * not-yet-migrated database degrades to legacy-allow rather than locking
292
+ * everyone out. Accepts an optional query runner so the refresh exchange
293
+ * can read the stamp inside its own transaction.
294
+ */
295
+ /**
296
+ * The table a token's owner lives in when nobody says otherwise.
297
+ *
298
+ * `oauth_access_tokens` is polymorphic - `tokenable_type` holds the owner's
299
+ * TABLE name, matching what the framework's other polymorphic traits write
300
+ * (`taggable_type: tableName`) - but every caller that predates the pair passes
301
+ * a user id and nothing else, so this is what they get.
302
+ */
303
+ export declare const DEFAULT_TOKENABLE_TYPE: 'users';
273
304
  /**
274
305
  * Alias for currentAccessToken
275
306
  *
package/dist/tokens.js CHANGED
@@ -1,27 +1,28 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export async function getPasswordChangedAt(userId,q=db){if(userId===null||userId===void 0)return null;try{const value=(await q.unsafe(`
1
+ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(userId,q=db){if(userId===null||userId===void 0)return null;try{const value=(await q.unsafe(`
2
2
  SELECT password_changed_at FROM users WHERE id = ${param(1)} LIMIT 1
3
- `,[userId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch{return null}}export function isIssuedBeforePasswordChange(createdAt,changedAt){if(!changedAt)return!1;if(createdAt===null||createdAt===void 0)return!1;const created=parseSqlDateTime(createdAt);if(!created)return!1;return created.getTime()<changedAt.getTime()}export async function tokens(userId){return(await db.unsafe(`
3
+ `,[userId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch{return null}}export function isIssuedBeforePasswordChange(createdAt,changedAt){if(!changedAt)return!1;if(createdAt===null||createdAt===void 0)return!1;const created=parseSqlDateTime(createdAt);if(!created)return!1;return created.getTime()<changedAt.getTime()}export async function tokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){return(await db.unsafe(`
4
4
  SELECT t.*, c.provider as client_provider
5
5
  FROM oauth_access_tokens t
6
6
  LEFT JOIN oauth_clients c ON t.oauth_client_id = c.id
7
- WHERE t.user_id = ${param(1)}
7
+ WHERE t.tokenable_id = ${param(1)}
8
+ AND t.tokenable_type = ${param(2)}
8
9
  AND t.revoked = ${boolFalse}
9
10
  ORDER BY t.created_at DESC
10
- `,[userId])).map((row)=>({id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at?new Date(row.expires_at):null,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date,userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
11
+ `,[userId,tokenableType])).map((row)=>({id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at?new Date(row.expires_at):null,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date,userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
11
12
  SELECT * FROM oauth_access_tokens
12
13
  WHERE token = ${param(1)}
13
14
  AND revoked = ${boolFalse}
14
15
  AND (expires_at IS NULL OR expires_at > ${appNow()})
15
16
  LIMIT 1
16
- `,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.user_id)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at?new Date(row.expires_at):null,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return attached;const bearerToken=request.bearerToken?.();if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null}=options,client=(await db.unsafe(`
17
+ `,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.user_id)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at?new Date(row.expires_at):null,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return attached;const bearerToken=request.bearerToken?.();if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,client=(await db.unsafe(`
17
18
  SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} LIMIT 1
18
19
  `))[0];if(!client)throw new HttpError(500,"No personal access client found. Run ./buddy auth:setup first.");const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),expiresAt=new Date;expiresAt.setMinutes(expiresAt.getMinutes()+expiresInMinutes);const agent=userAgent?String(userAgent).slice(0,255):null,address=ipAddress?String(ipAddress).slice(0,45):null;if(isPostgres)await db.unsafe(`
19
- INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
20
- VALUES ($1, $2, $3, $4, $5, false, $6, $7, $8, ${appNow()}, ${appNow()})
21
- `,[userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);else await db.unsafe(`
22
- INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
23
- VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
24
- `,[userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await db.unsafe(`
20
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
21
+ VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, $8, $9, ${appNow()}, ${appNow()})
22
+ `,[tokenableType,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);else await db.unsafe(`
23
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
25
+ `,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await db.unsafe(`
25
26
  SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
26
27
  `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await db.unsafe(`
27
28
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
@@ -30,14 +31,14 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
30
31
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
31
32
  VALUES (?, ?, 0, ?, ${appNow()})
32
33
  `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)])}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:expiresInMinutes*60}}export async function refreshToken(refreshTokenPlain,options={}){const{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain);return await db.transaction(async(rawTrx)=>{const trx=rawTrx,forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
33
- SELECT r.*, t.user_id, t.oauth_client_id, t.name, t.scopes
34
+ SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.name, t.scopes
34
35
  FROM oauth_refresh_tokens r
35
36
  JOIN oauth_access_tokens t ON r.access_token_id = t.id
36
37
  WHERE r.token = ${param(1)}
37
38
  AND r.revoked = ${boolFalse}
38
39
  AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
39
40
  LIMIT 1${forUpdate}
40
- `,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.user_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
41
+ `,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
41
42
  UPDATE oauth_refresh_tokens
42
43
  SET revoked = ${boolTrue}
43
44
  WHERE id = ${param(1)}
@@ -46,12 +47,12 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
46
47
  SET revoked = ${boolTrue}
47
48
  WHERE id = ${param(1)}
48
49
  `,[refreshRow.access_token_id]);const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),expiresAt=new Date;expiresAt.setMinutes(expiresAt.getMinutes()+expiresInMinutes);if(isPostgres)await trx.unsafe(`
49
- INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
50
- VALUES ($1, $2, $3, $4, $5, false, $6, ${appNow()}, ${appNow()})
51
- `,[refreshRow.user_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);else await trx.unsafe(`
52
- INSERT INTO oauth_access_tokens (user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
53
- VALUES (?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
54
- `,[refreshRow.user_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);const row=(await trx.unsafe(`
50
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
51
+ VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, ${appNow()}, ${appNow()})
52
+ `,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);else await trx.unsafe(`
53
+ INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
55
+ `,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);const row=(await trx.unsafe(`
55
56
  SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
56
57
  `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.user_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
57
58
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
@@ -69,13 +70,13 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
69
70
  UPDATE oauth_refresh_tokens
70
71
  SET revoked = ${boolTrue}
71
72
  WHERE token = ${param(1)}
72
- `,[hashedRefreshToken])}export async function revokeAllRefreshTokens(userId){await db.unsafe(`
73
+ `,[hashedRefreshToken])}export async function revokeAllRefreshTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await db.unsafe(`
73
74
  UPDATE oauth_refresh_tokens
74
75
  SET revoked = ${boolTrue}
75
76
  WHERE access_token_id IN (
76
- SELECT id FROM oauth_access_tokens WHERE user_id = ${param(1)}
77
+ SELECT id FROM oauth_access_tokens WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
77
78
  )
78
- `,[userId])}export async function deleteExpiredRefreshTokens(){const written=await db.unsafe(`
79
+ `,[userId,tokenableType])}export async function deleteExpiredRefreshTokens(){const written=await db.unsafe(`
79
80
  DELETE FROM oauth_refresh_tokens
80
81
  WHERE expires_at < ${appNow()}
81
82
  `);return Number(written?.changes??written?.rowCount??0)}export async function deleteRevokedRefreshTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const written=await db.unsafe(`
@@ -99,30 +100,30 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
99
100
  UPDATE oauth_access_tokens
100
101
  SET revoked = ${boolTrue}, updated_at = ${appNow()}
101
102
  WHERE id = ${param(1)}
102
- `,[tokenId])}export async function revokeAllTokens(userId){await revokeAllRefreshTokens(userId);await db.unsafe(`
103
+ `,[tokenId])}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeAllRefreshTokens(userId);await db.unsafe(`
103
104
  UPDATE oauth_access_tokens
104
105
  SET revoked = ${boolTrue}, updated_at = ${appNow()}
105
- WHERE user_id = ${param(1)}
106
- `,[userId])}export async function revokeOtherTokens(userId){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId);if(isPostgres){await db.unsafe(`
106
+ WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
107
+ `,[userId,tokenableType])}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId);if(isPostgres){await db.unsafe(`
107
108
  UPDATE oauth_refresh_tokens
108
109
  SET revoked = true
109
110
  WHERE access_token_id IN (
110
- SELECT id FROM oauth_access_tokens WHERE user_id = $1 AND id != $2
111
+ SELECT id FROM oauth_access_tokens WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
111
112
  )
112
- `,[userId,current.id]);await db.unsafe(`
113
+ `,[userId,current.id,tokenableType]);await db.unsafe(`
113
114
  UPDATE oauth_access_tokens
114
115
  SET revoked = true, updated_at = ${appNow()}
115
- WHERE user_id = $1 AND id != $2
116
+ WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
116
117
  `,[userId,current.id])}else{await db.unsafe(`
117
118
  UPDATE oauth_refresh_tokens
118
119
  SET revoked = 1
119
120
  WHERE access_token_id IN (
120
- SELECT id FROM oauth_access_tokens WHERE user_id = ? AND id != ?
121
+ SELECT id FROM oauth_access_tokens WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
121
122
  )
122
- `,[userId,current.id]);await db.unsafe(`
123
+ `,[userId,tokenableType,current.id]);await db.unsafe(`
123
124
  UPDATE oauth_access_tokens
124
125
  SET revoked = 1, updated_at = ${appNow()}
125
- WHERE user_id = ? AND id != ?
126
+ WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
126
127
  `,[userId,current.id])}}export async function deleteExpiredTokens(){await db.unsafe(`
127
128
  DELETE FROM oauth_refresh_tokens
128
129
  WHERE access_token_id IN (
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.29",
5
+ "version": "0.74.31",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.11",
61
- "@stacksjs/cache": "0.74.29",
62
- "@stacksjs/config": "0.74.29",
63
- "@stacksjs/database": "0.74.29",
64
- "@stacksjs/email": "0.74.29",
65
- "@stacksjs/env": "0.74.29",
66
- "@stacksjs/error-handling": "0.74.29",
67
- "@stacksjs/logging": "0.74.29",
68
- "@stacksjs/orm": "0.74.29",
69
- "@stacksjs/path": "0.74.29",
70
- "@stacksjs/router": "0.74.29",
71
- "@stacksjs/security": "0.74.29",
72
- "@stacksjs/storage": "0.74.29",
61
+ "@stacksjs/cache": "0.74.31",
62
+ "@stacksjs/config": "0.74.31",
63
+ "@stacksjs/database": "0.74.31",
64
+ "@stacksjs/email": "0.74.31",
65
+ "@stacksjs/env": "0.74.31",
66
+ "@stacksjs/error-handling": "0.74.31",
67
+ "@stacksjs/logging": "0.74.31",
68
+ "@stacksjs/orm": "0.74.31",
69
+ "@stacksjs/path": "0.74.31",
70
+ "@stacksjs/router": "0.74.31",
71
+ "@stacksjs/security": "0.74.31",
72
+ "@stacksjs/storage": "0.74.31",
73
73
  "@stacksjs/ts-auth": "^0.4.4",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },