@stacksjs/auth 0.75.349 → 0.75.360
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 +3 -0
- package/dist/authentication.js +2 -2
- package/dist/impersonation-auth.d.ts +5 -0
- package/dist/impersonation-auth.js +1 -0
- package/dist/impersonation-store.d.ts +9 -0
- package/dist/impersonation-store.js +16 -0
- package/dist/impersonation.d.ts +46 -0
- package/dist/impersonation.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/package.json +14 -14
package/dist/authentication.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requestToken } from './request-token';
|
|
2
2
|
import { User } from '@stacksjs/orm';
|
|
3
3
|
import type { AuthCredentials, AuthToken, NewAccessToken, PersonalAccessToken, TokenCreateOptions } from '@stacksjs/types';
|
|
4
|
+
import type { ImpersonationGrant } from './impersonation';
|
|
4
5
|
declare type UserModel = NonNullable<Awaited<ReturnType<typeof User.find>>>;
|
|
5
6
|
export declare class Auth {
|
|
6
7
|
static attempt(credentials: AuthCredentials): Promise<boolean>;
|
|
@@ -24,6 +25,8 @@ export declare class Auth {
|
|
|
24
25
|
static requestToken(credentials: AuthCredentials, clientId: number, clientSecret: string): Promise<{ token: AuthToken } | null>;
|
|
25
26
|
static validateToken(token: string): Promise<boolean>;
|
|
26
27
|
static getUserFromToken(token: string): Promise<UserModel | undefined>;
|
|
28
|
+
static startImpersonation(subjectId: number, reason: string): Promise<ImpersonationGrant>;
|
|
29
|
+
static endImpersonation(credential: string): Promise<void>;
|
|
27
30
|
static currentAccessToken(): Promise<PersonalAccessToken | undefined>;
|
|
28
31
|
static tokenCan(ability: string): Promise<boolean>;
|
|
29
32
|
static tokenCant(ability: string): Promise<boolean>;
|
package/dist/authentication.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout,sessionUser}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at==null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,getPasswordChangedAt,getTokenOwnerCredentialState,isIssuedBeforePasswordChange,parseScopes}from"./tokens";async function ordinaryAuthorizationIntact(tokenId){const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout,sessionUser}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";import{IMPERSONATION_HEADER}from"./impersonation";import{authenticatedImpersonationService,resolveRequestImpersonation}from"./impersonation-auth";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at==null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,getPasswordChangedAt,getTokenOwnerCredentialState,isIssuedBeforePasswordChange,parseScopes}from"./tokens";async function ordinaryAuthorizationIntact(tokenId){const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
2
2
|
SELECT t.id FROM oauth_access_tokens t
|
|
3
3
|
LEFT JOIN oauth_clients c ON c.id = t.oauth_client_id
|
|
4
4
|
WHERE t.id = ${sql.param(1)}
|
|
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,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}}}
|
|
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});const currentRequest=getCurrentRequest();if(currentRequest?.headers.has(IMPERSONATION_HEADER)){if(!parseScopes(accessToken.scopes).includes("*"))return;if(requestToken(currentRequest)!==token)return;const session=await resolveRequestImpersonation(Number(user.id),String(accessToken.id));if(!session)return;return await User.find(session.subjectId)??void 0}return user}static async startImpersonation(subjectId,reason){const currentRequest=getCurrentRequest();if(!currentRequest||currentRequest.headers.has(IMPERSONATION_HEADER))throw new HttpError(403,"Impersonation is not authorized.");const bearer=requestToken(currentRequest),actor=bearer?await this.getUserFromToken(bearer):void 0,token=await this.currentAccessToken();if(!actor||!token)throw new HttpError(401,"Unauthenticated.");if(!token.abilities.includes("*"))throw new HttpError(403,"A full administrator session is required.");return authenticatedImpersonationService(Number(actor.id),String(token.id)).start(subjectId,reason)}static async endImpersonation(credential){const currentRequest=getCurrentRequest();if(!currentRequest||currentRequest.headers.has(IMPERSONATION_HEADER))throw new HttpError(403,"Use the original administrator session to exit support viewing.");const bearer=requestToken(currentRequest),actor=bearer?await this.getUserFromToken(bearer):void 0,token=await this.currentAccessToken();if(!actor||!token)throw new HttpError(401,"Unauthenticated.");await authenticatedImpersonationService(Number(actor.id),String(token.id)).end(credential)}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}}}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ImpersonationSession } from './impersonation';
|
|
2
|
+
export declare function requestImpersonation(): ImpersonationSession | null;
|
|
3
|
+
/** The actor and session here have already passed ordinary token validation. */
|
|
4
|
+
export declare function authenticatedImpersonationService(actorId: number, actorSessionId: string): unknown;
|
|
5
|
+
export declare function resolveRequestImpersonation(actorId: number, actorSessionId: string): Promise<ImpersonationSession | null>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{config}from"@stacksjs/config";import{getCurrentRequest}from"@stacksjs/router";import{createImpersonationService,IMPERSONATION_HEADER}from"./impersonation";import{createDatabaseImpersonationStore}from"./impersonation-store";import{hasFreshRole}from"./rbac";const CONTEXT=Symbol.for("stacks.auth.impersonation");export function requestImpersonation(){return getCurrentRequest()?.[CONTEXT]??null}export function authenticatedImpersonationService(actorId,actorSessionId){return createImpersonationService(createDatabaseImpersonationStore(),{async currentActor(){const request=getCurrentRequest();if(!request||request.headers.has(IMPERSONATION_HEADER))return null;return{userId:actorId,sessionId:actorSessionId,impersonating:!1}},async mayImpersonate(expectedActor,expectedSession,subjectId){const policy=config.auth.impersonation;return expectedActor===actorId&&expectedSession===actorSessionId&&policy?.enabled===!0&&typeof policy.eligible==="function"&&await hasFreshRole(actorId,"super-admin","platform")&&await policy.eligible(actorId)&&await policy.eligible(subjectId)},async workspaceId(subjectId){const request=getCurrentRequest(),resolve=config.auth.impersonation?.workspaceId;return request&&resolve?await resolve(request,subjectId):null}})}export async function resolveRequestImpersonation(actorId,actorSessionId){const request=getCurrentRequest();if(!request)return null;const credential=request.headers.get(IMPERSONATION_HEADER);if(!credential)return null;const session=await authenticatedImpersonationService(actorId,actorSessionId).resolve(credential,{method:request.method,path:new URL(request.url).pathname});if(session)request[CONTEXT]=session;return session}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ImpersonationStore } from './impersonation';
|
|
2
|
+
/** Record a platform operation before releasing protected data. Errors propagate. */
|
|
3
|
+
export declare function recordPrivilegedAccess(actorId: number, action: string, subjectId?: number | null): Promise<void>;
|
|
4
|
+
/** Explicit installation step, never invoked during an authentication request. */
|
|
5
|
+
export declare function migrateImpersonationTables(): Promise<void>;
|
|
6
|
+
/** Framework migration hook for applications opting into support viewing. */
|
|
7
|
+
export declare function migrateEnabledImpersonationTables(): Promise<void>;
|
|
8
|
+
/** Database transactions make session transitions and their audits inseparable. */
|
|
9
|
+
export declare function createDatabaseImpersonationStore(): ImpersonationStore;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import{db,getDatabaseDialect,markContextWrote,sqlHelpers}from"@stacksjs/database/runtime";import{config}from"@stacksjs/config";export async function recordPrivilegedAccess(actorId,action,subjectId=null){if(!Number.isSafeInteger(actorId)||actorId<=0||!/^[a-z][a-z0-9.:-]{1,63}$/.test(action)||subjectId!==null&&(!Number.isSafeInteger(subjectId)||subjectId<=0))throw TypeError("Invalid privileged access audit.");const values=sqlHelpers(getDatabaseDialect()).params(crypto.randomUUID(),actorId,subjectId,action,Date.now(),"{}");await db.unsafe(`INSERT INTO auth_privileged_audits (id, actor_id, subject_id, event, occurred_at, details)
|
|
2
|
+
VALUES (${values.sql})`,values.values).execute();markContextWrote()}function fromRow(row){return{id:String(row.id),actorId:Number(row.actor_id),actorSessionId:String(row.actor_session_id),subjectId:Number(row.subject_id),reason:String(row.reason),expiresAt:Number(row.expires_at),endedAt:row.ended_at==null?null:Number(row.ended_at)}}export async function migrateImpersonationTables(){await db.unsafe(`CREATE TABLE IF NOT EXISTS auth_impersonations (
|
|
3
|
+
id VARCHAR(36) PRIMARY KEY, credential_hash VARCHAR(64) NOT NULL UNIQUE,
|
|
4
|
+
actor_id BIGINT NOT NULL, actor_session_id VARCHAR(255) NOT NULL,
|
|
5
|
+
subject_id BIGINT NOT NULL, reason VARCHAR(500) NOT NULL,
|
|
6
|
+
expires_at BIGINT NOT NULL, ended_at BIGINT
|
|
7
|
+
)`).execute();await db.unsafe(`CREATE TABLE IF NOT EXISTS auth_privileged_audits (
|
|
8
|
+
id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36),
|
|
9
|
+
actor_id BIGINT NOT NULL, subject_id BIGINT, event VARCHAR(64) NOT NULL,
|
|
10
|
+
occurred_at BIGINT NOT NULL, details TEXT NOT NULL
|
|
11
|
+
)`).execute()}export async function migrateEnabledImpersonationTables(){if(config.auth.impersonation?.enabled)await migrateImpersonationTables()}export function createDatabaseImpersonationStore(){const sql=()=>sqlHelpers(getDatabaseDialect());async function audit(tx,session,event,now,details){const values=sql().params(crypto.randomUUID(),session.id,session.actorId,session.subjectId,event,now,JSON.stringify(details));await tx.unsafe(`INSERT INTO auth_privileged_audits
|
|
12
|
+
(id, session_id, actor_id, subject_id, event, occurred_at, details) VALUES (${values.sql})`,values.values)}async function locked(tx,id){const helpers=sql(),rows=await tx.unsafe(`SELECT id, actor_id, actor_session_id, subject_id, reason, expires_at, ended_at
|
|
13
|
+
FROM auth_impersonations WHERE id = ${helpers.param(1)}${helpers.isPostgres||helpers.isMysql?" FOR UPDATE":""}`,[id]);return rows[0]?fromRow(rows[0]):null}return{async create(session,credentialHash){await db.transaction(async(raw)=>{const tx=raw,values=sql().params(session.id,credentialHash,session.actorId,session.actorSessionId,session.subjectId,session.reason,session.expiresAt);await tx.unsafe(`INSERT INTO auth_impersonations
|
|
14
|
+
(id, credential_hash, actor_id, actor_session_id, subject_id, reason, expires_at)
|
|
15
|
+
VALUES (${values.sql})`,values.values);await audit(tx,session,"started",Date.now(),{reason:session.reason})});markContextWrote()},async find(hash){const rows=await db.primary.unsafe(`SELECT id, actor_id, actor_session_id, subject_id, reason, expires_at, ended_at
|
|
16
|
+
FROM auth_impersonations WHERE credential_hash = ${sql().param(1)}`,[hash]);return rows[0]?fromRow(rows[0]):null},async acceptAccess(id,access,now){const accepted=await db.transaction(async(raw)=>{const tx=raw,session=await locked(tx,id),acceptedAt=Math.max(now,Date.now());if(!session||session.endedAt!==null||session.expiresAt<=acceptedAt)return!1;await audit(tx,session,"accessed",acceptedAt,access);return!0});if(accepted)markContextWrote();return accepted},async end(id,actorId,now){await db.transaction(async(raw)=>{const tx=raw,session=await locked(tx,id);if(!session||session.actorId!==actorId||session.endedAt!==null)return;const values=sql().params(now,id);await tx.unsafe(`UPDATE auth_impersonations SET ended_at = ${sql().param(1)} WHERE id = ${sql().param(2)}`,values.values);await audit(tx,session,"ended",now,{})});markContextWrote()}}}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
declare function credentialHash(value: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Lifecycle for separately revocable support-view credentials. This service
|
|
4
|
+
* never issues a subject's ordinary login token and never returns the actor's
|
|
5
|
+
* original credential. The host must resolve it at its authentication boundary.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createImpersonationService(store: ImpersonationStore, authority: ImpersonationAuthority): {
|
|
8
|
+
start: (subjectId: number, reason: string) => Promise<ImpersonationGrant>;
|
|
9
|
+
resolve: (credential: string, access: ImpersonationAccess) => Promise<ImpersonationSession | null>;
|
|
10
|
+
end: (credential: string) => Promise<void>
|
|
11
|
+
};
|
|
12
|
+
export declare const IMPERSONATION_HEADER: 'x-stacks-impersonation';
|
|
13
|
+
export declare interface ImpersonationSession {
|
|
14
|
+
id: string
|
|
15
|
+
actorId: number
|
|
16
|
+
actorSessionId: string
|
|
17
|
+
subjectId: number
|
|
18
|
+
reason: string
|
|
19
|
+
expiresAt: number
|
|
20
|
+
endedAt: number | null
|
|
21
|
+
}
|
|
22
|
+
export declare interface ImpersonationGrant {
|
|
23
|
+
credential: string
|
|
24
|
+
session: ImpersonationSession
|
|
25
|
+
}
|
|
26
|
+
export declare interface ImpersonationAccess {
|
|
27
|
+
method: string
|
|
28
|
+
path: string
|
|
29
|
+
workspaceId?: string
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Persistence must commit session changes and their audit events atomically.
|
|
33
|
+
* acceptAccess must recheck endedAt and expiresAt in the same transaction as
|
|
34
|
+
* its audit insert; a stale read must never resurrect a revoked session.
|
|
35
|
+
*/
|
|
36
|
+
export declare interface ImpersonationStore {
|
|
37
|
+
create(session: ImpersonationSession, credentialHash: string): Promise<void>
|
|
38
|
+
find(credentialHash: string): Promise<ImpersonationSession | null>
|
|
39
|
+
acceptAccess(sessionId: string, access: ImpersonationAccess, now: number): Promise<boolean>
|
|
40
|
+
end(sessionId: string, actorId: number, now: number): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
export declare interface ImpersonationAuthority {
|
|
43
|
+
currentActor(): Promise<{ userId: number, sessionId: string, impersonating: boolean } | null>
|
|
44
|
+
mayImpersonate(actorId: number, actorSessionId: string, subjectId: number): Promise<boolean>
|
|
45
|
+
workspaceId?: (subjectId: number) => string | number | null | Promise<string | number | null>
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";export const IMPERSONATION_HEADER="x-stacks-impersonation";const PREFIX="stacks_imp_",CREDENTIAL=/^stacks_imp_[a-f0-9]{80}$/;function credentialHash(value){return createHash("sha256").update(value).digest("hex")}function validId(id){return Number.isSafeInteger(id)&&id>0}export function createImpersonationService(store,authority){return{async start(subjectId,reason){const actor=await authority.currentActor();if(!actor||actor.impersonating||!validId(actor.userId)||!actor.sessionId||!validId(subjectId)||actor.userId===subjectId)throw Error("Impersonation is not authorized.");const normalizedReason=reason.trim();if(normalizedReason.length<10||normalizedReason.length>500)throw TypeError("Provide a reason between 10 and 500 characters.");if(!await authority.mayImpersonate(actor.userId,actor.sessionId,subjectId))throw Error("Impersonation is not authorized.");const credential=`${PREFIX}${randomBytes(40).toString("hex")}`,session={id:crypto.randomUUID(),actorId:actor.userId,actorSessionId:actor.sessionId,subjectId,reason:normalizedReason,expiresAt:Date.now()+900000,endedAt:null};await store.create(session,credentialHash(credential));return{credential,session}},async resolve(credential,access){if(!CREDENTIAL.test(credential))return null;if(access.method!=="GET"||!/^\/(?!\/)[^?#\r\n]*$/.test(access.path))return null;const session=await store.find(credentialHash(credential));if(!session||!validId(session.actorId)||!validId(session.subjectId)||session.actorId===session.subjectId||session.endedAt!==null||!Number.isFinite(session.expiresAt)||session.expiresAt<=Date.now())return null;if(!await authority.mayImpersonate(session.actorId,session.actorSessionId,session.subjectId))return null;const workspace=await authority.workspaceId?.(session.subjectId),workspaceId=workspace==null?void 0:String(workspace);if(workspaceId!==void 0&&!/^[A-Za-z0-9._:-]{1,128}$/.test(workspaceId))throw TypeError("Invalid support-view workspace identifier.");if(!await store.acceptAccess(session.id,{method:access.method,path:access.path,...workspaceId===void 0?{}:{workspaceId}},Date.now()))return null;return session},async end(credential){if(!CREDENTIAL.test(credential))return;const actor=await authority.currentActor();if(!actor||actor.impersonating)throw Error("Impersonation is not authorized.");const session=await store.find(credentialHash(credential));if(!session||session.actorId!==actor.userId||session.actorSessionId!==actor.sessionId)throw Error("Impersonation is not authorized.");await store.end(session.id,actor.userId,Date.now())}}}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export type { SeedDefaultRolesResult } from './rbac-seed';
|
|
2
2
|
export * from './authentication';
|
|
3
|
+
export * from './impersonation';
|
|
4
|
+
export { requestImpersonation } from './impersonation-auth';
|
|
5
|
+
export * from './impersonation-store';
|
|
3
6
|
export * from './authenticator';
|
|
4
7
|
export * from './client';
|
|
5
8
|
export * from './credential-user';
|
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*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";
|
|
1
|
+
export*from"./authentication";export*from"./impersonation";export{requestImpersonation}from"./impersonation-auth";export*from"./impersonation-store";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";
|
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.360",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -59,19 +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/socials": "0.75.
|
|
74
|
-
"@stacksjs/storage": "0.75.
|
|
62
|
+
"@stacksjs/cache": "0.75.360",
|
|
63
|
+
"@stacksjs/config": "0.75.360",
|
|
64
|
+
"@stacksjs/database": "0.75.360",
|
|
65
|
+
"@stacksjs/email": "0.75.360",
|
|
66
|
+
"@stacksjs/env": "0.75.360",
|
|
67
|
+
"@stacksjs/error-handling": "0.75.360",
|
|
68
|
+
"@stacksjs/logging": "0.75.360",
|
|
69
|
+
"@stacksjs/orm": "0.75.360",
|
|
70
|
+
"@stacksjs/path": "0.75.360",
|
|
71
|
+
"@stacksjs/router": "0.75.360",
|
|
72
|
+
"@stacksjs/security": "0.75.360",
|
|
73
|
+
"@stacksjs/socials": "0.75.360",
|
|
74
|
+
"@stacksjs/storage": "0.75.360",
|
|
75
75
|
"@stacksjs/ts-auth": "^0.4.7",
|
|
76
76
|
"ts-qr-codes": "^0.1.8"
|
|
77
77
|
},
|