diff --git a/.env.example b/.env.example index 12dd2928..d387a783 100644 --- a/.env.example +++ b/.env.example @@ -40,4 +40,6 @@ PERMANENT_API_BASE_PATH=${LOCAL_TEMPORARY_AUTH_TOKEN} # See https://fusionauth.io/docs/v1/tech/apis/api-keys FUSION_AUTH_HOST=${FUSION_AUTH_HOST} FUSION_AUTH_KEY=${FUSION_AUTH_KEY} -FUSION_AUTH_APP_ID=${FUSION_AUTH_APP_ID} +FUSION_AUTH_SFTP_APP_ID=${FUSION_AUTH_SFTP_APP_ID} +FUSION_AUTH_SFTP_CLIENT_ID=${FUSION_AUTH_SFTP_CLIENT_ID} +FUSION_AUTH_SFTP_CLIENT_SECRET=${FUSION_AUTH_SFTP_CLIENT_SECRET} diff --git a/src/classes/AuthenticationSession.ts b/src/classes/AuthenticationSession.ts index 666dae14..4adcfb07 100644 --- a/src/classes/AuthenticationSession.ts +++ b/src/classes/AuthenticationSession.ts @@ -13,18 +13,22 @@ enum FusionAuthStatusCode { } export class AuthenticationSession { - public authToken = ''; + private static readonly sftpFusionAuthAppId = process.env.FUSION_AUTH_SFTP_APP_ID ?? ''; + + private static readonly sftpFusionAuthClientId = process.env.FUSION_AUTH_SFTP_CLIENT_ID ?? ''; + + private static readonly sftpFusionAuthClientSecret = process.env.FUSION_AUTH_SFTP_CLIENT_SECRET ?? ''; + + private authToken = ''; public refreshToken = ''; public readonly authContext; - private authTokenExpiresAt = 0; + private authTokenExpiresAt = new Date(); private readonly fusionAuthClient; - private readonly fusionAuthAppId = process.env.FUSION_AUTH_APP_ID ?? ''; - private twoFactorId = ''; private twoFactorMethods: TwoFactorMethod[] = []; @@ -38,30 +42,68 @@ export class AuthenticationSession { this.promptForPassword(); } - public obtainNewAuthTokenUsingRefreshToken(): void { - this.fusionAuthClient.exchangeRefreshTokenForAccessToken(this.refreshToken, '', '', '', '') - .then((clientResponse) => { - this.authToken = clientResponse.response.access_token ?? ''; - }) - .catch((clientResponse: unknown) => { - const message = isPartialClientResponse(clientResponse) - ? clientResponse.exception.message - : ''; - logger.warn(`Error obtaining refresh token : ${message}`); - this.authContext.reject(); - }); + public async getToken() { + if (this.tokenWouldExpireSoon()) { + await this.getAuthTokenUsingRefreshToken(); + } + return this.authToken; } - public tokenExpired(): boolean { - const expirationDate = new Date(this.authTokenExpiresAt); - return expirationDate <= new Date(); + private getAuthTokenUsingRefreshToken(): Promise { + return new Promise((resolve, reject) => { + if (!AuthenticationSession.sftpFusionAuthClientId) { + logger.error( + 'Cannot obtain a new access token without the sftp client ID.', + ); + reject(Error('Missing sftp client ID')); + return; + } + + if (!AuthenticationSession.sftpFusionAuthClientSecret) { + logger.error( + 'Cannot obtain a new access token without the sftp client secret.', + ); + reject(Error('Missing sftp client secret')); + return; + } + + this.fusionAuthClient + .exchangeRefreshTokenForAccessToken( + this.refreshToken, + AuthenticationSession.sftpFusionAuthClientId, + AuthenticationSession.sftpFusionAuthClientSecret, + '', + '', + ) + .then((clientResponse) => { + this.authToken = clientResponse.response.access_token ?? ''; + // The exchange refresh token for access token endpoint does not return a timestamp, + // it returns expires_in in seconds. + // So we need to create the timestamp to be consistent with what is first + // returned upon initial authentication + this.authTokenExpiresAt = new Date( + Date.now() + (clientResponse.response.expires_in ?? 1 * 1000), + ); + logger.info('New access token obtained'); + resolve(); + }) + .catch((clientResponse: unknown) => { + const message = isPartialClientResponse(clientResponse) + ? clientResponse.exception.error_description + : ''; + logger.warn(`Error obtaining refresh token: ${message}`); + this.authContext.reject(); + reject(new Error(message)); + }); + }); } - public tokenWouldExpireSoon(minutes = 5): boolean { - const expirationDate = new Date(this.authTokenExpiresAt); + private tokenWouldExpireSoon(seconds = 300): boolean { const currentTime = new Date(); - const timeDifferenceMinutes = (expirationDate.getTime() - currentTime.getTime()) / (1000 * 60); - return timeDifferenceMinutes <= minutes; + const timeDifferenceSeconds = ( + (this.authTokenExpiresAt.getTime() - currentTime.getTime()) / (1000 * 60 * 60) + ); + return timeDifferenceSeconds <= seconds; } private promptForPassword(): void { @@ -77,27 +119,40 @@ export class AuthenticationSession { } private processPasswordResponse([password]: string[]): void { + if (!AuthenticationSession.sftpFusionAuthAppId) { + logger.error('SFTP application id missing. No refresh token would be returned'); + } this.fusionAuthClient.login({ - applicationId: this.fusionAuthAppId, + applicationId: AuthenticationSession.sftpFusionAuthAppId, loginId: this.authContext.username, password, }).then((clientResponse) => { switch (clientResponse.statusCode) { - case FusionAuthStatusCode.Success: - case FusionAuthStatusCode.SuccessButUnregisteredInApp: + case FusionAuthStatusCode.Success: { if (clientResponse.response.token !== undefined) { logger.verbose('Successful password authentication attempt.', { username: this.authContext.username, }); this.authToken = clientResponse.response.token; - this.authTokenExpiresAt = clientResponse.response.tokenExpirationInstant ?? 0; + this.authTokenExpiresAt = new Date(clientResponse.response.tokenExpirationInstant ?? 0); this.refreshToken = clientResponse.response.refreshToken ?? ''; this.authContext.accept(); - return; + } else { + this.authContext.reject(); } - this.authContext.reject(); return; - case FusionAuthStatusCode.SuccessNeedsTwoFactorAuth: + } + case FusionAuthStatusCode.SuccessButUnregisteredInApp: { + const userId: string = clientResponse.response.user?.id ?? ''; + this.registerUserInApp(userId) + .then(() => { this.processPasswordResponse([password]); }) + .catch((error) => { + logger.warn('Error during registration and authentication:', error); + this.authContext.reject(); + }); + return; + } + case FusionAuthStatusCode.SuccessNeedsTwoFactorAuth: { if (clientResponse.response.twoFactorId !== undefined) { logger.verbose('Successful password authentication attempt; MFA required.', { username: this.authContext.username, @@ -105,16 +160,18 @@ export class AuthenticationSession { this.twoFactorId = clientResponse.response.twoFactorId; this.twoFactorMethods = clientResponse.response.methods ?? []; this.promptForTwoFactorMethod(); - return; + } else { + this.authContext.reject(); } - this.authContext.reject(); return; - default: + } + default: { logger.verbose('Failed password authentication attempt.', { username: this.authContext.username, response: clientResponse.response, }); this.authContext.reject(); + } } }).catch((clientResponse: unknown) => { const message = isPartialClientResponse(clientResponse) @@ -125,6 +182,29 @@ export class AuthenticationSession { }); } + private async registerUserInApp(userId: string): Promise { + return this.fusionAuthClient.register(userId, { + registration: { + applicationId: AuthenticationSession.sftpFusionAuthAppId, + }, + }).then((clientResponse) => { + switch (clientResponse.statusCode) { + case FusionAuthStatusCode.Success: + logger.verbose('User registered successfully after authentication.', { + userId, + }); + break; + default: + logger.verbose('User registration after authentication failed.', { + userId, + response: clientResponse.response, + }); + } + }).catch((error) => { + logger.warn('Error during user registration after authentication:', error); + }); + } + private promptForTwoFactorMethod(): void { const promptOptions = this.twoFactorMethods.map( (method, index) => `[${index + 1}] ${method.method ?? ''}`, diff --git a/src/classes/SftpSessionHandler.ts b/src/classes/SftpSessionHandler.ts index d86d16d5..c6693b3c 100644 --- a/src/classes/SftpSessionHandler.ts +++ b/src/classes/SftpSessionHandler.ts @@ -1070,16 +1070,10 @@ export class SftpSessionHandler { } private getCurrentPermanentFileSystem(): PermanentFileSystem { - if ( - this.authenticationSession.tokenExpired() - || this.authenticationSession.tokenWouldExpireSoon() - ) { - this.authenticationSession.obtainNewAuthTokenUsingRefreshToken(); - } return this.permanentFileSystemManager .getCurrentPermanentFileSystemForUser( this.authenticationSession.authContext.username, - this.authenticationSession.authToken, + this.authenticationSession.getToken(), ); } } diff --git a/src/fusionAuth.ts b/src/fusionAuth.ts index ca710ee3..ec29dfa5 100644 --- a/src/fusionAuth.ts +++ b/src/fusionAuth.ts @@ -8,6 +8,8 @@ export const getFusionAuthClient = (): FusionAuthClient => new FusionAuthClient( export interface PartialClientResponse { exception: { message: string; + error?: string; + error_description?: string; }; }