@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.
- package/dist/authentication.js +1 -1
- package/dist/middleware.js +1 -1
- package/dist/tokens.d.ts +35 -4
- package/dist/tokens.js +32 -31
- package/package.json +13 -13
package/dist/authentication.js
CHANGED
|
@@ -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}}}
|
package/dist/middleware.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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.
|
|
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.
|
|
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, $
|
|
51
|
-
`,[refreshRow.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
62
|
-
"@stacksjs/config": "0.74.
|
|
63
|
-
"@stacksjs/database": "0.74.
|
|
64
|
-
"@stacksjs/email": "0.74.
|
|
65
|
-
"@stacksjs/env": "0.74.
|
|
66
|
-
"@stacksjs/error-handling": "0.74.
|
|
67
|
-
"@stacksjs/logging": "0.74.
|
|
68
|
-
"@stacksjs/orm": "0.74.
|
|
69
|
-
"@stacksjs/path": "0.74.
|
|
70
|
-
"@stacksjs/router": "0.74.
|
|
71
|
-
"@stacksjs/security": "0.74.
|
|
72
|
-
"@stacksjs/storage": "0.74.
|
|
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
|
},
|