@stacksjs/auth 0.74.46 → 0.74.47
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/cookie-auth.js +1 -1
- package/dist/cookie.js +1 -1
- package/dist/session-auth.d.ts +1 -1
- package/dist/session-auth.js +1 -1
- package/dist/token-revocation.d.ts +4 -0
- package/dist/token-revocation.js +19 -0
- package/dist/tokens.d.ts +3 -1
- package/dist/tokens.js +53 -88
- 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,DEFAULT_TOKENABLE_TYPE,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().executeTakeFirst();if(!result)return null;const token=result;return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=options?.expiresAt??new Date(Date.now()+accessTtlMs),expiresInMinutes=Math.max(1,Math.floor((expiresAt.getTime()-Date.now())/60000)),refreshExpiresInDays=options?.refreshExpiresInDays??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresInMinutes,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();return}}const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=await this.getTokenFromId(accessToken.id)??void 0;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id"]).executeTakeFirst();if(!accessToken)return;const token=await this.getTokenFromId(Number(accessToken.id));if(token&&state)state.currentToken=token;return token??void 0}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).selectAll().execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}))}static async revokeToken(token){const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(token)).select(["id"]).executeTakeFirst();if(accessToken)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("token","=",hashToken(token)).execute()}static async revokeTokenById(tokenId){await this.revokeRefreshTokensFor(tokenId);await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("id","=",tokenId).execute()}static async revokeRefreshTokensFor(accessTokenId){await db.updateTable("oauth_refresh_tokens").set({revoked:!0}).where("access_token_id","=",accessTokenId).execute()}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;const accessTokens=await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).select(["id"]).execute();for(const accessToken of accessTokens)await this.revokeRefreshTokensFor(Number(accessToken.id));await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).execute()}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await db.updateTable("oauth_access_tokens").set({revoked:!0,updated_at:formatDate(new Date)}).where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("id","!=",currentToken.id).execute()}static async pruneExpiredTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;const remainingMs=existing.expiresAt?existing.expiresAt.getTime()-Date.now():config.auth.tokenExpiry??3600000,expiresInMinutes=Math.max(1,Math.floor(remainingMs/60000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresInMinutes,withRefreshToken:!1})).plainTextToken}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).selectAll().executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async validateClient(clientId,clientSecret){const client=await db.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).selectAll().executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return!1}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored);const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return!1}return timingSafeEqual(storedBuf,provided)}static async getTokenFromId(tokenId){const result=await db.selectFrom("oauth_access_tokens").where("id","=",tokenId).selectAll().executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:parseSqlDateTime(token.expires_at),createdAt:parseSqlDateTime(token.created_at)??new Date,updatedAt:parseSqlDateTime(token.updated_at)??new Date,revoked:!!token.revoked}}static async attempt(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return!1;if(hashCheck&&user){await RateLimiter.resetAttempts(email);const state=authStateOrNull();if(state)state.authUser=user;return!0}await RateLimiter.recordFailedAttempt(email);return!1}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async login(credentials,options){if(!await this.attempt(credentials))return null;const username=config.auth.username||"email",usernameValue=credentials[username];if(usernameValue===void 0)return null;const authedUser=authStateOrNull()?.authUser??await User.where(username,"=",usernameValue).first();if(!authedUser)return null;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(authedUser,options);return{user:authedUser,token:plainTextToken,refreshToken,expiresIn}}static async loginUsingId(userId,options){const user=await User.find(userId);if(!user)return null;const state=authStateOrNull();if(state)state.authUser=user;const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken();if(!bearerToken)return;const user=await this.getUserFromToken(bearerToken);if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??Math.max(1,Math.round((config.auth.refreshTokenExpiry??2592000000)/86400000));log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestToken(credentials,clientId,clientSecret){if(!await this.validateClient(clientId,clientSecret))throw new HttpError(401,"Invalid client credentials");const isValid=await this.attempt(credentials),authedUser=authStateOrNull()?.authUser;if(!isValid||!authedUser)return null;return{token:await this.createToken(authedUser,"user-auth-token")}}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return!1}if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date){await db.deleteFrom("oauth_access_tokens").where("id","=",accessToken.id).execute();return}if(accessToken.revoked)return;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();return}}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken)return state.currentToken;const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).selectAll().execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:parseSqlDateTime(token.expires_at),createdAt:token.created_at?new Date(String(token.created_at)):new Date,updatedAt:token.updated_at?new Date(String(token.updated_at)):new Date,revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("expires_at","<",formatDate(new Date)).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async pruneRevokedTokens(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),existing=await findRawToken(oldToken);if(!existing)return null;if((await db.selectFrom("oauth_access_tokens").where("id","=",existing.id).select("tokenable_type").executeTakeFirst())?.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const expiresAt=existing.expiresAt??new Date(Date.now()+(config.auth.tokenExpiry??3600000));await revokeRawToken(oldToken);const user=await User.find(existing.userId);if(!user)return null;return(await this.createTokenForUser(user,{name:existing.name,abilities:existing.scopes??["*"],expiresAt,withRefreshToken:!1})).plainTextToken}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
package/dist/cookie-auth.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{authCookieToken,clearAuthCookie}from"./cookie";import{Auth}from"./authentication";export{authCookie,authCookieName,authCookieToken,clearAuthCookie,shouldSecureAuthCookie}from"./cookie";export async function userFromCookie(request,options={}){const token=authCookieToken(request,options);if(!token)return;return Auth.getUserFromToken(token)}export async function cookieCheck(request,options={}){return Boolean(await userFromCookie(request,options))}export async function logoutCookie(request,options={}){const token=authCookieToken(request,options);if(token)
|
|
1
|
+
import{authCookieToken,clearAuthCookie}from"./cookie";import{Auth}from"./authentication";export{authCookie,authCookieName,authCookieToken,clearAuthCookie,shouldSecureAuthCookie}from"./cookie";export async function userFromCookie(request,options={}){const token=authCookieToken(request,options);if(!token)return;return Auth.getUserFromToken(token)}export async function cookieCheck(request,options={}){return Boolean(await userFromCookie(request,options))}export async function logoutCookie(request,options={}){const token=authCookieToken(request,options);if(token)await Auth.revokeToken(token);return clearAuthCookie(options)}
|
package/dist/cookie.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a cookie name - set config.auth.cookie.name instead (stacksjs/stacks#2236).`)}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS - the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}return}
|
|
1
|
+
import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";const COOKIE_NAME_RE=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;export function authCookieName(options){if(options?.name)return options.name;const configured=config.auth?.cookie?.name;if(typeof configured==="string"&&configured.length>0)return configured;const legacy=config.auth?.defaultTokenName;if(typeof legacy==="string"&&legacy.length>0&&legacy!=="auth-token"){if(COOKIE_NAME_RE.test(legacy))return legacy;console.warn(`[auth] config.auth.defaultTokenName ("${legacy}") is not a valid cookie name and is being ignored for cookie naming; using "auth-token". defaultTokenName is a personal access token label, not a cookie name - set config.auth.cookie.name instead (stacksjs/stacks#2236).`)}return"auth-token"}function cookieName(options){return authCookieName(options)}function defaultMaxAge(){const milliseconds=Number(config.auth?.tokenExpiry??3600000);if(!Number.isFinite(milliseconds)||milliseconds<=0)return 3600;return Math.max(60,Math.round(milliseconds/1000))}function isLoopbackHost(hostname){const host=hostname.toLowerCase();return host==="localhost"||host.endsWith(".localhost")||host==="127.0.0.1"||host==="0.0.0.0"||host==="::1"||host==="[::1]"}export function shouldSecureAuthCookie(app=config.app??{}){const rawUrl=String(app?.url??process.env.APP_URL??"").trim();if(rawUrl)try{const url=new URL(rawUrl.includes("://")?rawUrl:`http://${rawUrl}`);if(url.protocol==="https:")return!0;return!isLoopbackHost(url.hostname)}catch{}const environment=String(app?.env??process.env.APP_ENV??"");return!(environment==="local"||environment==="dev")}let warnedInsecureOverride=!1;export function authCookie(token,options={}){const parts=[`${cookieName(options)}=${encodeURIComponent(token)}`,`Path=${options.path??"/"}`,`Max-Age=${options.maxAge??defaultMaxAge()}`,"HttpOnly",`SameSite=${options.sameSite??"Lax"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.secure??shouldSecureAuthCookie())parts.push("Secure");else if(options.secure===!1&&shouldSecureAuthCookie()&&!warnedInsecureOverride){warnedInsecureOverride=!0;log.warn("[auth] authCookie() was asked for secure: false while the app URL is HTTPS - the session cookie will also travel over plain HTTP.")}return parts.join("; ")}export function clearAuthCookie(options={}){return authCookie("",{...options,maxAge:0})}export function authCookieToken(request,options={}){const header=request.headers.get("cookie");if(!header)return;const wanted=cookieName(options);for(const pair of header.split(";")){const index=pair.indexOf("=");if(index===-1)continue;if(pair.slice(0,index).trim()!==wanted)continue;try{const value=decodeURIComponent(pair.slice(index+1).trim());return value.length>0?value:void 0}catch{return}}return}
|
package/dist/session-auth.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare function sessionLogout(sessionId: string): Promise<void>;
|
|
|
36
36
|
* session cookie survives a password reset for up to 24h
|
|
37
37
|
* (stacksjs/stacks#1947).
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* Like `sessionLogout`, real failures propagate (fail loud): a reset
|
|
40
40
|
* that reports success while the attacker's session lives would be a
|
|
41
41
|
* lie. A missing `sessions` table alone is a benign no-op — no
|
|
42
42
|
* framework migration creates it (only userland adopting session-auth
|
package/dist/session-auth.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){
|
|
1
|
+
import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,sqlDateTime,parseSqlDateTime}from"@stacksjs/database";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000),{ip,userAgent}=readRequestFingerprint(fingerprint);log.debug(`[auth] Session created for user#${user.id}`);try{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute()}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}return{user,sessionId}}export async function sessionLogout(sessionId){await db.deleteFrom("sessions").where("id","=",sessionId).execute();log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.deleteFrom("sessions").where("user_id","=",userId).execute()}catch(err){const message=err instanceof Error?err.message:String(err);if(message.includes("sessions")&&/no such table|does not exist|doesn't exist/i.test(message))return;throw err}}export async function sessionUser(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){try{const session=await db.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await db.deleteFrom("sessions").where("id","=",sessionId).execute();return!1}if(sessionFingerprintRejected(session))return!1;const newExpiry=new Date(Date.now()+ttlMs),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId).returning("id").executeTakeFirst();return Boolean(result)}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Revoke one access/refresh pair without partially applying a failed logout. */
|
|
2
|
+
export declare function revokeTokenPair(selector: { id: number } | { hash: string }): Promise<void>;
|
|
3
|
+
/** Revoke an owner's access/refresh pairs, optionally keeping one session. */
|
|
4
|
+
export declare function revokeTokenPairs(userId: number, ownerType: string, exceptId?: number): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import{db,getDatabaseDialect,markContextWrote,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";export async function revokeTokenPair(selector){await db.transaction(async(trx)=>{const sql=sqlHelpers(getDatabaseDialect()),byId="id"in selector,row=(await trx.unsafe(`
|
|
2
|
+
SELECT id FROM oauth_access_tokens
|
|
3
|
+
WHERE ${byId?"id":"token"} = ${sql.param(1)}${sql.isSqlite?"":" FOR UPDATE"}
|
|
4
|
+
`,[byId?selector.id:selector.hash]))[0];if(!row)return;markContextWrote();await trx.unsafe(`
|
|
5
|
+
UPDATE oauth_refresh_tokens SET revoked = ${sql.boolTrue}
|
|
6
|
+
WHERE access_token_id = ${sql.param(1)}
|
|
7
|
+
`,[row.id]);await trx.unsafe(`
|
|
8
|
+
UPDATE oauth_access_tokens SET revoked = ${sql.boolTrue}, updated_at = ${sqlDateTimeLiteral()}
|
|
9
|
+
WHERE id = ${sql.param(1)}
|
|
10
|
+
`,[row.id])},{retries:2,sqlStates:["40001","40P01"]})}export async function revokeTokenPairs(userId,ownerType,exceptId){await db.transaction(async(trx)=>{const sql=sqlHelpers(getDatabaseDialect()),processed=new Set,bindings=[userId,ownerType];let where=`tokenable_id = ${sql.param(1)} AND tokenable_type = ${sql.param(2)}`;if(exceptId!==void 0){bindings.push(exceptId);where+=` AND id != ${sql.param(bindings.length)}`}for(;;){const pending=(await trx.unsafe(`
|
|
11
|
+
SELECT id FROM oauth_access_tokens WHERE ${where}
|
|
12
|
+
ORDER BY id${sql.isSqlite?"":" FOR UPDATE"}
|
|
13
|
+
`,bindings)).filter((row)=>!processed.has(String(row.id))).map((row)=>row.id);if(pending.length===0)break;for(let offset=0;offset<pending.length;offset+=100){const ids=pending.slice(offset,offset+100),placeholders=sql.params(...ids).sql;await trx.unsafe(`
|
|
14
|
+
UPDATE oauth_refresh_tokens SET revoked = ${sql.boolTrue}
|
|
15
|
+
WHERE access_token_id IN (${placeholders})
|
|
16
|
+
`,ids);await trx.unsafe(`
|
|
17
|
+
UPDATE oauth_access_tokens SET revoked = ${sql.boolTrue}, updated_at = ${sqlDateTimeLiteral()}
|
|
18
|
+
WHERE id IN (${placeholders})
|
|
19
|
+
`,ids);for(const id of ids)processed.add(String(id))}}},{retries:2,sqlStates:["40001","40P01"]});markContextWrote()}
|
package/dist/tokens.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AccessToken, CreateClientOptions, CreateClientResult, DatabaseDriver, OAuthClient, PersonalAccessTokenResult, RefreshTokenResult, TokenScopes } from '@stacksjs/types';
|
|
2
|
-
export declare function getPasswordChangedAt(
|
|
2
|
+
export declare function getPasswordChangedAt(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<Date | null>;
|
|
3
3
|
/**
|
|
4
4
|
* True when a credential issued at `createdAt` predates the user's last
|
|
5
5
|
* password change (`changedAt`) and must therefore be rejected.
|
|
@@ -96,6 +96,7 @@ export declare function tokenAbilities(): Promise<string[]>;
|
|
|
96
96
|
* @param scopes - Array of scopes/abilities for the token
|
|
97
97
|
* @param options - Additional options
|
|
98
98
|
* @param options.expiresInMinutes - Token expiry in minutes (default: 60)
|
|
99
|
+
* @param options.expiresAt - Absolute deadline, taking precedence over expiresInMinutes
|
|
99
100
|
* @param options.withRefreshToken - Whether to create a refresh token (default: true)
|
|
100
101
|
* @param options.refreshExpiresInDays - Refresh token expiry in days (default: 30)
|
|
101
102
|
*
|
|
@@ -107,6 +108,7 @@ export declare function tokenAbilities(): Promise<string[]>;
|
|
|
107
108
|
*/
|
|
108
109
|
export declare function createToken(userId: number, name?: string, scopes?: string[], options?: {
|
|
109
110
|
expiresInMinutes?: number
|
|
111
|
+
expiresAt?: Date
|
|
110
112
|
withRefreshToken?: boolean
|
|
111
113
|
refreshExpiresInDays?: number
|
|
112
114
|
/**
|
package/dist/tokens.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,markContextWrote,mutationCount}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database";const dbDriver=env.DB_CONNECTION||"sqlite",sql=sqlHelpers(dbDriver),{isPostgres,isMysql,boolTrue,boolFalse}=sql;function appNow(){return sqlDateTimeLiteral()}function param(index){return sql.param(index)}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
|
|
2
|
+
SELECT 1 FROM information_schema.columns
|
|
3
|
+
WHERE table_schema = DATABASE() AND table_name = ?
|
|
4
|
+
AND column_name = 'password_changed_at'
|
|
5
|
+
`,[ownerType])).length===0)return null}else if(isPostgres&&q!==db){if((await q.unsafe(`
|
|
6
|
+
SELECT 1 FROM pg_attribute
|
|
7
|
+
WHERE attrelid = to_regclass($1) AND attname = 'password_changed_at'
|
|
8
|
+
AND attnum > 0 AND NOT attisdropped
|
|
9
|
+
`,[table])).length===0)return null}const value=(await q.unsafe(`
|
|
10
|
+
SELECT password_changed_at FROM ${table} WHERE id = ${param(1)} LIMIT 1
|
|
11
|
+
`,[ownerId]))[0]?.password_changed_at;if(value===null||value===void 0)return null;return parseSqlDateTime(value)}catch{return null}}export function isIssuedBeforePasswordChange(createdAt,changedAt){if(!changedAt)return!1;if(createdAt===null||createdAt===void 0)return!1;const created=parseSqlDateTime(createdAt);if(!created)return!1;return created.getTime()<changedAt.getTime()}export async function tokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){return(await db.unsafe(`
|
|
4
12
|
SELECT t.*, c.provider as client_provider
|
|
5
13
|
FROM oauth_access_tokens t
|
|
6
14
|
LEFT JOIN oauth_clients c ON t.oauth_client_id = c.id
|
|
@@ -14,31 +22,34 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
|
|
|
14
22
|
AND revoked = ${boolFalse}
|
|
15
23
|
AND (expires_at IS NULL OR expires_at > ${appNow()})
|
|
16
24
|
LIMIT 1
|
|
17
|
-
`,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.user_id)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
`,[hashedToken]))[0];if(!row)return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return attached;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000),result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
|
|
26
|
+
SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} AND revoked = ${boolFalse} LIMIT 1
|
|
27
|
+
`))[0];if(!client)throw new HttpError(500,"No personal access client found. Run ./buddy auth:setup first.");const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),agent=userAgent?String(userAgent).slice(0,255):null,address=ipAddress?String(ipAddress).slice(0,45):null;if(isPostgres)await trx.unsafe(`
|
|
28
|
+
INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
|
|
29
|
+
VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, $8, $9, ${appNow()}, ${appNow()})
|
|
30
|
+
`,[tokenableType,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);else await trx.unsafe(`
|
|
31
|
+
INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, user_agent, ip_address, created_at, updated_at)
|
|
32
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
|
|
33
|
+
`,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await trx.unsafe(`
|
|
34
|
+
SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
|
|
35
|
+
`,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
|
|
36
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
37
|
+
VALUES ($1, $2, false, $3, ${appNow()})
|
|
38
|
+
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
|
|
39
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
40
|
+
VALUES (?, ?, 0, ?, ${appNow()})
|
|
41
|
+
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)])}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
|
|
34
42
|
SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.name, t.scopes
|
|
35
43
|
FROM oauth_refresh_tokens r
|
|
36
44
|
JOIN oauth_access_tokens t ON r.access_token_id = t.id
|
|
37
45
|
WHERE r.token = ${param(1)}
|
|
38
46
|
AND r.revoked = ${boolFalse}
|
|
47
|
+
AND EXISTS (
|
|
48
|
+
SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
|
|
49
|
+
)
|
|
39
50
|
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
40
51
|
LIMIT 1${forUpdate}
|
|
41
|
-
`,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
|
|
52
|
+
`,[hashedRefreshToken]))[0];if(!refreshRow)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");await trx.unsafe(`
|
|
42
53
|
UPDATE oauth_refresh_tokens
|
|
43
54
|
SET revoked = ${boolTrue}
|
|
44
55
|
WHERE id = ${param(1)}
|
|
@@ -46,7 +57,7 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
|
|
|
46
57
|
UPDATE oauth_access_tokens
|
|
47
58
|
SET revoked = ${boolTrue}
|
|
48
59
|
WHERE id = ${param(1)}
|
|
49
|
-
`,[refreshRow.access_token_id]);const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),expiresAt=new Date
|
|
60
|
+
`,[refreshRow.access_token_id]);const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),issuedAt=Date.now(),expiresAt=new Date(issuedAt+expiresInMinutes*60000);if(isPostgres)await trx.unsafe(`
|
|
50
61
|
INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
51
62
|
VALUES ($1, $2, $2, $3, $4, $5, $6, false, $7, ${appNow()}, ${appNow()})
|
|
52
63
|
`,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);else await trx.unsafe(`
|
|
@@ -54,108 +65,62 @@ import{createHash,randomBytes}from"node:crypto";import{db}from"@stacksjs/databas
|
|
|
54
65
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
|
|
55
66
|
`,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);const row=(await trx.unsafe(`
|
|
56
67
|
SELECT * FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
|
|
57
|
-
`,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.
|
|
68
|
+
`,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,createdAt:new Date(row.created_at),updatedAt:row.updated_at?new Date(row.updated_at):new Date(row.created_at)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=new Date;refreshExpiresAt.setDate(refreshExpiresAt.getDate()+refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
|
|
58
69
|
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
59
70
|
VALUES ($1, $2, false, $3, ${appNow()})
|
|
60
71
|
`,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
|
|
61
72
|
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
62
73
|
VALUES (?, ?, 0, ?, ${appNow()})
|
|
63
|
-
`,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);return{accessToken,plainTextToken,refreshToken:newRefreshTokenPlain,expiresIn:
|
|
64
|
-
SELECT id FROM oauth_refresh_tokens
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
AND
|
|
74
|
+
`,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);return{accessToken,plainTextToken,refreshToken:newRefreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function validateRefreshToken(refreshTokenPlain){const hashedRefreshToken=hashToken(refreshTokenPlain);return(await db.unsafe(`
|
|
75
|
+
SELECT r.id FROM oauth_refresh_tokens r
|
|
76
|
+
JOIN oauth_access_tokens t ON r.access_token_id = t.id
|
|
77
|
+
WHERE r.token = ${param(1)}
|
|
78
|
+
AND r.revoked = ${boolFalse}
|
|
79
|
+
AND EXISTS (
|
|
80
|
+
SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
|
|
81
|
+
)
|
|
82
|
+
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
68
83
|
LIMIT 1
|
|
69
84
|
`,[hashedRefreshToken])).length>0}export async function revokeRefreshToken(refreshTokenPlain){const hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
|
|
70
85
|
UPDATE oauth_refresh_tokens
|
|
71
86
|
SET revoked = ${boolTrue}
|
|
72
87
|
WHERE token = ${param(1)}
|
|
73
|
-
`,[hashedRefreshToken])}export async function revokeAllRefreshTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await db.unsafe(`
|
|
88
|
+
`,[hashedRefreshToken]);markContextWrote()}export async function revokeAllRefreshTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await db.unsafe(`
|
|
74
89
|
UPDATE oauth_refresh_tokens
|
|
75
90
|
SET revoked = ${boolTrue}
|
|
76
91
|
WHERE access_token_id IN (
|
|
77
92
|
SELECT id FROM oauth_access_tokens WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
|
|
78
93
|
)
|
|
79
|
-
`,[userId,tokenableType])}export async function deleteExpiredRefreshTokens(){const
|
|
94
|
+
`,[userId,tokenableType]);markContextWrote()}export async function deleteExpiredRefreshTokens(){const result=await db.unsafe(`
|
|
80
95
|
DELETE FROM oauth_refresh_tokens
|
|
81
96
|
WHERE expires_at < ${appNow()}
|
|
82
|
-
`);return
|
|
97
|
+
`);markContextWrote();return mutationCount(result)}export async function deleteRevokedRefreshTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const result=await db.unsafe(`
|
|
83
98
|
DELETE FROM oauth_refresh_tokens
|
|
84
99
|
WHERE revoked = ${boolTrue} AND created_at < ${param(1)}
|
|
85
|
-
`,[sqlDateTime(cutoffDate)]);return
|
|
86
|
-
UPDATE oauth_refresh_tokens
|
|
87
|
-
SET revoked = ${boolTrue}
|
|
88
|
-
WHERE access_token_id IN (
|
|
89
|
-
SELECT id FROM oauth_access_tokens WHERE token = ${param(1)}
|
|
90
|
-
)
|
|
91
|
-
`,[hashedToken]);await db.unsafe(`
|
|
92
|
-
UPDATE oauth_access_tokens
|
|
93
|
-
SET revoked = ${boolTrue}, updated_at = ${appNow()}
|
|
94
|
-
WHERE token = ${param(1)}
|
|
95
|
-
`,[hashedToken])}export async function revokeTokenById(tokenId){await db.unsafe(`
|
|
96
|
-
UPDATE oauth_refresh_tokens
|
|
97
|
-
SET revoked = ${boolTrue}
|
|
98
|
-
WHERE access_token_id = ${param(1)}
|
|
99
|
-
`,[tokenId]);await db.unsafe(`
|
|
100
|
-
UPDATE oauth_access_tokens
|
|
101
|
-
SET revoked = ${boolTrue}, updated_at = ${appNow()}
|
|
102
|
-
WHERE id = ${param(1)}
|
|
103
|
-
`,[tokenId])}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeAllRefreshTokens(userId);await db.unsafe(`
|
|
104
|
-
UPDATE oauth_access_tokens
|
|
105
|
-
SET revoked = ${boolTrue}, updated_at = ${appNow()}
|
|
106
|
-
WHERE tokenable_id = ${param(1)} AND tokenable_type = ${param(2)}
|
|
107
|
-
`,[userId,tokenableType])}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId);if(isPostgres){await db.unsafe(`
|
|
108
|
-
UPDATE oauth_refresh_tokens
|
|
109
|
-
SET revoked = true
|
|
110
|
-
WHERE access_token_id IN (
|
|
111
|
-
SELECT id FROM oauth_access_tokens WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
|
|
112
|
-
)
|
|
113
|
-
`,[userId,current.id,tokenableType]);await db.unsafe(`
|
|
114
|
-
UPDATE oauth_access_tokens
|
|
115
|
-
SET revoked = true, updated_at = ${appNow()}
|
|
116
|
-
WHERE tokenable_id = $1 AND tokenable_type = $3 AND id != $2
|
|
117
|
-
`,[userId,current.id])}else{await db.unsafe(`
|
|
118
|
-
UPDATE oauth_refresh_tokens
|
|
119
|
-
SET revoked = 1
|
|
120
|
-
WHERE access_token_id IN (
|
|
121
|
-
SELECT id FROM oauth_access_tokens WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
|
|
122
|
-
)
|
|
123
|
-
`,[userId,tokenableType,current.id]);await db.unsafe(`
|
|
124
|
-
UPDATE oauth_access_tokens
|
|
125
|
-
SET revoked = 1, updated_at = ${appNow()}
|
|
126
|
-
WHERE tokenable_id = ? AND tokenable_type = ? AND id != ?
|
|
127
|
-
`,[userId,current.id])}}export async function deleteExpiredTokens(){await db.unsafe(`
|
|
100
|
+
`,[sqlDateTime(cutoffDate)]);markContextWrote();return mutationCount(result)}export async function revokeToken(plainTextToken){await revokeTokenPair({hash:bearerLookupHash(plainTextToken)})}export async function revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeTokenPairs(userId,tokenableType)}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const current=await currentAccessToken();if(!current)return revokeAllTokens(userId,tokenableType);await revokeTokenPairs(userId,tokenableType,current.id)}export async function deleteExpiredTokens(){await db.unsafe(`
|
|
128
101
|
DELETE FROM oauth_refresh_tokens
|
|
129
102
|
WHERE access_token_id IN (
|
|
130
103
|
SELECT id FROM oauth_access_tokens WHERE expires_at < ${appNow()}
|
|
131
104
|
)
|
|
132
|
-
`);const
|
|
105
|
+
`);markContextWrote();const result=await db.unsafe(`
|
|
133
106
|
DELETE FROM oauth_access_tokens
|
|
134
107
|
WHERE expires_at < ${appNow()}
|
|
135
|
-
`);return
|
|
108
|
+
`);return mutationCount(result)}export async function deleteRevokedTokens(daysOld=7){const cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);await db.unsafe(`
|
|
136
109
|
DELETE FROM oauth_refresh_tokens
|
|
137
110
|
WHERE access_token_id IN (
|
|
138
111
|
SELECT id FROM oauth_access_tokens WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
|
|
139
112
|
)
|
|
140
|
-
`,[sqlDateTime(cutoffDate)]);const
|
|
113
|
+
`,[sqlDateTime(cutoffDate)]);markContextWrote();const result=await db.unsafe(`
|
|
141
114
|
DELETE FROM oauth_access_tokens
|
|
142
115
|
WHERE revoked = ${boolTrue} AND updated_at < ${param(1)}
|
|
143
|
-
`,[sqlDateTime(cutoffDate)]);return
|
|
116
|
+
`,[sqlDateTime(cutoffDate)]);return mutationCount(result)}export async function clients(userId){return(await db.unsafe(`
|
|
144
117
|
SELECT * FROM oauth_clients
|
|
145
118
|
WHERE user_id = ${param(1)} AND revoked = ${boolFalse}
|
|
146
119
|
ORDER BY created_at DESC
|
|
147
120
|
`,[userId])).map(mapToOAuthClient)}export async function findClient(clientId){const row=(await db.unsafe(`
|
|
148
121
|
SELECT * FROM oauth_clients WHERE id = ${param(1)} LIMIT 1
|
|
149
|
-
`,[clientId]))[0];return row?mapToOAuthClient(row):null}export async function createClient(options){const secret=generateSecureToken(40);if(
|
|
150
|
-
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
151
|
-
VALUES ($1, $2, 'local', $3, $4, $5, false, ${appNow()})
|
|
152
|
-
`,[options.name,secret,options.redirect,options.personalAccessClient||!1,options.passwordClient||!1]);else await db.unsafe(`
|
|
153
|
-
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
154
|
-
VALUES (?, ?, 'local', ?, ?, ?, 0, ${appNow()})
|
|
155
|
-
`,[options.name,secret,options.redirect,options.personalAccessClient?1:0,options.passwordClient?1:0]);const createdClient=(await db.unsafe(`
|
|
156
|
-
SELECT * FROM oauth_clients WHERE secret = ${param(1)} LIMIT 1
|
|
157
|
-
`,[secret]))[0];if(!createdClient)throw new HttpError(500,"Failed to read back the OAuth client that was just created.");return{client:mapToOAuthClient(createdClient),plainTextSecret:secret}}export async function revokeClient(clientId){await db.unsafe(`
|
|
122
|
+
`,[clientId]))[0];return row?mapToOAuthClient(row):null}export async function createClient(options){const secret=generateSecureToken(40),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"}),createdClient=await db.transaction(async(trx)=>{await trx.insertInto("oauth_clients").values({user_id:options.userId??null,name:options.name,secret:hashedSecret,provider:options.provider??"local",redirect:options.redirect,personal_access_client:!!options.personalAccessClient,password_client:!!options.passwordClient,revoked:!1,created_at:sqlDateTime()}).execute();const inserted=await trx.selectFrom("oauth_clients").where("secret","=",hashedSecret).executeTakeFirst();if(!inserted)throw new HttpError(500,"Failed to read back the OAuth client that was just created.");return inserted});markContextWrote();return{client:mapToOAuthClient(createdClient),plainTextSecret:secret}}export async function revokeClient(clientId){await db.unsafe(`
|
|
158
123
|
UPDATE oauth_clients
|
|
159
124
|
SET revoked = ${boolTrue}, updated_at = ${appNow()}
|
|
160
125
|
WHERE id = ${param(1)}
|
|
161
|
-
`,[clientId])}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:row.created_at?new Date(row.created_at):new Date,updatedAt:row.updated_at?new Date(row.updated_at):null}}
|
|
126
|
+
`,[clientId]);markContextWrote()}export function parseScopes(scopes){if(!scopes)return[];if(Array.isArray(scopes))return scopes;try{const parsed=JSON.parse(scopes);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function mapToOAuthClient(row){return{id:row.id,name:row.name,secret:row.secret,provider:row.provider,redirect:row.redirect,personalAccessClient:Boolean(row.personal_access_client),passwordClient:Boolean(row.password_client),revoked:Boolean(row.revoked),createdAt:row.created_at?new Date(row.created_at):new Date,updatedAt:row.updated_at?new Date(row.updated_at):null}}
|
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.47",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,18 +58,18 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@stacksjs/bun-router": "^0.1.18",
|
|
61
|
-
"@stacksjs/cache": "0.74.
|
|
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.47",
|
|
62
|
+
"@stacksjs/config": "0.74.47",
|
|
63
|
+
"@stacksjs/database": "0.74.47",
|
|
64
|
+
"@stacksjs/email": "0.74.47",
|
|
65
|
+
"@stacksjs/env": "0.74.47",
|
|
66
|
+
"@stacksjs/error-handling": "0.74.47",
|
|
67
|
+
"@stacksjs/logging": "0.74.47",
|
|
68
|
+
"@stacksjs/orm": "0.74.47",
|
|
69
|
+
"@stacksjs/path": "0.74.47",
|
|
70
|
+
"@stacksjs/router": "0.74.47",
|
|
71
|
+
"@stacksjs/security": "0.74.47",
|
|
72
|
+
"@stacksjs/storage": "0.74.47",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|