@stacksjs/auth 0.74.47 → 0.74.48
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/client.js +1 -1
- package/dist/email-verification.js +1 -1
- package/dist/magic-link.js +1 -1
- package/dist/passkey.d.ts +1 -1
- package/dist/passkey.js +1 -1
- package/dist/password/reset.js +1 -1
- package/dist/rbac-store-bqb.js +1 -1
- package/dist/referrals.js +1 -1
- package/dist/register.js +1 -1
- package/dist/session-auth.js +1 -1
- package/dist/socials.js +1 -1
- package/dist/team.js +1 -1
- package/dist/token-revocation.js +1 -1
- package/dist/tokens.js +1 -1
- package/dist/two-factor.js +1 -1
- 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{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}}}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,parseSqlDateTime}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout}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/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{db}from"@stacksjs/database";import{formatDate}from"@stacksjs/orm";import{err,HttpError,ok}from"@stacksjs/error-handling";import{makeHash}from"@stacksjs/security";export async function createPersonalAccessClient(){if((await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id"]).executeTakeFirst())?.id)return err({code:"already-exists",message:"A personal access client already exists. There is no CLI command to revoke one yet - delete its row from `oauth_clients` (or use the existing client) before creating another."});const secret=randomBytes(40).toString("hex"),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"});await db.insertInto("oauth_clients").values({name:"Personal Access Client",secret:hashedSecret,provider:"local",redirect:"http://localhost",personal_access_client:!0,password_client:!1,revoked:!1,created_at:formatDate(new Date)}).execute();if(!(await db.selectFrom("oauth_clients").where("secret","=",hashedSecret).select(["id"]).executeTakeFirst())?.id)throw new HttpError(500,"Failed to create personal access client");return ok(secret)}
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db}from"@stacksjs/database/runtime";import{formatDate}from"@stacksjs/orm";import{err,HttpError,ok}from"@stacksjs/error-handling";import{makeHash}from"@stacksjs/security";export async function createPersonalAccessClient(){if((await db.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id"]).executeTakeFirst())?.id)return err({code:"already-exists",message:"A personal access client already exists. There is no CLI command to revoke one yet - delete its row from `oauth_clients` (or use the existing client) before creating another."});const secret=randomBytes(40).toString("hex"),hashedSecret=await makeHash(secret,{algorithm:"bcrypt"});await db.insertInto("oauth_clients").values({name:"Personal Access Client",secret:hashedSecret,provider:"local",redirect:"http://localhost",personal_access_client:!0,password_client:!1,revoked:!1,created_at:formatDate(new Date)}).execute();if(!(await db.selectFrom("oauth_clients").where("secret","=",hashedSecret).select(["id"]).executeTakeFirst())?.id)throw new HttpError(500,"Failed to create personal access client");return ok(secret)}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime
|
|
1
|
+
import{Buffer}from"node:buffer";import{createHmac,randomBytes,timingSafeEqual}from"node:crypto";import{config}from"@stacksjs/config";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";function getVerificationKey(){const appKey=config.app.key;if(typeof appKey!=="string"||appKey.length===0)throw Error("[auth] config.app.key is not set - email-verification HMAC requires a real APP_KEY. Run `./buddy key:generate` to provision one, or set the APP_KEY env var before booting the app.");return appKey}function generateVerificationToken(userId){const nonce=randomBytes(32).toString("hex"),payload=`${userId}:${nonce}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex");return{token:nonce,hash}}function verifyToken(userId,token,storedHash){const payload=`${userId}:${token}`,hash=createHmac("sha256",getVerificationKey()).update(payload).digest("hex"),a=Buffer.from(hash),b=Buffer.from(storedHash);if(a.length!==b.length)return!1;return timingSafeEqual(a,b)}function getExpiryMinutes(){const emailVerification=(config.auth??{}).emailVerification;if(emailVerification!=null&&typeof emailVerification==="object"){const ev=emailVerification;if(typeof ev.expire==="number")return ev.expire}return 60}function getVerificationUrl(userId,token){const base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.emailVerification?.url??"/verify-email/{id}/{token}").replace("{id}",String(userId)).replace("{token}",token);return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}export function isEmailVerified(user){return user.email_verified_at!=null}export async function sendVerificationEmail(user){const{token,hash}=generateVerificationToken(user.id),expiryMinutes=getExpiryMinutes(),expiresAt=new Date(Date.now()+expiryMinutes*60*1000);await db.deleteFrom("email_verifications").where("user_id","=",user.id).execute();await db.insertInto("email_verifications").values({user_id:user.id,token:hash,expires_at:sqlDateTime(expiresAt)}).executeTakeFirst();const verificationUrl=getVerificationUrl(user.id,token),appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("email-verification",{subject:`Verify Your ${appName} Email Address`,variables:{verificationUrl,expiryMinutes,userName:user.name||user.email}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("email-verification template missing or rendered empty")}catch(templateError){const errorMessage=templateError instanceof Error?templateError.message:String(templateError);log.warn(`[email] Email verification template failed, using plain text fallback: ${errorMessage}`);html=void 0;text=`Please verify your email address by visiting: ${verificationUrl}
|
|
2
2
|
|
|
3
3
|
This link expires in ${expiryMinutes} minutes.`}await mail.sendOrFail({to:user.email,subject:`Verify Your ${appName} Email Address`,text,html})}export async function verifyEmail(userId,token){const record=await db.selectFrom("email_verifications").where("user_id","=",userId).selectAll().executeTakeFirst();if(!record)return{success:!1,message:"No verification request found. Please request a new verification email."};const expiresAt=parseSqlDateTime(record.expires_at)??new Date(Number.NaN);if(Number.isNaN(expiresAt.getTime())||new Date>expiresAt){await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!1,message:"Verification link has expired. Please request a new one."}}if(!verifyToken(userId,token,record.token))return{success:!1,message:"Invalid verification link."};await db.updateTable("users").set({email_verified_at:sqlDateTime()}).where("id","=",userId).executeTakeFirst();await db.deleteFrom("email_verifications").where("user_id","=",userId).execute();return{success:!0,message:"Email verified successfully."}}export async function resendVerificationEmail(user){if(isEmailVerified(user))return{success:!1,message:"Email is already verified."};const existing=await db.selectFrom("email_verifications").where("user_id","=",user.id).selectAll().executeTakeFirst();if(existing){const createdAt=new Date(existing.created_at),secondsSince=(Date.now()-createdAt.getTime())/1000;if(Number.isNaN(secondsSince))return{success:!1,message:"Please wait a moment before requesting another verification email."};if(secondsSince<60)return{success:!1,message:`Please wait ${Math.ceil(60-secondsSince)} seconds before requesting another verification email.`}}await sendVerificationEmail(user);return{success:!0,message:"Verification email sent."}}export const EmailVerification={isVerified:isEmailVerified,send:sendVerificationEmail,verify:verifyEmail,resend:resendVerificationEmail};
|
package/dist/magic-link.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
|
|
2
2
|
|
|
3
3
|
This link works once and expires in ${ttl} minutes. If you didn't request it, ignore this email.`})}catch(error){log.error(`Magic-link email to ${normalized} failed: ${error.message}`)}}export async function consumeMagicLink(raw){if(!raw||raw.length>255)return{ok:!1,reason:"invalid"};const hashed=hashToken(raw),now=sqlDateTime(new Date),claim=await db.updateTable("magic_link_tokens").set({consumed_at:now,updated_at:now}).where("token","=",hashed).where("consumed_at","is",null).where("expires_at",">",now).execute(),updated=typeof claim==="number"?claim:Number(claim?.numUpdatedRows??claim?.changes??0),row=await db.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","email","expires_at","consumed_at","redirect_to"]).executeTakeFirst();if(!row)return{ok:!1,reason:"invalid"};if(updated<1){if(!row.consumed_at&&new Date(row.expires_at).getTime()<=Date.now())return{ok:!1,reason:"expired"};return{ok:!1,reason:"used"}}if(!row.user_id)return{ok:!1,reason:"no-user"};return{ok:!0,userId:Number(row.user_id),email:row.email,redirectTo:safeRedirect(row.redirect_to)}}export async function pruneMagicLinkTokens(olderThanDays=7){const cutoff=sqlDateTime(new Date(Date.now()-olderThanDays*86400000));await db.deleteFrom("magic_link_tokens").where("expires_at","<",cutoff).execute()}
|
package/dist/passkey.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { User } from '@stacksjs/orm';
|
|
2
|
-
import type { Insertable } from '@stacksjs/database';
|
|
2
|
+
import type { Insertable } from '@stacksjs/database/runtime';
|
|
3
3
|
import type { VerifiedRegistrationResponse } from '@stacksjs/ts-auth';
|
|
4
4
|
// Re-export WebAuthn types from ts-auth
|
|
5
5
|
export type {
|
package/dist/passkey.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Buffer}from"node:buffer";import{db,sqlDateTime
|
|
1
|
+
import{Buffer}from"node:buffer";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{User}from"@stacksjs/orm";export{generateRegistrationOptions,generateAuthenticationOptions,verifyRegistrationResponse,verifyAuthenticationResponse,startRegistration,startAuthentication,browserSupportsWebAuthn,browserSupportsWebAuthnAutofill,platformAuthenticatorIsAvailable}from"@stacksjs/ts-auth";export function passkeyDescriptors(passkeys){return passkeys.map((passkey)=>({id:passkey.id,type:"public-key",transports:parseTransports(passkey.transports)}))}function parseTransports(stored){if(!stored)return["internal"];try{const parsed=JSON.parse(stored);return Array.isArray(parsed)&&parsed.length?parsed:["internal"]}catch{return["internal"]}}export async function getUserPasskeys(userId){return await db.selectFrom("passkeys").selectAll().where("user_id","=",userId).execute()}export async function getUserPasskey(userId,passkeyId){return await db.selectFrom("passkeys").selectAll().where("id","=",passkeyId).where("user_id","=",userId).executeTakeFirst()}export async function updatePasskeyCounter(userId,passkeyId,newCounter){const passkey=await getUserPasskey(userId,passkeyId);if(!passkey)return!1;const stored=Number(passkey.counter??0);if(newCounter<=stored&&!(newCounter===0&&stored===0))return!1;await db.updateTable("passkeys").set({counter:newCounter,last_used_at:formatDateTime()}).where("id","=",passkeyId).where("user_id","=",userId).execute();return!0}export async function setCurrentRegistrationOptions(user,verified){const credentialId=verified.registrationInfo?.credential.id,credentialPublicKey=verified.registrationInfo?.credential.publicKey;if(!credentialId)throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");if(!credentialPublicKey)throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");const passkeyData={id:credentialId,cred_public_key:JSON.stringify(credentialPublicKey),user_id:user.id,webauthn_user_id:user.email||"",counter:verified.registrationInfo?.credential.counter||0,credential_type:verified.registrationInfo?.credentialType||"",device_type:verified.registrationInfo?.credentialDeviceType||"",backup_eligible:!1,backup_status:verified.registrationInfo?.credentialBackedUp||!1,transports:JSON.stringify(["internal"]),last_used_at:formatDateTime()};await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow()}function formatDateTime(){const date=new Date,pad=(num)=>String(num).padStart(2,"0"),year=date.getFullYear(),month=pad(date.getMonth()+1),day=pad(date.getDate()),hours=pad(date.getHours()),minutes=pad(date.getMinutes()),seconds=pad(date.getSeconds());return`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`}const DEFAULT_CHALLENGE_TTL_SECONDS=300;export async function storeWebAuthnChallenge(userId,challenge,purpose,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000)),encodedChallenge=typeof challenge==="string"?challenge:Buffer.from(challenge).toString("base64url");await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();await db.insertInto("webauthn_challenges").values({user_id:userId,challenge:encodedChallenge,purpose,expires_at:expiresAt}).execute()}export async function consumeWebAuthnChallenge(userId,purpose){const row=await db.selectFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("webauthn_challenges").where("user_id","=",userId).where("purpose","=",purpose).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Buffer.from(String(row.challenge),"base64url")}
|
package/dist/password/reset.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{formatDate}from"@stacksjs/orm";import{makeHash,verifyHash}from"@stacksjs/security";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(typeof explicit==="string"||explicit instanceof Date)return new Date(explicit).getTime()>Date.now();const created=row.created_at;if(typeof created==="string"||created instanceof Date){const expireMinutes=getTokenExpireMinutes();return new Date(created).getTime()+expireMinutes*60000>Date.now()}return!1}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes(),expiresAt=sqlDateTime(new Date(Date.now()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt}).executeTakeFirst();return token}async function sendEmail(){if(!await db.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())return;const token=await createResetToken(),expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.passwordReset?.url??"/password/reset/{token}?email={email}").replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
|
|
1
|
+
import{randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{formatDate}from"@stacksjs/orm";import{makeHash,verifyHash}from"@stacksjs/security";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(typeof explicit==="string"||explicit instanceof Date)return new Date(explicit).getTime()>Date.now();const created=row.created_at;if(typeof created==="string"||created instanceof Date){const expireMinutes=getTokenExpireMinutes();return new Date(created).getTime()+expireMinutes*60000>Date.now()}return!1}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes(),expiresAt=sqlDateTime(new Date(Date.now()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt}).executeTakeFirst();return token}async function sendEmail(){if(!await db.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())return;const token=await createResetToken(),expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`,filled=(config.auth.passwordReset?.url??"/password/reset/{token}?email={email}").replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
|
|
2
2
|
|
|
3
3
|
This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,resetRecord=await trx.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const user=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});try{await trx.updateTable("users").set({password:hashedPassword,password_changed_at:formatDate(new Date)}).where("email","=",email).executeTakeFirst()}catch(err){const message=err instanceof Error?err.message:String(err);if(/password_changed_at|no such column|unknown column/i.test(message)){log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");await trx.updateTable("users").set({password:hashedPassword}).where("email","=",email).executeTakeFirst()}else throw err}await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!0,userId:Number(user.id)}});if(result.success){await revokeAllTokens(result.userId);await sessionDestroyAll(result.userId);sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}
|
package/dist/rbac-store-bqb.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db}from"@stacksjs/database";import{isUniqueViolation}from"@stacksjs/orm";export{isUniqueViolation};export function swallowDuplicate(err){if(!isUniqueViolation(err))throw err}export function toRecord(row){if(!row)return null;return{id:Number(row.id),name:String(row.name),guard_name:String(row.guard_name),description:row.description==null?void 0:String(row.description),created_at:row.created_at==null?void 0:String(row.created_at),updated_at:row.updated_at==null?void 0:String(row.updated_at)}}async function insertAndFetch(table,name,guardName,description){await db.insertInto(table).values({name,guard_name:guardName,description:description??null}).execute();const row=await db.selectFrom(table).selectAll().where("name","=",name).where("guard_name","=",guardName).orderBy("id","desc").limit(1).executeTakeFirst(),rec=toRecord(row);if(!rec)throw Error(`[rbac] insertAndFetch(${table}, ${name}, ${guardName}) succeeded but follow-up SELECT returned nothing`);return rec}async function pivotAttach(table,cols){let q=db.selectFrom(table).select("user_id"in cols?"user_id":"role_id");for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);if(await q.limit(1).executeTakeFirst())return;try{await db.insertInto(table).values(cols).execute()}catch(err){swallowDuplicate(err)}}async function pivotDetach(table,cols){let q=db.deleteFrom(table);for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);await q.execute()}async function pivotDetachAll(table,column,value){await db.deleteFrom(table).where(column,"=",value).execute()}async function pivotSync(table,ownerColumn,ownerId,targetColumn,targetIds){const unique=Array.from(new Set(targetIds));await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.deleteFrom(table).where(ownerColumn,"=",ownerId).execute();if(unique.length===0)return;const rows=unique.map((id)=>({[ownerColumn]:ownerId,[targetColumn]:id}));try{await trx.insertInto(table).values(rows).execute()}catch(err){swallowDuplicate(err);for(const id of unique)try{await trx.insertInto(table).values({[ownerColumn]:ownerId,[targetColumn]:id}).execute()}catch(innerErr){swallowDuplicate(innerErr)}}})}export function createBqbRbacStore(){return{async findRoleByName(name,guardName="web"){const row=await db.selectFrom("roles").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.selectFrom("roles").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createRole(name,guardName="web",description){return await insertAndFetch("roles",name,guardName,description)},async deleteRole(id){await db.deleteFrom("user_roles").where("role_id","=",id).execute();await db.deleteFrom("role_permissions").where("role_id","=",id).execute();await db.deleteFrom("roles").where("id","=",id).execute()},async getAllRoles(guardName){let q=db.selectFrom("roles").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async findPermissionByName(name,guardName="web"){const row=await db.selectFrom("permissions").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.selectFrom("permissions").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createPermission(name,guardName="web",description){return await insertAndFetch("permissions",name,guardName,description)},async deletePermission(id){await db.deleteFrom("user_permissions").where("permission_id","=",id).execute();await db.deleteFrom("role_permissions").where("permission_id","=",id).execute();await db.deleteFrom("permissions").where("id","=",id).execute()},async getAllPermissions(guardName){let q=db.selectFrom("permissions").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async getUserRoles(userId){return(await db.selectFrom("roles").innerJoin("user_roles","user_roles.role_id","=","roles.id").select(["roles.id as id","roles.name as name","roles.guard_name as guard_name","roles.description as description","roles.created_at as created_at","roles.updated_at as updated_at"]).where("user_roles.user_id","=",userId).orderBy("roles.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignRoleToUser(userId,roleId){return pivotAttach("user_roles",{user_id:userId,role_id:roleId})},removeRoleFromUser(userId,roleId){return pivotDetach("user_roles",{user_id:userId,role_id:roleId})},removeAllRolesFromUser(userId){return pivotDetachAll("user_roles","user_id",userId)},syncUserRoles(userId,roleIds){return pivotSync("user_roles","user_id",userId,"role_id",roleIds)},async getUserDirectPermissions(userId){return(await db.selectFrom("permissions").innerJoin("user_permissions","user_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("user_permissions.user_id","=",userId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToUser(userId,permissionId){return pivotAttach("user_permissions",{user_id:userId,permission_id:permissionId})},removePermissionFromUser(userId,permissionId){return pivotDetach("user_permissions",{user_id:userId,permission_id:permissionId})},removeAllPermissionsFromUser(userId){return pivotDetachAll("user_permissions","user_id",userId)},syncUserPermissions(userId,permissionIds){return pivotSync("user_permissions","user_id",userId,"permission_id",permissionIds)},async getRolePermissions(roleId){return(await db.selectFrom("permissions").innerJoin("role_permissions","role_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("role_permissions.role_id","=",roleId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToRole(roleId,permissionId){return pivotAttach("role_permissions",{role_id:roleId,permission_id:permissionId})},removePermissionFromRole(roleId,permissionId){return pivotDetach("role_permissions",{role_id:roleId,permission_id:permissionId})},syncRolePermissions(roleId,permissionIds){return pivotSync("role_permissions","role_id",roleId,"permission_id",permissionIds)}}}
|
|
1
|
+
import{db}from"@stacksjs/database/runtime";import{isUniqueViolation}from"@stacksjs/orm";export{isUniqueViolation};export function swallowDuplicate(err){if(!isUniqueViolation(err))throw err}export function toRecord(row){if(!row)return null;return{id:Number(row.id),name:String(row.name),guard_name:String(row.guard_name),description:row.description==null?void 0:String(row.description),created_at:row.created_at==null?void 0:String(row.created_at),updated_at:row.updated_at==null?void 0:String(row.updated_at)}}async function insertAndFetch(table,name,guardName,description){await db.insertInto(table).values({name,guard_name:guardName,description:description??null}).execute();const row=await db.selectFrom(table).selectAll().where("name","=",name).where("guard_name","=",guardName).orderBy("id","desc").limit(1).executeTakeFirst(),rec=toRecord(row);if(!rec)throw Error(`[rbac] insertAndFetch(${table}, ${name}, ${guardName}) succeeded but follow-up SELECT returned nothing`);return rec}async function pivotAttach(table,cols){let q=db.selectFrom(table).select("user_id"in cols?"user_id":"role_id");for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);if(await q.limit(1).executeTakeFirst())return;try{await db.insertInto(table).values(cols).execute()}catch(err){swallowDuplicate(err)}}async function pivotDetach(table,cols){let q=db.deleteFrom(table);for(const[k,v]of Object.entries(cols))q=q.where(k,"=",v);await q.execute()}async function pivotDetachAll(table,column,value){await db.deleteFrom(table).where(column,"=",value).execute()}async function pivotSync(table,ownerColumn,ownerId,targetColumn,targetIds){const unique=Array.from(new Set(targetIds));await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.deleteFrom(table).where(ownerColumn,"=",ownerId).execute();if(unique.length===0)return;const rows=unique.map((id)=>({[ownerColumn]:ownerId,[targetColumn]:id}));try{await trx.insertInto(table).values(rows).execute()}catch(err){swallowDuplicate(err);for(const id of unique)try{await trx.insertInto(table).values({[ownerColumn]:ownerId,[targetColumn]:id}).execute()}catch(innerErr){swallowDuplicate(innerErr)}}})}export function createBqbRbacStore(){return{async findRoleByName(name,guardName="web"){const row=await db.selectFrom("roles").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findRoleById(id){const row=await db.selectFrom("roles").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createRole(name,guardName="web",description){return await insertAndFetch("roles",name,guardName,description)},async deleteRole(id){await db.deleteFrom("user_roles").where("role_id","=",id).execute();await db.deleteFrom("role_permissions").where("role_id","=",id).execute();await db.deleteFrom("roles").where("id","=",id).execute()},async getAllRoles(guardName){let q=db.selectFrom("roles").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async findPermissionByName(name,guardName="web"){const row=await db.selectFrom("permissions").selectAll().where("name","=",name).where("guard_name","=",guardName).limit(1).executeTakeFirst();return toRecord(row)},async findPermissionById(id){const row=await db.selectFrom("permissions").selectAll().where("id","=",id).limit(1).executeTakeFirst();return toRecord(row)},async createPermission(name,guardName="web",description){return await insertAndFetch("permissions",name,guardName,description)},async deletePermission(id){await db.deleteFrom("user_permissions").where("permission_id","=",id).execute();await db.deleteFrom("role_permissions").where("permission_id","=",id).execute();await db.deleteFrom("permissions").where("id","=",id).execute()},async getAllPermissions(guardName){let q=db.selectFrom("permissions").selectAll();if(guardName)q=q.where("guard_name","=",guardName);return(await q.orderBy("id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},async getUserRoles(userId){return(await db.selectFrom("roles").innerJoin("user_roles","user_roles.role_id","=","roles.id").select(["roles.id as id","roles.name as name","roles.guard_name as guard_name","roles.description as description","roles.created_at as created_at","roles.updated_at as updated_at"]).where("user_roles.user_id","=",userId).orderBy("roles.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignRoleToUser(userId,roleId){return pivotAttach("user_roles",{user_id:userId,role_id:roleId})},removeRoleFromUser(userId,roleId){return pivotDetach("user_roles",{user_id:userId,role_id:roleId})},removeAllRolesFromUser(userId){return pivotDetachAll("user_roles","user_id",userId)},syncUserRoles(userId,roleIds){return pivotSync("user_roles","user_id",userId,"role_id",roleIds)},async getUserDirectPermissions(userId){return(await db.selectFrom("permissions").innerJoin("user_permissions","user_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("user_permissions.user_id","=",userId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToUser(userId,permissionId){return pivotAttach("user_permissions",{user_id:userId,permission_id:permissionId})},removePermissionFromUser(userId,permissionId){return pivotDetach("user_permissions",{user_id:userId,permission_id:permissionId})},removeAllPermissionsFromUser(userId){return pivotDetachAll("user_permissions","user_id",userId)},syncUserPermissions(userId,permissionIds){return pivotSync("user_permissions","user_id",userId,"permission_id",permissionIds)},async getRolePermissions(roleId){return(await db.selectFrom("permissions").innerJoin("role_permissions","role_permissions.permission_id","=","permissions.id").select(["permissions.id as id","permissions.name as name","permissions.guard_name as guard_name","permissions.description as description","permissions.created_at as created_at","permissions.updated_at as updated_at"]).where("role_permissions.role_id","=",roleId).orderBy("permissions.id","asc").execute()).map((r)=>toRecord(r)).filter(Boolean)},assignPermissionToRole(roleId,permissionId){return pivotAttach("role_permissions",{role_id:roleId,permission_id:permissionId})},removePermissionFromRole(roleId,permissionId){return pivotDetach("role_permissions",{role_id:roleId,permission_id:permissionId})},syncRolePermissions(roleId,permissionIds){return pivotSync("role_permissions","role_id",roleId,"permission_id",permissionIds)}}}
|
package/dist/referrals.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";export function normalizeReferralCode(value){if(typeof value!=="string")return null;const code=value.trim().toLowerCase();return/^[a-f0-9]{24}$/.test(code)?code:null}function userId(value){if(!Number.isSafeInteger(value)||value<1)throw TypeError("A positive integer user ID is required.")}async function connection(database){return database??(await import("@stacksjs/database")).db}async function rows(database,sql,bindings){const result=await database.unsafe(sql,bindings).execute();if(Array.isArray(result))return result;if(result&&typeof result==="object"&&"rows"in result&&Array.isArray(result.rows))return result.rows;throw Error("The referral query returned an unsupported result.")}async function duplicate(error){const{isUniqueViolation}=await import("@stacksjs/orm");return isUniqueViolation(error)}export async function createReferralCode(ownerId,database){userId(ownerId);const db=await connection(database);for(let attempt=0;attempt<5;attempt++){const existing=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]);if(existing[0])return existing[0].code;const code=randomBytes(12).toString("hex");try{await db.unsafe("INSERT INTO referral_codes (user_id, code) VALUES (?, ?)",[ownerId,code]).execute();return code}catch(error){if(!await duplicate(error))throw error}}throw Error("Could not allocate a referral code. Please retry.")}export async function attributeReferral(newUserId,input,database){userId(newUserId);const code=normalizeReferralCode(input);if(!code)return!1;const db=await connection(database),owner=(await rows(db,"SELECT code, user_id FROM referral_codes WHERE code = ?",[code]))[0];if(!owner||Number(owner.user_id)===newUserId)return!1;try{await db.unsafe("INSERT INTO referrals (referrer_id, referred_user_id, code, status) VALUES (?, ?, ?, ?)",[Number(owner.user_id),newUserId,code,"registered"]).execute();return!0}catch(error){if(await duplicate(error))return!1;throw error}}export async function qualifyReferral(referredUserId,database){userId(referredUserId);const db=await connection(database),now=new Date().toISOString().slice(0,19).replace("T"," ");await db.unsafe("UPDATE referrals SET status = ?, qualified_at = ?, updated_at = ? WHERE referred_user_id = ? AND status = ?",["qualified",now,now,referredUserId,"registered"]).execute()}export async function referralSummary(ownerId,database){userId(ownerId);const db=await connection(database),codes=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]),counts=await rows(db,"SELECT COUNT(*) AS total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS qualified FROM referrals WHERE referrer_id = ?",["qualified",ownerId]);return{code:codes[0]?.code??null,referred:Number(counts[0]?.total??0),qualified:Number(counts[0]?.qualified??0)}}
|
|
1
|
+
import{randomBytes}from"node:crypto";export function normalizeReferralCode(value){if(typeof value!=="string")return null;const code=value.trim().toLowerCase();return/^[a-f0-9]{24}$/.test(code)?code:null}function userId(value){if(!Number.isSafeInteger(value)||value<1)throw TypeError("A positive integer user ID is required.")}async function connection(database){return database??(await import("@stacksjs/database/runtime")).db}async function rows(database,sql,bindings){const result=await database.unsafe(sql,bindings).execute();if(Array.isArray(result))return result;if(result&&typeof result==="object"&&"rows"in result&&Array.isArray(result.rows))return result.rows;throw Error("The referral query returned an unsupported result.")}async function duplicate(error){const{isUniqueViolation}=await import("@stacksjs/orm");return isUniqueViolation(error)}export async function createReferralCode(ownerId,database){userId(ownerId);const db=await connection(database);for(let attempt=0;attempt<5;attempt++){const existing=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]);if(existing[0])return existing[0].code;const code=randomBytes(12).toString("hex");try{await db.unsafe("INSERT INTO referral_codes (user_id, code) VALUES (?, ?)",[ownerId,code]).execute();return code}catch(error){if(!await duplicate(error))throw error}}throw Error("Could not allocate a referral code. Please retry.")}export async function attributeReferral(newUserId,input,database){userId(newUserId);const code=normalizeReferralCode(input);if(!code)return!1;const db=await connection(database),owner=(await rows(db,"SELECT code, user_id FROM referral_codes WHERE code = ?",[code]))[0];if(!owner||Number(owner.user_id)===newUserId)return!1;try{await db.unsafe("INSERT INTO referrals (referrer_id, referred_user_id, code, status) VALUES (?, ?, ?, ?)",[Number(owner.user_id),newUserId,code,"registered"]).execute();return!0}catch(error){if(await duplicate(error))return!1;throw error}}export async function qualifyReferral(referredUserId,database){userId(referredUserId);const db=await connection(database),now=new Date().toISOString().slice(0,19).replace("T"," ");await db.unsafe("UPDATE referrals SET status = ?, qualified_at = ?, updated_at = ? WHERE referred_user_id = ? AND status = ?",["qualified",now,now,referredUserId,"registered"]).execute()}export async function referralSummary(ownerId,database){userId(ownerId);const db=await connection(database),codes=await rows(db,"SELECT code, user_id FROM referral_codes WHERE user_id = ?",[ownerId]),counts=await rows(db,"SELECT COUNT(*) AS total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS qualified FROM referrals WHERE referrer_id = ?",["qualified",ownerId]);return{code:codes[0]?.code??null,referred:Number(counts[0]?.total??0),qualified:Number(counts[0]?.qualified??0)}}
|
package/dist/register.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials){const{email,password,name}=credentials;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if(await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials){const{email,password,name}=credentials;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if(await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
|
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
|
|
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,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";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};
|
package/dist/socials.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function defaultStore(){return{async findLink(provider,providerUserId){const row=await db.selectFrom("social_accounts").select(["user_id"]).where("provider","=",provider).where("provider_user_id","=",providerUserId).executeTakeFirst();return row?{userId:Number(row.user_id)}:void 0},async createLink(link){await db.insertInto("social_accounts").values({user_id:link.userId,provider:link.provider,provider_user_id:link.providerUserId,provider_email:link.providerEmail,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()},async findUserIdByEmail(email){const row=await db.selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();return row?Number(row.id):void 0},async createUser(attrs){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),created=await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id)return Number(created.id);const row=await db.insertInto("users").values({name:attrs.name,email:attrs.email,password,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).executeTakeFirst();return Number(row?.insertId??0)}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore()){const providerUserId=String(identity.id??"").trim();if(!provider||!providerUserId)throw TypeError("[auth/socials] a provider name and provider user id are required.");const existing=await store.findLink(provider,providerUserId);if(existing)return{userId:existing.userId,createdUser:!1,linked:!1};const matching=configuredMatching();if(matching==="refuse")throw new SocialSignInRefusedError("no-linked-account",`No ${provider} account is linked here. Sign in with your password, then connect ${provider} from your account settings.`);const email=identity.email?.trim().toLowerCase()||null,displayName=identity.name?.trim()||identity.nickname?.trim()||(email??`${provider} user`);if(matching==="link"&&email){const matchedUserId=await store.findUserIdByEmail(email);if(matchedUserId!==void 0){if(identity.emailVerified!==!0)throw new SocialSignInRefusedError("unverified-provider-email",`${provider} did not verify ${email}, and an account with that address already exists. Sign in with your password, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email});return{userId:matchedUserId,createdUser:!1,linked:!0}}}if(!email)throw new SocialSignInRefusedError("no-email-to-create-with",`${provider} shared no email address, so an account cannot be created. Grant the email permission at ${provider}, or register with an email first.`);if(matching==="create"&&await store.findUserIdByEmail(email)!==void 0)throw new SocialSignInRefusedError("email-already-registered",`An account with ${email} already exists. Sign in with your password, then connect ${provider} from your account settings.`);const userId=await store.createUser({name:displayName,email});await store.createLink({userId,provider,providerUserId,providerEmail:email});return{userId,createdUser:!0,linked:!0}}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function defaultStore(){return{async findLink(provider,providerUserId){const row=await db.selectFrom("social_accounts").select(["user_id"]).where("provider","=",provider).where("provider_user_id","=",providerUserId).executeTakeFirst();return row?{userId:Number(row.user_id)}:void 0},async createLink(link){await db.insertInto("social_accounts").values({user_id:link.userId,provider:link.provider,provider_user_id:link.providerUserId,provider_email:link.providerEmail,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()},async findUserIdByEmail(email){const row=await db.selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();return row?Number(row.id):void 0},async createUser(attrs){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),created=await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id)return Number(created.id);const row=await db.insertInto("users").values({name:attrs.name,email:attrs.email,password,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).executeTakeFirst();return Number(row?.insertId??0)}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore()){const providerUserId=String(identity.id??"").trim();if(!provider||!providerUserId)throw TypeError("[auth/socials] a provider name and provider user id are required.");const existing=await store.findLink(provider,providerUserId);if(existing)return{userId:existing.userId,createdUser:!1,linked:!1};const matching=configuredMatching();if(matching==="refuse")throw new SocialSignInRefusedError("no-linked-account",`No ${provider} account is linked here. Sign in with your password, then connect ${provider} from your account settings.`);const email=identity.email?.trim().toLowerCase()||null,displayName=identity.name?.trim()||identity.nickname?.trim()||(email??`${provider} user`);if(matching==="link"&&email){const matchedUserId=await store.findUserIdByEmail(email);if(matchedUserId!==void 0){if(identity.emailVerified!==!0)throw new SocialSignInRefusedError("unverified-provider-email",`${provider} did not verify ${email}, and an account with that address already exists. Sign in with your password, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email});return{userId:matchedUserId,createdUser:!1,linked:!0}}}if(!email)throw new SocialSignInRefusedError("no-email-to-create-with",`${provider} shared no email address, so an account cannot be created. Grant the email permission at ${provider}, or register with an email first.`);if(matching==="create"&&await store.findUserIdByEmail(email)!==void 0)throw new SocialSignInRefusedError("email-already-registered",`An account with ${email} already exists. Sign in with your password, then connect ${provider} from your account settings.`);const userId=await store.createUser({name:displayName,email});await store.createLink({userId,provider,providerUserId,providerEmail:email});return{userId,createdUser:!0,linked:!0}}
|
package/dist/team.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database";import{Auth}from"./authentication";import{sessionUser}from"./session-auth";import{authCookieName}from"./cookie-auth";export const ACTIVE_TEAM_COOKIE="active_team";export function selectActiveTeam(opts){const priority=opts.rolePriority??{owner:0,admin:1},sorted=[...opts.memberships??[]].sort((a,b)=>(priority[a.role??""]??2)-(priority[b.role??""]??2)),active=opts.activeTeamId==null?null:Number(opts.activeTeamId);if(active!=null&&Number.isFinite(active)){const member=sorted.find((x)=>Number(x.team_id)===active);if(member)return{teamId:active,role:member.role??null};if(opts.allowAnyTeam)return{teamId:active,role:"admin"}}const first=sorted[0];return first?{teamId:Number(first.team_id),role:first.role??null}:{teamId:null,role:null}}export function getActiveTeamPreference(request){const raw=request.cookies?.get(ACTIVE_TEAM_COOKIE);if(!raw)return null;const n=Number(raw);return Number.isFinite(n)&&n>0?n:null}export function buildActiveTeamCookie(teamId,opts={}){const maxAge=Math.max(0,Math.floor(opts.maxAgeSeconds??31536000)),parts=[`${ACTIVE_TEAM_COOKIE}=${encodeURIComponent(String(teamId))}`,"Path=/","HttpOnly","SameSite=Lax",`Max-Age=${maxAge}`];if(opts.secure)parts.push("Secure");return parts.join("; ")}export function clearActiveTeamCookie(){return`${ACTIVE_TEAM_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`}export async function resolveAuthenticatedMembership(request){const user=await resolveAuthenticatedUser(request);if(!user?.id)return null;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute();if(memberships.length===0)return null;const picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:m.role==null?null:String(m.role)})),activeTeamId:getActiveTeamPreference(request)});return picked.teamId!=null?{teamId:picked.teamId,role:picked.role??""}:null}export async function resolveTeamContext(request,opts={}){const empty={user:null,teamId:null,role:null,teams:[],activeTeamId:null},guardName=config.auth?.default||"api",user=(config.auth?.guards?.[guardName]?.driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);if(!user?.id)return empty;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute(),allowAny=opts.allowAnyTeam?!!opts.allowAnyTeam(user):!1,activeTeamId=getActiveTeamPreference(request),picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:String(m.role)})),activeTeamId,allowAnyTeam:allowAny}),roleByTeam=new Map(memberships.map((m)=>[Number(m.team_id),String(m.role)]));let teamRows=[];if(allowAny)teamRows=await db.selectFrom("teams").select(["id","name"]).execute();else{const ids=[...roleByTeam.keys()];teamRows=ids.length?await db.selectFrom("teams").whereIn("id",ids).select(["id","name"]).execute():[]}const teams=teamRows.map((t)=>({id:Number(t.id),name:String(t.name),role:roleByTeam.get(Number(t.id))||"viewer"})).sort((a,b)=>a.name.localeCompare(b.name));return{user,teamId:picked.teamId,role:picked.role,teams,activeTeamId}}export async function resolveAuthenticatedTeamId(request){const membership=await resolveAuthenticatedMembership(request);return membership?membership.teamId:null}export async function resolveAuthenticatedUser(request){const guardName=config.auth?.default||"api",user=((config.auth?.guards?.[guardName]||{driver:"token"}).driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);return user?.id?user:void 0}async function resolveSessionUser(request){const sessionId=request.cookies?.get("session_id");if(!sessionId)return;return sessionUser(sessionId)}async function resolveTokenUser(request){const cookieName=authCookieName(),bearer=(typeof request.bearerToken==="function"?request.bearerToken():void 0)??request.cookies?.get(cookieName);if(!bearer)return;return Auth.getUserFromToken(bearer)}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{Auth}from"./authentication";import{sessionUser}from"./session-auth";import{authCookieName}from"./cookie-auth";export const ACTIVE_TEAM_COOKIE="active_team";export function selectActiveTeam(opts){const priority=opts.rolePriority??{owner:0,admin:1},sorted=[...opts.memberships??[]].sort((a,b)=>(priority[a.role??""]??2)-(priority[b.role??""]??2)),active=opts.activeTeamId==null?null:Number(opts.activeTeamId);if(active!=null&&Number.isFinite(active)){const member=sorted.find((x)=>Number(x.team_id)===active);if(member)return{teamId:active,role:member.role??null};if(opts.allowAnyTeam)return{teamId:active,role:"admin"}}const first=sorted[0];return first?{teamId:Number(first.team_id),role:first.role??null}:{teamId:null,role:null}}export function getActiveTeamPreference(request){const raw=request.cookies?.get(ACTIVE_TEAM_COOKIE);if(!raw)return null;const n=Number(raw);return Number.isFinite(n)&&n>0?n:null}export function buildActiveTeamCookie(teamId,opts={}){const maxAge=Math.max(0,Math.floor(opts.maxAgeSeconds??31536000)),parts=[`${ACTIVE_TEAM_COOKIE}=${encodeURIComponent(String(teamId))}`,"Path=/","HttpOnly","SameSite=Lax",`Max-Age=${maxAge}`];if(opts.secure)parts.push("Secure");return parts.join("; ")}export function clearActiveTeamCookie(){return`${ACTIVE_TEAM_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`}export async function resolveAuthenticatedMembership(request){const user=await resolveAuthenticatedUser(request);if(!user?.id)return null;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute();if(memberships.length===0)return null;const picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:m.role==null?null:String(m.role)})),activeTeamId:getActiveTeamPreference(request)});return picked.teamId!=null?{teamId:picked.teamId,role:picked.role??""}:null}export async function resolveTeamContext(request,opts={}){const empty={user:null,teamId:null,role:null,teams:[],activeTeamId:null},guardName=config.auth?.default||"api",user=(config.auth?.guards?.[guardName]?.driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);if(!user?.id)return empty;const memberships=await db.selectFrom("team_members").where("user_id","=",user.id).where("status","=","active").select(["team_id","role"]).execute(),allowAny=opts.allowAnyTeam?!!opts.allowAnyTeam(user):!1,activeTeamId=getActiveTeamPreference(request),picked=selectActiveTeam({memberships:memberships.map((m)=>({team_id:Number(m.team_id),role:String(m.role)})),activeTeamId,allowAnyTeam:allowAny}),roleByTeam=new Map(memberships.map((m)=>[Number(m.team_id),String(m.role)]));let teamRows=[];if(allowAny)teamRows=await db.selectFrom("teams").select(["id","name"]).execute();else{const ids=[...roleByTeam.keys()];teamRows=ids.length?await db.selectFrom("teams").whereIn("id",ids).select(["id","name"]).execute():[]}const teams=teamRows.map((t)=>({id:Number(t.id),name:String(t.name),role:roleByTeam.get(Number(t.id))||"viewer"})).sort((a,b)=>a.name.localeCompare(b.name));return{user,teamId:picked.teamId,role:picked.role,teams,activeTeamId}}export async function resolveAuthenticatedTeamId(request){const membership=await resolveAuthenticatedMembership(request);return membership?membership.teamId:null}export async function resolveAuthenticatedUser(request){const guardName=config.auth?.default||"api",user=((config.auth?.guards?.[guardName]||{driver:"token"}).driver||"token")==="session"?await resolveSessionUser(request):await resolveTokenUser(request);return user?.id?user:void 0}async function resolveSessionUser(request){const sessionId=request.cookies?.get("session_id");if(!sessionId)return;return sessionUser(sessionId)}async function resolveTokenUser(request){const cookieName=authCookieName(),bearer=(typeof request.bearerToken==="function"?request.bearerToken():void 0)??request.cookies?.get(cookieName);if(!bearer)return;return Auth.getUserFromToken(bearer)}
|
package/dist/token-revocation.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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(`
|
|
1
|
+
import{db,getDatabaseDialect,markContextWrote,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";export async function revokeTokenPair(selector){await db.transaction(async(trx)=>{const sql=sqlHelpers(getDatabaseDialect()),byId="id"in selector,row=(await trx.unsafe(`
|
|
2
2
|
SELECT id FROM oauth_access_tokens
|
|
3
3
|
WHERE ${byId?"id":"token"} = ${sql.param(1)}${sql.isSqlite?"":" FOR UPDATE"}
|
|
4
4
|
`,[byId?selector.id:selector.hash]))[0];if(!row)return;markContextWrote();await trx.unsafe(`
|
package/dist/tokens.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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(`
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,markContextWrote,mutationCount}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{env}from"@stacksjs/env";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";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
2
|
SELECT 1 FROM information_schema.columns
|
|
3
3
|
WHERE table_schema = DATABASE() AND table_name = ?
|
|
4
4
|
AND column_name = 'password_changed_at'
|
package/dist/two-factor.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{db,sqlDateTime
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{generateTwoFactorSecret,generateTwoFactorUri,twoFactorQrCode,verifyTwoFactorCode}from"./authenticator";import{RateLimiter}from"./rate-limiter";const DEFAULT_CHALLENGE_TTL_SECONDS=300,TWO_FACTOR_RATE_LIMIT_PREFIX="2fa:",TWO_FACTOR_STEP_SECONDS=30;function currentTotpStep(){return Math.floor(Date.now()/1000/TWO_FACTOR_STEP_SECONDS)}async function getLastUsedTwoFactorStep(userId){try{const value=(await db.selectFrom("users").where("id","=",userId).selectAll().executeTakeFirst())?.two_factor_last_used_step;return value==null?null:Number(value)}catch{return null}}async function setLastUsedTwoFactorStep(userId,step){try{await db.updateTable("users").set({two_factor_last_used_step:step}).where("id","=",userId).executeTakeFirst()}catch{}}export async function getTwoFactorState(userId){const row=await db.selectFrom("users").where("id","=",userId).select(["two_factor_secret","two_factor_enabled"]).executeTakeFirst();return{secret:row?.two_factor_secret??null,enabled:Boolean(row?.two_factor_enabled)}}export function isTwoFactorEnabled(user){return Boolean(user.two_factor_enabled)}export function generateTwoFactorSetup(email,serviceName){const secret=generateTwoFactorSecret(),uri=generateTwoFactorUri(email,serviceName,secret);return{secret,uri,qr:twoFactorQrCode(uri)}}const PENDING_SECRET_TTL_SECONDS=600;export async function stashPendingTwoFactorSecret(userId,secret,ttlSeconds=PENDING_SECRET_TTL_SECONDS){const expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();await db.insertInto("two_factor_pending_secrets").values({user_id:userId,secret,expires_at:expiresAt}).execute()}export async function consumePendingTwoFactorSecret(userId){const row=await db.selectFrom("two_factor_pending_secrets").where("user_id","=",userId).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_pending_secrets").where("user_id","=",userId).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return String(row.secret)}export async function enableTwoFactor(userId,secret,code){if(!await verifyTwoFactorCode(code,secret))return!1;await db.updateTable("users").set({two_factor_secret:secret,two_factor_enabled:!0}).where("id","=",userId).executeTakeFirst();return!0}export async function disableTwoFactor(userId){await db.updateTable("users").set({two_factor_secret:null,two_factor_enabled:!1}).where("id","=",userId).executeTakeFirst()}export async function verifyTwoFactorLoginCode(userId,code){const rateKey=`${TWO_FACTOR_RATE_LIMIT_PREFIX}${userId}`;await RateLimiter.validateAttempt(rateKey);const{secret,enabled}=await getTwoFactorState(userId);if(!enabled||!secret)return!1;if(!await verifyTwoFactorCode(code,secret)){await RateLimiter.recordFailedAttempt(rateKey);return!1}const step=currentTotpStep(),lastStep=await getLastUsedTwoFactorStep(userId);if(lastStep!==null&&step<=lastStep)return!1;await setLastUsedTwoFactorStep(userId,step);await RateLimiter.resetAttempts(rateKey);return!0}export async function createTwoFactorChallenge(userId,ttlSeconds=DEFAULT_CHALLENGE_TTL_SECONDS){const id=randomBytes(32).toString("hex"),expiresAt=sqlDateTime(new Date(Date.now()+ttlSeconds*1000));await db.deleteFrom("two_factor_challenges").where("user_id","=",userId).execute();await db.insertInto("two_factor_challenges").values({id,user_id:userId,expires_at:expiresAt}).execute();return id}export async function consumeTwoFactorChallenge(challengeToken){const row=await db.selectFrom("two_factor_challenges").where("id","=",challengeToken).selectAll().executeTakeFirst();if(!row)return null;await db.deleteFrom("two_factor_challenges").where("id","=",challengeToken).execute();const expiresAt=parseSqlDateTime(row.expires_at)?.getTime()??0;if(Date.now()>expiresAt)return null;return Number(row.user_id)}export const TwoFactor={isEnabled:isTwoFactorEnabled,getState:getTwoFactorState,generateSetup:generateTwoFactorSetup,stashPendingSecret:stashPendingTwoFactorSecret,consumePendingSecret:consumePendingTwoFactorSecret,enable:enableTwoFactor,disable:disableTwoFactor,verifyLoginCode:verifyTwoFactorLoginCode,createChallenge:createTwoFactorChallenge,consumeChallenge:consumeTwoFactorChallenge};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/auth",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.48",
|
|
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.48",
|
|
62
|
+
"@stacksjs/config": "0.74.48",
|
|
63
|
+
"@stacksjs/database": "0.74.48",
|
|
64
|
+
"@stacksjs/email": "0.74.48",
|
|
65
|
+
"@stacksjs/env": "0.74.48",
|
|
66
|
+
"@stacksjs/error-handling": "0.74.48",
|
|
67
|
+
"@stacksjs/logging": "0.74.48",
|
|
68
|
+
"@stacksjs/orm": "0.74.48",
|
|
69
|
+
"@stacksjs/path": "0.74.48",
|
|
70
|
+
"@stacksjs/router": "0.74.48",
|
|
71
|
+
"@stacksjs/security": "0.74.48",
|
|
72
|
+
"@stacksjs/storage": "0.74.48",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|