@stacksjs/auth 0.75.225 → 0.75.230
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.d.ts +1 -1
- package/dist/authentication.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/password/credentials.d.ts +55 -0
- package/dist/password/credentials.js +1 -0
- package/dist/password/reset.d.ts +26 -1
- package/dist/password/reset.js +4 -2
- package/dist/rate-limiter.d.ts +26 -6
- package/dist/rate-limiter.js +2 -2
- package/dist/session-auth.d.ts +1 -1
- package/dist/session-auth.js +1 -1
- package/dist/session-handoff.d.ts +46 -0
- package/dist/session-handoff.js +1 -0
- package/dist/socials.d.ts +30 -7
- package/dist/socials.js +1 -1
- package/dist/tokens.d.ts +6 -3
- package/dist/tokens.js +1 -1
- package/package.json +14 -13
package/dist/authentication.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare class Auth {
|
|
|
34
34
|
static revokeToken(token: string): Promise<void>;
|
|
35
35
|
static revokeTokenById(tokenId: number): Promise<void>;
|
|
36
36
|
static revokeAllTokens(userId?: number): Promise<void>;
|
|
37
|
-
static revokeOtherTokens(userId?: number): Promise<
|
|
37
|
+
static revokeOtherTokens(userId?: number, options?: { exceptTokenId?: number }): Promise<{ keptTokenId: number | null }>;
|
|
38
38
|
static pruneExpiredTokens(): Promise<number>;
|
|
39
39
|
static pruneRevokedTokens(): Promise<number>;
|
|
40
40
|
static rotateToken(oldToken: string): Promise<AuthToken | null>;
|
package/dist/authentication.js
CHANGED
|
@@ -5,4 +5,4 @@ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransactio
|
|
|
5
5
|
AND t.oauth_grant_id IS NULL
|
|
6
6
|
AND (c.id IS NULL OR c.revoked = ${sql.boolFalse})
|
|
7
7
|
LIMIT 1
|
|
8
|
-
`,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate||candidate.grantId!=null)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||existing.grantId!=null||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
|
8
|
+
`,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId,options={}){const uid=userId??await this.id();if(!uid)return{keptTokenId:null};const keep=options.exceptTokenId??(await this.currentAccessToken())?.id;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,keep);return{keptTokenId:keep??null}}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate||candidate.grantId!=null)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||existing.grantId!=null||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
package/dist/index.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export * from './request-token';
|
|
|
55
55
|
export * from './page-gate';
|
|
56
56
|
// Social sign-in: which local user a provider identity resolves to.
|
|
57
57
|
export * from './socials';
|
|
58
|
+
// One-use, browser-bound codes that move a redirect flow's sign-in into a page.
|
|
59
|
+
export * from './session-handoff';
|
|
58
60
|
// TOTP (Two-Factor Authentication) - re-export from ts-auth
|
|
59
61
|
export {
|
|
60
62
|
generateTOTP,
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
|
|
1
|
+
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export*from"./session-handoff";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare function revokeAccountCredentials(userId: number, options?: RevokeAccountCredentialsOptions): Promise<RevokeAccountCredentialsResult>;
|
|
2
|
+
/**
|
|
3
|
+
* Change a password the signed-in user knows, then sweep every other
|
|
4
|
+
* credential the account holds.
|
|
5
|
+
*
|
|
6
|
+
* The current password is verified, and the change commits only if it is
|
|
7
|
+
* still the stored hash at write time (a concurrent reset or change wins, and
|
|
8
|
+
* this returns `invalid-current-password`). The new password's policy (length,
|
|
9
|
+
* strength, breach checks) is the caller's to enforce first.
|
|
10
|
+
*/
|
|
11
|
+
export declare function changePassword(userId: number, currentPassword: string, newPassword: string, options?: ChangePasswordOptions): Promise<ChangePasswordResult>;
|
|
12
|
+
/**
|
|
13
|
+
* The framework's "your password was changed" mail. Never throws: the change
|
|
14
|
+
* already happened, and a mail failure must not report it as failed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sendPasswordChangedNotification(userEmail: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* One credential sweep for every path that changes how an account proves who
|
|
19
|
+
* it is: a password reset, a password change, an account recovery, an admin
|
|
20
|
+
* forcing a sign-out.
|
|
21
|
+
*
|
|
22
|
+
* Password resets swept everything; anything else that changed a password
|
|
23
|
+
* had to remember the same five revocations and the stamp, and an app that
|
|
24
|
+
* forgot one left a door open (an attacker's session surviving the victim's
|
|
25
|
+
* password change is the case that matters). This is that sweep, exported:
|
|
26
|
+
*
|
|
27
|
+
* - stamps `users.password_changed_at`, which the token paths read to refuse
|
|
28
|
+
* any token issued before it, so even a token this sweep could not see is
|
|
29
|
+
* dead;
|
|
30
|
+
* - revokes pending two-factor login challenges, magic links, OAuth grants
|
|
31
|
+
* and authorization codes, access and refresh tokens, and sessions;
|
|
32
|
+
* - all in one transaction: a failure anywhere rolls the whole sweep back
|
|
33
|
+
* rather than reporting a half-revoked account.
|
|
34
|
+
*
|
|
35
|
+
* `exceptTokenId` / `exceptSessionId` keep the caller's own credential, so a
|
|
36
|
+
* user who changes their password stays signed in where they did it. The kept
|
|
37
|
+
* token (and its refresh token) is re-bound to the new stamp; everything else
|
|
38
|
+
* the account holds is gone.
|
|
39
|
+
*/
|
|
40
|
+
export declare interface RevokeAccountCredentialsOptions {
|
|
41
|
+
exceptTokenId?: number
|
|
42
|
+
exceptSessionId?: string
|
|
43
|
+
tx?: unknown
|
|
44
|
+
}
|
|
45
|
+
export declare interface RevokeAccountCredentialsResult {
|
|
46
|
+
passwordChangedAt: Date | null
|
|
47
|
+
keptTokenId: number | null
|
|
48
|
+
}
|
|
49
|
+
export declare interface ChangePasswordOptions {
|
|
50
|
+
keepTokenId?: number
|
|
51
|
+
keepSessionId?: string
|
|
52
|
+
notify?: boolean | ((user: { id: number, email: string, name?: string }) => Promise<void> | void)
|
|
53
|
+
}
|
|
54
|
+
export type ChangePasswordResult = | { success: true, keptTokenId: number | null }
|
|
55
|
+
| { success: false, reason: 'invalid-current-password' | 'user-not-found' | 'invalid-new-password' }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,isInTransaction,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{makeHash,verifyHash}from"@stacksjs/security";import{withVerifiedPassword}from"../credential-version";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";import{sessionDestroyAll}from"../session-auth";import{revokeTokenPairs}from"../token-revocation";import{DEFAULT_TOKENABLE_TYPE}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";export async function revokeAccountCredentials(userId,options={}){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] revokeAccountCredentials needs a positive integer user id.");if(options.exceptTokenId!==void 0&&(!Number.isSafeInteger(options.exceptTokenId)||options.exceptTokenId<=0))throw TypeError("[auth] exceptTokenId must be a positive integer.");if(options.tx!==void 0&&!isInTransaction())throw TypeError("[auth] revokeAccountCredentials was given a tx but is not running inside that transaction.");return db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).selectAll();if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner)throw Error("[auth] revokeAccountCredentials: the user does not exist.");let passwordChangedAt=null;const stampValue=sqlDateTime(new Date);if(Object.hasOwn(owner,"password_changed_at")){const stamped=await db.updateTable("users").set({password_changed_at:stampValue}).where("id","=",userId).execute();if(mutationCount(stamped)!==1)throw Error("[auth] revokeAccountCredentials could not stamp password_changed_at.");passwordChangedAt=parseSqlDateTime(stampValue)}else log.warn("[auth] password_changed_at column missing - run `buddy migrate`; credentials are revoked without the credential-version stamp");await revokeTwoFactorChallenges(userId);await revokeMagicLinks(userId);await revokeOAuthSubjectAuthorizationState("users",userId);await revokeTokenPairs(userId,DEFAULT_TOKENABLE_TYPE,options.exceptTokenId);await sessionDestroyAll(userId,options.exceptSessionId!==void 0?{exceptId:options.exceptSessionId}:{});let keptTokenId=null;if(options.exceptTokenId!==void 0&&passwordChangedAt){const rebound=await db.updateTable("oauth_access_tokens").set({created_at:stampValue}).where("id","=",options.exceptTokenId).where("tokenable_id","=",userId).execute();if(mutationCount(rebound)===1){await db.updateTable("oauth_refresh_tokens").set({created_at:stampValue}).where("access_token_id","=",options.exceptTokenId).execute();keptTokenId=options.exceptTokenId}}else if(options.exceptTokenId!==void 0)keptTokenId=options.exceptTokenId;return{passwordChangedAt,keptTokenId}})}export async function changePassword(userId,currentPassword,newPassword,options={}){if(typeof newPassword!=="string"||newPassword.length===0)return{success:!1,reason:"invalid-new-password"};const user=await db.primary.selectFrom("users").where("id","=",userId).select(["id","email","name","password"]).executeTakeFirst();if(!user)return{success:!1,reason:"user-not-found"};const storedHash=typeof user.password==="string"?user.password:"";if(typeof currentPassword!=="string"||!storedHash||!await verifyHash(currentPassword,storedHash))return{success:!1,reason:"invalid-current-password"};const hashed=await makeHash(newPassword,{algorithm:"bcrypt"}),swept=await withVerifiedPassword(userId,storedHash,async()=>{const updated=await db.updateTable("users").set({password:hashed}).where("id","=",userId).execute();if(mutationCount(updated)!==1)throw Error("[auth] changePassword could not update the password.");return revokeAccountCredentials(userId,{...options.keepTokenId!==void 0&&{exceptTokenId:options.keepTokenId},...options.keepSessionId!==void 0&&{exceptSessionId:options.keepSessionId}})});if(!swept)return{success:!1,reason:"invalid-current-password"};const notify=options.notify??!0;if(notify!==!1&&user.email){const recipient={id:Number(user.id),email:String(user.email),...user.name?{name:String(user.name)}:{}},send=async()=>{try{if(typeof notify==="function")await notify(recipient);else await sendPasswordChangedNotification(recipient.email)}catch(error){log.error(`[auth] password-changed notification failed: ${error instanceof Error?error.message:String(error)}`)}};if(!enqueueAfterCommit(send))send()}return{success:!0,keptTokenId:swept.keptTokenId}}export 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)}}
|
package/dist/password/reset.d.ts
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
|
+
import { revokeAccountCredentials, sendPasswordChangedNotification } from './credentials';
|
|
2
|
+
export type { ChangePasswordOptions, ChangePasswordResult, RevokeAccountCredentialsOptions, RevokeAccountCredentialsResult } from './credentials';
|
|
1
3
|
export declare function passwordResets(value: string): PasswordResetActions;
|
|
2
4
|
export declare interface PasswordResetResult {
|
|
3
5
|
success: boolean
|
|
4
6
|
message?: string
|
|
5
7
|
}
|
|
8
|
+
/** What a `deliver` hook receives. `token` and `url` are the live credential: never log them. */
|
|
9
|
+
export declare interface PasswordResetDelivery {
|
|
10
|
+
purpose: PasswordResetPurpose
|
|
11
|
+
userId: number
|
|
12
|
+
email: string
|
|
13
|
+
token: string
|
|
14
|
+
url: string
|
|
15
|
+
expiresAt: Date
|
|
16
|
+
}
|
|
17
|
+
export declare interface SendPasswordResetOptions {
|
|
18
|
+
purpose?: PasswordResetPurpose
|
|
19
|
+
expiresAt?: Date
|
|
20
|
+
deliver?: (delivery: PasswordResetDelivery) => Promise<void> | void
|
|
21
|
+
}
|
|
6
22
|
export declare interface PasswordResetActions {
|
|
7
|
-
sendEmail: () => Promise<void>
|
|
23
|
+
sendEmail: (options?: SendPasswordResetOptions) => Promise<void>
|
|
8
24
|
verifyToken: (token: string) => Promise<boolean>
|
|
9
25
|
resetPassword: (token: string, newPassword: string) => Promise<PasswordResetResult>
|
|
10
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Why the link is being sent. `reset` is "I forgot my password"; `setup` is
|
|
29
|
+
* an account someone else created (an invitation, an import, a staff member
|
|
30
|
+
* adding a client) whose holder chooses their first password. The token and
|
|
31
|
+
* redemption are identical; the default mail differs (`password-setup`
|
|
32
|
+
* template) and a `deliver` hook can tell them apart.
|
|
33
|
+
*/
|
|
34
|
+
export type PasswordResetPurpose = 'reset' | 'setup';
|
|
35
|
+
export { changePassword, revokeAccountCredentials, sendPasswordChangedNotification } from './credentials';
|
package/dist/password/reset.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{
|
|
1
|
+
import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{findAuthUserRowByEmail,normalizeAuthEmail}from"../credential-user";import{revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";export{changePassword,revokeAccountCredentials,sendPasswordChangedNotification}from"./credentials";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function getResetThrottleMilliseconds(){const seconds=config.auth.passwordReset?.throttle??60;if(seconds===0)return 0;const milliseconds=seconds*1000;return Number.isFinite(milliseconds)&&milliseconds>0?milliseconds:60000}function resetRequestIsThrottled(createdAt,throttleMs){if(throttleMs===0)return!1;const created=parseSqlDateTime(createdAt);return created!=null&&created.getTime()+throttleMs>Date.now()}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}export function passwordResets(value){const email=normalizeAuthEmail(value);function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId,expiresAtOverride){const throttleMs=getResetThrottleMilliseconds(),observed=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(observed?.created_at,throttleMs))return;const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();if(!await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).select(["id","email"]);if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();const owner=await ownerQuery.executeTakeFirst();if(!owner||normalizeAuthEmail(String(owner.email??""))!==email)return!1;const current=await db.primary.selectFrom("password_resets").where("email","=",email).select("created_at").orderBy("created_at","desc").executeTakeFirst();if(resetRequestIsThrottled(current?.created_at,throttleMs))return!1;const createdAt=new Date,expiresAt=sqlDateTime(expiresAtOverride??new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token"]).execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0))return;return{token,expiresAt:expiresAtOverride??new Date(Date.now()+expireMinutes*60000)}}async function sendEmail(options={}){const purpose=options.purpose==="setup"?"setup":"reset";if(options.expiresAt!==void 0&&(!(options.expiresAt instanceof Date)||!Number.isFinite(options.expiresAt.getTime())||options.expiresAt.getTime()<=Date.now()))throw TypeError("[PasswordReset] expiresAt must be a future Date.");const user=await findAuthUserRowByEmail(email);if(!user)return;const userId=Number(user.id);if(!Number.isSafeInteger(userId)||userId<=0)return;const issued=await createResetToken(userId,options.expiresAt);if(!issued)return;const delivery={purpose,userId,email,token:issued.token,url:await resetUrlFor(issued.token),expiresAt:issued.expiresAt},deliver=options.deliver?async()=>{await options.deliver(delivery)}:()=>deliverResetEmail(delivery);if(!enqueueAfterCommit(deliver))await deliver()}async function resetUrlFor(token){const base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email));return/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`}async function deliverResetEmail(delivery){const resetUrl=delivery.url,expireMinutes=Math.max(1,Math.round((delivery.expiresAt.getTime()-Date.now())/60000)),appName=config.app.name||"Stacks",setup=delivery.purpose==="setup",subject=setup?`Set Your ${appName} Password`:`Reset Your ${appName} Password`;let html,text;try{const rendered=await template("password-reset",{subject,variables:{resetUrl,expireMinutes,purpose:delivery.purpose}});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=setup?`Choose your password by visiting: ${resetUrl}
|
|
2
2
|
|
|
3
|
-
This link expires in ${expireMinutes} minutes
|
|
3
|
+
This link expires in ${expireMinutes} minutes.`:`Reset your password by visiting: ${resetUrl}
|
|
4
|
+
|
|
5
|
+
This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:delivery.email,subject,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).select(["token","expires_at","created_at"]).executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const owner=await findAuthUserRowByEmail(email);if(!owner)return{success:!1,message:"Invalid or expired reset token"};const ownerId=Number(owner.id);if(!Number.isSafeInteger(ownerId)||ownerId<=0)return{success:!1,message:"Invalid or expired reset token"};const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("id","=",ownerId).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user||normalizeAuthEmail(String(user.email??""))!==email)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).select(["token","expires_at","created_at"]);if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.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 hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const updated=await trx.updateTable("users").set({password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeAccountCredentials(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
|
package/dist/rate-limiter.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures before a lockout: 5, multiplied by `config.security.rateLimit.scale`
|
|
3
|
+
* (`STACKS_RATE_LIMIT_SCALE`) so a test suite can sign in often without
|
|
4
|
+
* turning the lockout off.
|
|
5
|
+
*/
|
|
6
|
+
export declare function maxFailedAttempts(): number;
|
|
1
7
|
export declare interface RateLimitEntry {
|
|
2
8
|
attempts: number
|
|
3
9
|
lockedUntil: number
|
|
@@ -5,12 +11,14 @@ export declare interface RateLimitEntry {
|
|
|
5
11
|
/**
|
|
6
12
|
* Pluggable backing store for the auth rate limiter.
|
|
7
13
|
*
|
|
8
|
-
* Methods may be sync or async — the limiter awaits them either way.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* Methods may be sync or async — the limiter awaits them either way. When
|
|
15
|
+
* `config/cache.ts` uses the Redis driver the limiter counts there (atomic
|
|
16
|
+
* across workers and instances), falling back to this process's memory only
|
|
17
|
+
* while Redis is unreachable. Otherwise it uses the process-local
|
|
18
|
+
* {@link MemoryStore}, which a horizontally-scaled deployment bypasses by
|
|
19
|
+
* spreading attempts across instances: configure Redis, call
|
|
20
|
+
* `RateLimiter.useSharedStore()`, or provide a custom store whose
|
|
21
|
+
* `recordFailedAttempt` operation is atomic.
|
|
14
22
|
*/
|
|
15
23
|
export declare interface RateLimiterStore {
|
|
16
24
|
get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
|
|
@@ -38,6 +46,18 @@ export declare class RedisRateLimiterStore implements RateLimiterStore {
|
|
|
38
46
|
recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
|
|
39
47
|
close(): Promise<void>;
|
|
40
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The configured shared store, with this process's memory standing in while
|
|
51
|
+
* it errors: a Redis outage weakens the lockout to per-process instead of
|
|
52
|
+
* failing every sign-in. Warns once per outage.
|
|
53
|
+
*/
|
|
54
|
+
export declare class FallbackRateLimiterStore implements RateLimiterStore {
|
|
55
|
+
constructor(shared: RateLimiterStore, local?: MemoryStore);
|
|
56
|
+
get(key: string): Promise<RateLimitEntry | undefined>;
|
|
57
|
+
delete(key: string): Promise<void>;
|
|
58
|
+
recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
41
61
|
export declare class RateLimiter {
|
|
42
62
|
static useStore(custom: RateLimiterStore): void;
|
|
43
63
|
static useSharedStore(options?: RedisRateLimiterStoreOptions): void;
|
package/dist/rate-limiter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{HttpError}from"@stacksjs/error-handling";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&¤t.lockedUntil>0&¤t.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=
|
|
1
|
+
import{HttpError}from"@stacksjs/error-handling";import{cacheRedisConnection,scaledLimit}from"@stacksjs/router";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;export function maxFailedAttempts(){return scaledLimit(MAX_ATTEMPTS)}function recordFailure(current,now){const expired=current&¤t.lockedUntil>0&¤t.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=maxFailedAttempts()){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}const RECORD_FAILURE_SCRIPT=`
|
|
2
2
|
local attempts = 0
|
|
3
3
|
local locked_until = 0
|
|
4
4
|
local raw = redis.call('GET', KEYS[1])
|
|
@@ -23,4 +23,4 @@ if attempts >= maximum then
|
|
|
23
23
|
end
|
|
24
24
|
redis.call('SET', KEYS[1], cjson.encode({ attempts = attempts, lockedUntil = locked_until }), 'PX', ttl)
|
|
25
25
|
return locked_until
|
|
26
|
-
`;function redisUrl(options,configured){
|
|
26
|
+
`;function redisUrl(options,configured){if(options.url)return{url:options.url,prefix:options.prefix??configured?.prefix??"stacks"};const connection=cacheRedisConnection(configured);if(!connection)throw Error("Shared auth rate limiting requires the Redis cache driver or an explicit Redis URL.");return{url:connection.url,prefix:options.prefix??connection.prefix}}export class RedisRateLimiterStore{options;clientPromise;constructor(options={}){this.options=options}async client(){if(!this.clientPromise)this.clientPromise=(async()=>{const{cache:configured}=await import("@stacksjs/config"),resolved=redisUrl(this.options,configured),client=new RedisClient(resolved.url);await client.connect();return{client,prefix:resolved.prefix}})().catch((error)=>{this.clientPromise=void 0;throw error});return this.clientPromise}async key(key){const{client,prefix}=await this.client();return{client,key:`${prefix}:auth:ratelimit:${key}`}}async get(key){const resolved=await this.key(key),raw=await resolved.client.get(resolved.key);if(raw==null)return;try{const entry=JSON.parse(raw);if(!Number.isSafeInteger(entry.attempts)||entry.attempts<0||!Number.isSafeInteger(entry.lockedUntil)||entry.lockedUntil<0)return;return{attempts:entry.attempts,lockedUntil:entry.lockedUntil}}catch{return}}async set(key,entry,ttlMs){const resolved=await this.key(key);await resolved.client.send("SET",[resolved.key,JSON.stringify(entry),"PX",String(ttlMs)])}async delete(key){const resolved=await this.key(key);await resolved.client.del(resolved.key)}async recordFailedAttempt(key,now,ttlMs){const resolved=await this.key(key);await resolved.client.send("EVAL",[RECORD_FAILURE_SCRIPT,"1",resolved.key,String(now),String(ttlMs),String(maxFailedAttempts())])}async close(){if(!this.clientPromise)return;const{client}=await this.clientPromise;client.close();this.clientPromise=void 0}}export class FallbackRateLimiterStore{shared;local;warned=!1;constructor(shared,local=new MemoryStore){this.shared=shared;this.local=local}async attempt(primary,secondary){try{const result=await primary();this.warned=!1;return result}catch(error){if(!this.warned){this.warned=!0;console.warn(`[auth] login lockouts fell back to this process's memory: the shared store is unreachable (${error instanceof Error?error.message:String(error)}).`)}return secondary()}}async get(key){return this.attempt(()=>this.shared.get(key),()=>this.local.get(key))}async delete(key){this.local.delete(key);await this.attempt(()=>this.shared.delete(key),()=>{return})}async recordFailedAttempt(key,now,ttlMs){await this.attempt(()=>this.shared.recordFailedAttempt(key,now,ttlMs),()=>this.local.recordFailedAttempt(key,now))}async close(){await this.shared.close?.()}}let store;function replaceStore(next){const previous=store;store=next;if(previous&&previous!==next&&previous.close)Promise.resolve(previous.close()).catch(()=>{})}async function configuredStore(){if(store)return store;const{cache}=await import("@stacksjs/config");store??=cache.driver==="redis"?new FallbackRateLimiterStore(new RedisRateLimiterStore):new MemoryStore;return store}export class RateLimiter{static useStore(custom){replaceStore(custom)}static useSharedStore(options={}){replaceStore(new RedisRateLimiterStore(options))}static useMemoryStore(){replaceStore(new MemoryStore)}static useConfiguredStore(){replaceStore(void 0)}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await(await configuredStore()).get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();await(await configuredStore()).recordFailedAttempt(email,now,LOCKOUT_DURATION)}static async resetAttempts(email){await(await configuredStore()).delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
|
package/dist/session-auth.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export declare function sessionLogout(sessionId: string): Promise<void>;
|
|
|
58
58
|
* does), and without the table `sessionCheck` can never validate a
|
|
59
59
|
* session, so there is no credential left to revoke.
|
|
60
60
|
*/
|
|
61
|
-
export declare function sessionDestroyAll(userId: number): Promise<void>;
|
|
61
|
+
export declare function sessionDestroyAll(userId: number, options?: { exceptId?: string }): Promise<void>;
|
|
62
62
|
/** Get the authenticated user from a session ID. */
|
|
63
63
|
export declare function sessionUser(sessionId: string): Promise<UserModel | undefined>;
|
|
64
64
|
/**
|
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,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{clientAddress,getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null,inRequest:!1};const headers=req.headers,ip=clientAddress(req),userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent,inRequest:!0}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&stored.userAgent!=null&¤t.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;if(enforceUa&&stored.userAgent!=null&¤t.userAgent==null&¤t.inRequest===!0)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0)return;let touch=db.updateTable("sessions").set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
|
|
1
|
+
import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{clientAddress,getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}export function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null,inRequest:!1};const headers=req.headers,ip=clientAddress(req),userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent,inRequest:!0}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&stored.userAgent!=null&¤t.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;if(enforceUa&&stored.userAgent!=null&¤t.userAgent==null&¤t.inRequest===!0)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId,options={}){const{exceptId}=options;try{await db.transaction(async()=>{let removal=db.deleteFrom("sessions").where("user_id","=",userId);if(exceptId!==void 0)removal=removal.where("id","!=",exceptId);await removal.execute();let check=db.primary.selectFrom("sessions").where("user_id","=",userId);if(exceptId!==void 0)check=check.where("id","!=",exceptId);if(await check.select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId,lock=!1){let query=db.primary.selectFrom("users").where("id","=",userId).select("id");if(lock&&getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();return await query.executeTakeFirst()!==void 0}function sessionIdleTimeoutExceeded(lastActivity){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const seconds=Number(lastActivity);if(!Number.isFinite(seconds)||seconds<=0)return!0;return Date.now()-seconds*1000>idleMs}async function deleteObservedIdleSession(sessionId,userId,expiresAt,lastActivity){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);cleanup=lastActivity==null?cleanup.whereNull("last_activity"):cleanup.where("last_activity","=",lastActivity);await cleanup.execute()}async function touchSessionActivity(sessionId,expiresAt,lastActivity){if((config.auth?.idleTimeout??0)<=0)return;let touch=db.updateTable("sessions").set({last_activity:Math.floor(Date.now()/1000)}).where("id","=",sessionId);touch=expiresAt==null?touch.whereNull("expires_at"):touch.where("expires_at","=",expiresAt);touch=lastActivity==null?touch.whereNull("last_activity"):touch.where("last_activity","=",lastActivity);await touch.execute()}export async function sessionUser(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return await User.find(userId)}catch{return}}export async function sessionCheck(sessionId){try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}await touchSessionActivity(sessionId,session.expires_at,session.last_activity);return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const query=db.primary.selectFrom("sessions").where("id","=",sessionId),session=config.auth?.session?.enforceFingerprint?await query.select(["user_id","expires_at","last_activity","ip_address","user_agent"]).executeTakeFirst():await query.select(["user_id","expires_at","last_activity"]).executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionIdleTimeoutExceeded(session.last_activity)){await deleteObservedIdleSession(sessionId,session.user_id,session.expires_at,session.last_activity);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const userId=session.user_id;if(!await sessionOwnerExists(userId,!0)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");if(!await sessionOwnerExists(userId,!0))throw Error("[auth] Session owner disappeared during renewal.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Mint a handoff for `userId`. Mint the session itself only on redeem. */
|
|
2
|
+
export declare function createSessionHandoff(userId: number, options: CreateSessionHandoffOptions): Promise<SessionHandoff>;
|
|
3
|
+
/**
|
|
4
|
+
* Redeem a handoff once. `secret` is the binding cookie's value
|
|
5
|
+
* ({@link readSessionHandoffCookie}) or the native client's verifier. Null for
|
|
6
|
+
* an unknown, spent, expired or foreign code.
|
|
7
|
+
*/
|
|
8
|
+
export declare function redeemSessionHandoff(code: unknown, secret: string | null | undefined): Promise<RedeemedSessionHandoff | null>;
|
|
9
|
+
/** The binding cookie's value for `code` from a `Cookie` header, or ''. */
|
|
10
|
+
export declare function readSessionHandoffCookie(cookieHeader: string | null | undefined, code: string): string;
|
|
11
|
+
/** A `Set-Cookie` value removing the binding cookie. Send it with every redeem answer. */
|
|
12
|
+
export declare function clearSessionHandoffCookie(code: string, cookiePath?: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* A one-use code that moves a server-side sign-in into a browser.
|
|
15
|
+
*
|
|
16
|
+
* A redirect flow (social sign-in, an emailed link, a native app's sign-in
|
|
17
|
+
* sheet) ends on a server that knows who signed in and a browser that does
|
|
18
|
+
* not hold a session yet. Putting the bearer and refresh tokens in the
|
|
19
|
+
* redirect's URL fragment works, but the fragment lands in browser history,
|
|
20
|
+
* and anyone who later reads it (a shared machine, a sync service, a
|
|
21
|
+
* screenshot) holds a live session.
|
|
22
|
+
*
|
|
23
|
+
* Instead the redirect carries this code. It lives 60 seconds, redeems once,
|
|
24
|
+
* and only for the browser that holds the matching HttpOnly cookie, or, for a
|
|
25
|
+
* native app whose sign-in sheet keeps its own cookies, for the client that
|
|
26
|
+
* holds the verifier behind the challenge it supplied (PKCE-style). The page
|
|
27
|
+
* POSTs the code to `/auth/handoff` and receives the session there.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SESSION_HANDOFF_PURPOSE: 'session-handoff';
|
|
30
|
+
export declare interface CreateSessionHandoffOptions {
|
|
31
|
+
bindTo: SessionHandoffBinding
|
|
32
|
+
data?: Record<string, unknown>
|
|
33
|
+
cookiePath?: string
|
|
34
|
+
}
|
|
35
|
+
export declare interface SessionHandoff {
|
|
36
|
+
code: string
|
|
37
|
+
cookie: string | null
|
|
38
|
+
expiresAt: Date
|
|
39
|
+
}
|
|
40
|
+
export declare interface RedeemedSessionHandoff {
|
|
41
|
+
userId: number
|
|
42
|
+
data: Record<string, unknown> | null
|
|
43
|
+
}
|
|
44
|
+
export type SessionHandoffBinding = | 'cookie'
|
|
45
|
+
/** The lowercase hex SHA-256 of a verifier the redeeming client holds (native apps). */
|
|
46
|
+
| { challenge: string }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{clearOAuthFlowCookie,consumeOAuthFlow,issueOAuthFlow,readOAuthFlowCookie}from"@stacksjs/socials";export const SESSION_HANDOFF_PURPOSE="session-handoff";const HANDOFF_TTL_MS=60000;export async function createSessionHandoff(userId,options){if(!Number.isSafeInteger(userId)||userId<=0)throw TypeError("[auth] createSessionHandoff needs a positive integer user id.");const challenge=typeof options.bindTo==="object"?options.bindTo.challenge:void 0,issued=await issueOAuthFlow({purpose:SESSION_HANDOFF_PURPOSE,userId,data:options.data,ttlMs:HANDOFF_TTL_MS,...challenge!==void 0?{challenge}:{bindBrowser:!0},cookie:{path:options.cookiePath??"/",sameSite:"Lax"}});return{code:issued.state,cookie:issued.cookie,expiresAt:issued.expiresAt}}export async function redeemSessionHandoff(code,secret){const flow=await consumeOAuthFlow(code,{purpose:SESSION_HANDOFF_PURPOSE,browserSecret:secret});if(!flow||flow.userId===null)return null;return{userId:flow.userId,data:flow.data}}export function readSessionHandoffCookie(cookieHeader,code){return readOAuthFlowCookie(cookieHeader,code)}export function clearSessionHandoffCookie(code,cookiePath="/"){return clearOAuthFlowCookie(code,{path:cookiePath,sameSite:"Lax"})}
|
package/dist/socials.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { db } from '@stacksjs/database/runtime';
|
|
2
|
+
/** Read `users.email_verified_at` for one user, on `tx` when given. */
|
|
3
|
+
export declare function isUserEmailVerified(userId: number, tx?: SocialSignInTransaction): Promise<boolean>;
|
|
4
|
+
/** The store {@link resolveSocialSignIn} uses when none is passed. */
|
|
5
|
+
export declare function defaultSocialSignInStore(): SocialSignInStore;
|
|
1
6
|
/**
|
|
2
7
|
* Resolve a provider identity to a local user id, creating the user and/or
|
|
3
8
|
* the link row according to the configured matching policy.
|
|
@@ -5,7 +10,7 @@
|
|
|
5
10
|
* Throws {@link SocialSignInRefusedError} when the policy says no; every
|
|
6
11
|
* other failure mode (db down, etc.) throws its own error untouched.
|
|
7
12
|
*/
|
|
8
|
-
export declare function resolveSocialSignIn(provider: string, identity: SocialIdentity, store?: SocialSignInStore): Promise<SocialSignInResult>;
|
|
13
|
+
export declare function resolveSocialSignIn(provider: string, identity: SocialIdentity, store?: SocialSignInStore, options?: ResolveSocialSignInOptions): Promise<SocialSignInResult>;
|
|
9
14
|
/**
|
|
10
15
|
* The social sign-in policy: which local user a provider identity resolves to
|
|
11
16
|
* (stacksjs/stacks#2276).
|
|
@@ -20,7 +25,12 @@ export declare function resolveSocialSignIn(provider: string, identity: SocialId
|
|
|
20
25
|
*
|
|
21
26
|
* That takeover guard is therefore NOT configurable: a provider identity only
|
|
22
27
|
* links to an existing user by email when the provider explicitly vouches for
|
|
23
|
-
* the address (`emailVerified === true`)
|
|
28
|
+
* the address (`emailVerified === true`) AND the local account has verified
|
|
29
|
+
* it too (`users.email_verified_at` is set). The second half closes the
|
|
30
|
+
* mirror-image attack: an attacker registers the victim's address locally
|
|
31
|
+
* with a password they know, never verifies it, and waits for the victim's
|
|
32
|
+
* first "Sign in with Google" to link the victim's identity onto an account
|
|
33
|
+
* the attacker can still sign in to. What IS configurable is what happens
|
|
24
34
|
* when no link row exists — `config.auth.socials.matching`:
|
|
25
35
|
*
|
|
26
36
|
* - `'link'` (default): a verified-email match links to the existing user;
|
|
@@ -49,19 +59,32 @@ export declare interface SocialSignInResult {
|
|
|
49
59
|
}
|
|
50
60
|
/**
|
|
51
61
|
* Storage the policy runs against. The default store reads and writes the
|
|
52
|
-
* `social_accounts` and `users` tables; tests inject their own.
|
|
62
|
+
* `social_accounts` and `users` tables; tests inject their own. Every method
|
|
63
|
+
* receives the `tx` passed to {@link resolveSocialSignIn} (undefined when
|
|
64
|
+
* none was).
|
|
53
65
|
*/
|
|
54
66
|
export declare interface SocialSignInStore {
|
|
55
|
-
findLink: (provider: string, providerUserId: string) => Promise<{ userId: number } | undefined>
|
|
56
|
-
createLink: (link: { userId: number, provider: string, providerUserId: string, providerEmail: string | null }) => Promise<void>
|
|
57
|
-
findUserIdByEmail: (email: string) => Promise<number | undefined>
|
|
58
|
-
createUser: (attrs: { name: string, email: string }) => Promise<number>
|
|
67
|
+
findLink: (provider: string, providerUserId: string, tx?: SocialSignInTransaction) => Promise<{ userId: number } | undefined>
|
|
68
|
+
createLink: (link: { userId: number, provider: string, providerUserId: string, providerEmail: string | null }, tx?: SocialSignInTransaction) => Promise<void>
|
|
69
|
+
findUserIdByEmail: (email: string, tx?: SocialSignInTransaction) => Promise<number | undefined>
|
|
70
|
+
createUser: (attrs: { name: string, email: string, emailVerified: boolean }, tx?: SocialSignInTransaction) => Promise<number>
|
|
71
|
+
isUserEmailVerified?: (userId: number, tx?: SocialSignInTransaction) => Promise<boolean>
|
|
72
|
+
}
|
|
73
|
+
export declare interface ResolveSocialSignInOptions {
|
|
74
|
+
tx?: SocialSignInTransaction
|
|
59
75
|
}
|
|
60
76
|
export type SocialMatchingPolicy = 'link' | 'create' | 'refuse';
|
|
61
77
|
export type SocialRefusalReason = | 'no-linked-account'
|
|
62
78
|
| 'unverified-provider-email'
|
|
79
|
+
| 'unverified-local-email'
|
|
63
80
|
| 'no-email-to-create-with'
|
|
64
81
|
| 'email-already-registered';
|
|
82
|
+
/**
|
|
83
|
+
* A transaction handle (`db.transaction(async tx => ...)`), threaded through
|
|
84
|
+
* to every store method so the lookup, the user and the link commit together.
|
|
85
|
+
*/
|
|
86
|
+
export type SocialSignInTransaction = unknown;
|
|
87
|
+
declare type Queryable = typeof db;
|
|
65
88
|
/** A sign-in the policy declines. `reason` is safe to show a visitor. */
|
|
66
89
|
export declare class SocialSignInRefusedError extends Error {
|
|
67
90
|
readonly reason: SocialRefusalReason;
|
package/dist/socials.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{findAuthUserByEmail}from"./credential-user";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
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{findAuthUserByEmail}from"./credential-user";import{isEmailVerified}from"./email-verification";export class SocialSignInRefusedError extends Error{reason;constructor(reason,message){super(message);this.name="SocialSignInRefusedError";this.reason=reason}}function on(tx){return tx??db}export async function isUserEmailVerified(userId,tx){const row=await(tx?on(tx):db.primary).selectFrom("users").select(["email_verified_at"]).where("id","=",userId).executeTakeFirst();return row?isEmailVerified(row):!1}export function defaultSocialSignInStore(){return defaultStore()}function defaultStore(){return{async findLink(provider,providerUserId,tx){const row=await on(tx).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,tx){await on(tx).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,tx){if(tx){const row=await on(tx).selectFrom("users").select(["id"]).where("email","=",email).executeTakeFirst();if(row?.id!==void 0)return Number(row.id)}const user=await findAuthUserByEmail(email);return user?Number(user.id):void 0},isUserEmailVerified,async createUser(attrs,tx){const{makeHash}=await import("@stacksjs/security"),password=await makeHash(crypto.randomUUID(),{algorithm:"bcrypt"}),verifiedAt=attrs.emailVerified?sqlDateTime(new Date):null,created=tx?void 0:await globalThis.User?.create?.({name:attrs.name,email:attrs.email,password});if(created?.id){const id=Number(created.id);if(verifiedAt)await db.updateTable("users").set({email_verified_at:verifiedAt}).where("id","=",id).execute();return id}const queryable=on(tx);await queryable.insertInto("users").values({name:attrs.name,email:attrs.email,password,...verifiedAt?{email_verified_at:verifiedAt}:{},created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();const inserted=await queryable.selectFrom("users").select(["id"]).where("email","=",attrs.email).executeTakeFirst(),id=Number(inserted?.id??0);if(!Number.isSafeInteger(id)||id<=0)throw Error("[auth/socials] the new user could not be read back after insert.");return id}}}function configuredMatching(){const raw=config.auth?.socials?.matching;return raw==="create"||raw==="refuse"?raw:"link"}export async function resolveSocialSignIn(provider,identity,store=defaultStore(),options={}){const{tx}=options,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,tx);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,tx);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.`);if(await(store.isUserEmailVerified??isUserEmailVerified)(matchedUserId,tx)!==!0)throw new SocialSignInRefusedError("unverified-local-email",`An account with ${email} already exists here, but its address has not been verified. Sign in with your password and verify your email, then connect ${provider} from your account settings.`);await store.createLink({userId:matchedUserId,provider,providerUserId,providerEmail:email},tx);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,tx)!==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,emailVerified:identity.emailVerified===!0},tx);await store.createLink({userId,provider,providerUserId,providerEmail:email},tx);return{userId,createdUser:!0,linked:!0}}
|
package/dist/tokens.d.ts
CHANGED
|
@@ -239,13 +239,16 @@ export declare function revokeTokenById(tokenId: number): Promise<void>;
|
|
|
239
239
|
*/
|
|
240
240
|
export declare function revokeAllTokens(userId: number, tokenableType?: string): Promise<void>;
|
|
241
241
|
/**
|
|
242
|
-
* Revoke
|
|
242
|
+
* Revoke every token except one: `options.exceptTokenId`, or else the token
|
|
243
|
+
* the current request authenticated with. When neither resolves, every token
|
|
244
|
+
* is revoked and `keptTokenId` is null (fail closed). `Auth.revokeOtherTokens`
|
|
245
|
+
* behaves identically.
|
|
243
246
|
*
|
|
244
247
|
* @example
|
|
245
248
|
* import { revokeOtherTokens } from '@stacksjs/auth'
|
|
246
|
-
* await revokeOtherTokens(user.id)
|
|
249
|
+
* const { keptTokenId } = await revokeOtherTokens(user.id)
|
|
247
250
|
*/
|
|
248
|
-
export declare function revokeOtherTokens(userId: number, tokenableType?: string): Promise<
|
|
251
|
+
export declare function revokeOtherTokens(userId: number, tokenableType?: string, options?: { exceptTokenId?: number }): Promise<{ keptTokenId: number | null }>;
|
|
249
252
|
/**
|
|
250
253
|
* Delete expired tokens (cleanup)
|
|
251
254
|
*
|
package/dist/tokens.js
CHANGED
|
@@ -141,7 +141,7 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
|
|
|
141
141
|
`);markContextWrote();return mutationCount(result)}export async function deleteRevokedRefreshTokens(daysOld=7){const{boolTrue,param}=tokenSql(),cutoffDate=new Date;cutoffDate.setDate(cutoffDate.getDate()-daysOld);const result=await db.unsafe(`
|
|
142
142
|
DELETE FROM oauth_refresh_tokens
|
|
143
143
|
WHERE revoked = ${boolTrue} AND created_at < ${param(1)}
|
|
144
|
-
`,[sqlDateTime(cutoffDate)]);markContextWrote();return mutationCount(result)}export async function revokeToken(plainTextToken){await revokeTokenPair({hash:bearerLookupHash(plainTextToken)})}export async function revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeTokenPairs(userId,tokenableType)}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){const
|
|
144
|
+
`,[sqlDateTime(cutoffDate)]);markContextWrote();return mutationCount(result)}export async function revokeToken(plainTextToken){await revokeTokenPair({hash:bearerLookupHash(plainTextToken)})}export async function revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}export async function revokeAllTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE){await revokeTokenPairs(userId,tokenableType)}export async function revokeOtherTokens(userId,tokenableType=DEFAULT_TOKENABLE_TYPE,options={}){const keep=options.exceptTokenId??(await currentAccessToken())?.id;await revokeTokenPairs(userId,tokenableType,keep);return{keptTokenId:keep??null}}export async function deleteExpiredTokens(){const{isPostgres,isMysql,boolFalse,param}=tokenSql(),cutoff=appNow();return db.transaction(async(trx)=>{let deleted=0,cursor;while(!0){const rows=await trx.unsafe(`
|
|
145
145
|
SELECT t.id FROM oauth_access_tokens t
|
|
146
146
|
WHERE t.expires_at <= ${cutoff}
|
|
147
147
|
${cursor===void 0?"":`AND t.id > ${param(1)}`}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/auth",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.230",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -59,18 +59,19 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@stacksjs/bun-router": "^0.1.24",
|
|
62
|
-
"@stacksjs/cache": "0.75.
|
|
63
|
-
"@stacksjs/config": "0.75.
|
|
64
|
-
"@stacksjs/database": "0.75.
|
|
65
|
-
"@stacksjs/email": "0.75.
|
|
66
|
-
"@stacksjs/env": "0.75.
|
|
67
|
-
"@stacksjs/error-handling": "0.75.
|
|
68
|
-
"@stacksjs/logging": "0.75.
|
|
69
|
-
"@stacksjs/orm": "0.75.
|
|
70
|
-
"@stacksjs/path": "0.75.
|
|
71
|
-
"@stacksjs/router": "0.75.
|
|
72
|
-
"@stacksjs/security": "0.75.
|
|
73
|
-
"@stacksjs/
|
|
62
|
+
"@stacksjs/cache": "0.75.230",
|
|
63
|
+
"@stacksjs/config": "0.75.230",
|
|
64
|
+
"@stacksjs/database": "0.75.230",
|
|
65
|
+
"@stacksjs/email": "0.75.230",
|
|
66
|
+
"@stacksjs/env": "0.75.230",
|
|
67
|
+
"@stacksjs/error-handling": "0.75.230",
|
|
68
|
+
"@stacksjs/logging": "0.75.230",
|
|
69
|
+
"@stacksjs/orm": "0.75.230",
|
|
70
|
+
"@stacksjs/path": "0.75.230",
|
|
71
|
+
"@stacksjs/router": "0.75.230",
|
|
72
|
+
"@stacksjs/security": "0.75.230",
|
|
73
|
+
"@stacksjs/socials": "0.75.230",
|
|
74
|
+
"@stacksjs/storage": "0.75.230",
|
|
74
75
|
"@stacksjs/ts-auth": "^0.4.7",
|
|
75
76
|
"ts-qr-codes": "^0.1.8"
|
|
76
77
|
},
|