From a7f797dbe8a7056f8758ba888a5b1f7df8d60164 Mon Sep 17 00:00:00 2001 From: Dan Schultz Date: Tue, 7 Nov 2023 15:58:24 -0500 Subject: [PATCH] Move token management to dedicated class We had organically coupled token management with the initial authentication flow, but they don't actually belong together. This separates token management (e.g. utilization of refresh tokens) from the SSH authentication system. It also refactors the sftp session handler to use the token manager rather than the authentication session. Finally, the tokens are now retrieved just-in-time by the permanent file system (rather than being passed during the creation of the permanent file system). This is a critical fix because (1) it prevents certain paths that would lead to stale tokens but also (2) it means that creating a permanent file system becomes a synchronous operation. This also resolves a bug where the failure to generate a token could result in a hanging sftp connection. While doing these refactors we took out a redundant environment variable. Issue #288 Permanent file system errors can result in hung connections Issue #289 Duplicated FusionAuth env vars --- .env.example | 1 - src/classes/AuthTokenManager.ts | 99 +++ src/classes/AuthenticationSession.ts | 116 +--- src/classes/PermanentFileSystem.ts | 34 +- src/classes/PermanentFileSystemManager.ts | 5 +- src/classes/SftpSessionHandler.ts | 737 ++++++++++------------ src/classes/SshConnectionHandler.ts | 22 +- src/classes/SshSessionHandler.ts | 10 +- src/server.ts | 3 - 9 files changed, 493 insertions(+), 534 deletions(-) create mode 100644 src/classes/AuthTokenManager.ts diff --git a/.env.example b/.env.example index d387a783..739d99db 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,5 @@ 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_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/AuthTokenManager.ts b/src/classes/AuthTokenManager.ts new file mode 100644 index 00000000..1ed16fee --- /dev/null +++ b/src/classes/AuthTokenManager.ts @@ -0,0 +1,99 @@ +import { logger } from '../logger'; +import { AuthTokenRefreshError } from '../errors/AuthTokenRefreshError'; +import { + getFusionAuthClient, + isPartialClientResponse, +} from '../fusionAuth'; + +export class AuthTokenManager { + public readonly username: string; + + private readonly fusionAuthClient; + + private refreshToken = ''; + + private authToken = ''; + + private authTokenExpiresAt = new Date(); + + private fusionAuthClientId = ''; + + private fusionAuthClientSecret = ''; + + public constructor( + username: string, + refreshToken: string, + fusionAuthClientId: string, + fusionAuthClientSecret: string, + ) { + this.username = username; + this.refreshToken = refreshToken; + this.fusionAuthClientId = fusionAuthClientId; + this.fusionAuthClientSecret = fusionAuthClientSecret; + this.fusionAuthClient = getFusionAuthClient(); + } + + public async getAuthToken() { + if (this.tokenWouldExpireSoon()) { + await this.resetAuthTokenUsingRefreshToken(); + } + return this.authToken; + } + + private async resetAuthTokenUsingRefreshToken(): Promise { + let clientResponse; + try { + /** + * Fusion auth sdk wrongly mandates last two params (scope, user_code) + * hence the need to pass two empty strings here. + * See: https://github.com/FusionAuth/fusionauth-typescript-client/issues/42 + */ + clientResponse = await this.fusionAuthClient.exchangeRefreshTokenForAccessToken( + this.refreshToken, + this.fusionAuthClientId, + this.fusionAuthClientSecret, + '', + '', + ); + } catch (error: unknown) { + let message: string; + if (isPartialClientResponse(error)) { + message = error.exception.error_description ?? error.exception.message; + } else { + message = error instanceof Error ? error.message : JSON.stringify(error); + } + logger.verbose(`Error obtaining refresh token: ${message}`); + throw new AuthTokenRefreshError(`Error obtaining refresh token: ${message}`); + } + + if (!clientResponse.response.access_token) { + logger.warn('No access token in response:', clientResponse.response); + throw new AuthTokenRefreshError('Response does not contain access_token'); + } + + if (!clientResponse.response.expires_in) { + logger.warn('Response lacks token TTL (expires_in):', clientResponse.response); + throw new AuthTokenRefreshError('Response lacks token TTL (expires_in)'); + } + + /** + * 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.authToken = clientResponse.response.access_token; + this.authTokenExpiresAt = new Date( + Date.now() + (clientResponse.response.expires_in * 1000), + ); + logger.debug('New access token obtained:', clientResponse.response); + } + + private tokenWouldExpireSoon(expirationThresholdInSeconds = 300): boolean { + const currentTime = new Date(); + const remainingTokenLife = ( + (this.authTokenExpiresAt.getTime() - currentTime.getTime()) / 1000 + ); + return remainingTokenLife <= expirationThresholdInSeconds; + } +} diff --git a/src/classes/AuthenticationSession.ts b/src/classes/AuthenticationSession.ts index aae6e35f..1a7378c2 100644 --- a/src/classes/AuthenticationSession.ts +++ b/src/classes/AuthenticationSession.ts @@ -3,7 +3,6 @@ import { getFusionAuthClient, isPartialClientResponse, } from '../fusionAuth'; -import { AuthTokenRefreshError } from '../errors/AuthTokenRefreshError'; import type { KeyboardAuthContext } from 'ssh2'; import type { TwoFactorMethod } from '@fusionauth/typescript-client'; @@ -13,108 +12,36 @@ enum FusionAuthStatusCode { SuccessNeedsTwoFactorAuth = 242, } -export class AuthenticationSession { - private authToken = ''; - - public refreshToken = ''; - - public readonly authContext; +type SuccessHandler = (refreshToken: string) => void; - private authTokenExpiresAt = new Date(); +export class AuthenticationSession { + private readonly authContext; private readonly fusionAuthClient; + private readonly successHandler: SuccessHandler; + private twoFactorId = ''; private twoFactorMethods: TwoFactorMethod[] = []; - private fusionAuthAppId = ''; - private fusionAuthClientId = ''; - private fusionAuthClientSecret = ''; - public constructor( authContext: KeyboardAuthContext, - fusionAuthAppId: string, fusionAuthClientId: string, - fusionAuthClientSecret: string, + successHandler: SuccessHandler, ) { this.authContext = authContext; - this.fusionAuthAppId = fusionAuthAppId; this.fusionAuthClientId = fusionAuthClientId; - this.fusionAuthClientSecret = fusionAuthClientSecret; this.fusionAuthClient = getFusionAuthClient(); + this.successHandler = successHandler; } public invokeAuthenticationFlow(): void { this.promptForPassword(); } - public async getAuthToken() { - if (this.tokenWouldExpireSoon()) { - await this.getAuthTokenUsingRefreshToken(); - } - return this.authToken; - } - - private async getAuthTokenUsingRefreshToken(): Promise { - let clientResponse; - try { - /** - * Fusion auth sdk wrongly mandates last two params (scope, user_code) - * hence the need to pass two empty strings here. - * See: https://github.com/FusionAuth/fusionauth-typescript-client/issues/42 - */ - clientResponse = await this.fusionAuthClient.exchangeRefreshTokenForAccessToken( - this.refreshToken, - this.fusionAuthClientId, - this.fusionAuthClientSecret, - '', - '', - ); - } catch (error: unknown) { - let message: string; - if (isPartialClientResponse(error)) { - message = error.exception.message; - } else { - message = error instanceof Error ? error.message : JSON.stringify(error); - } - logger.verbose(`Error obtaining refresh token: ${message}`); - throw new AuthTokenRefreshError(`Error obtaining refresh token: ${message}`); - } - - if (!clientResponse.response.access_token) { - logger.warn('No access token in response:', clientResponse.response); - throw new AuthTokenRefreshError('Response does not contain access_token'); - } - - if (!clientResponse.response.expires_in) { - logger.warn('Response lacks token TTL (expires_in):', clientResponse.response); - throw new AuthTokenRefreshError('Response lacks token TTL (expires_in)'); - } - - /** - * 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.authToken = clientResponse.response.access_token; - this.authTokenExpiresAt = new Date( - Date.now() + (clientResponse.response.expires_in * 1000), - ); - logger.debug('New access token obtained:', clientResponse.response); - } - - private tokenWouldExpireSoon(expirationThresholdInSeconds = 300): boolean { - const currentTime = new Date(); - const remainingTokenLife = ( - (this.authTokenExpiresAt.getTime() - currentTime.getTime()) / 1000 - ); - return remainingTokenLife <= expirationThresholdInSeconds; - } - private promptForPassword(): void { this.authContext.prompt( { @@ -129,29 +56,20 @@ export class AuthenticationSession { private processPasswordResponse([password]: string[]): void { this.fusionAuthClient.login({ - applicationId: this.fusionAuthAppId, + applicationId: this.fusionAuthClientId, loginId: this.authContext.username, password, }).then((clientResponse) => { switch (clientResponse.statusCode) { case FusionAuthStatusCode.Success: { - if (clientResponse.response.token !== undefined) { - logger.verbose('Successful password authentication attempt.', { - username: this.authContext.username, - }); - this.authToken = clientResponse.response.token; - if (clientResponse.response.refreshToken) { - this.refreshToken = clientResponse.response.refreshToken; - this.authTokenExpiresAt = new Date( - clientResponse.response.tokenExpirationInstant ?? 0, - ); - } else { - logger.warn('No refresh token in response :', clientResponse.response); - this.authContext.reject(); - } + logger.verbose('Successful password authentication attempt.', { + username: this.authContext.username, + }); + if (clientResponse.response.refreshToken) { + this.successHandler(clientResponse.response.refreshToken); this.authContext.accept(); } else { - logger.warn('No auth token in response', clientResponse.response); + logger.warn('No refresh token in response :', clientResponse.response); this.authContext.reject(); } return; @@ -159,7 +77,9 @@ export class AuthenticationSession { case FusionAuthStatusCode.SuccessButUnregisteredInApp: { const userId: string = clientResponse.response.user?.id ?? ''; this.registerUserInApp(userId) - .then(() => { this.processPasswordResponse([password]); }) + .then(() => { + this.processPasswordResponse([password]); + }) .catch((error) => { logger.warn('Error during registration and authentication:', error); this.authContext.reject(); @@ -203,7 +123,7 @@ export class AuthenticationSession { try { const clientResponse = await this.fusionAuthClient.register(userId, { registration: { - applicationId: this.fusionAuthAppId, + applicationId: this.fusionAuthClientId, }, }); diff --git a/src/classes/PermanentFileSystem.ts b/src/classes/PermanentFileSystem.ts index 01ef2c52..0b6dc068 100644 --- a/src/classes/PermanentFileSystem.ts +++ b/src/classes/PermanentFileSystem.ts @@ -43,6 +43,7 @@ import type { Attributes, FileEntry, } from 'ssh2'; +import type { AuthTokenManager } from './AuthTokenManager'; const isRootPath = (requestedPath: string): boolean => ( requestedPath === '/' @@ -79,10 +80,10 @@ export class PermanentFileSystem { private archivesCache?: Archive[]; - private readonly authToken; + private readonly authTokenManager; - public constructor(authToken: string) { - this.authToken = authToken; + public constructor(authTokenManager: AuthTokenManager) { + this.authTokenManager = authTokenManager; } private static loadRootFileEntries(): FileEntry[] { @@ -191,7 +192,7 @@ export class PermanentFileSystem { const childName = path.basename(requestedPath); const parentFolder = await this.loadFolder(parentPath); return createFolder( - this.getClientConfiguration(), + await this.getClientConfiguration(), { name: childName, }, @@ -201,7 +202,7 @@ export class PermanentFileSystem { public async deleteDirectory(requestedPath: string): Promise { const account = await getAuthenticatedAccount( - this.getClientConfiguration(), + await this.getClientConfiguration(), ); if (!account.isSftpDeletionEnabled) { throw new OperationNotAllowedError('You must enable SFTP deletion directly in your account settings.'); @@ -220,7 +221,7 @@ export class PermanentFileSystem { const folder = await this.loadFolder(requestedPath); await deleteFolder( - this.getClientConfiguration(), + await this.getClientConfiguration(), folder.id, ); } @@ -242,14 +243,14 @@ export class PermanentFileSystem { fileSystemCompatibleName: archiveRecordName, }; const s3Url = await uploadFile( - this.getClientConfiguration(), + await this.getClientConfiguration(), dataStream, fileFragment, archiveRecordfragment, parentFolder, ); await createArchiveRecord( - this.getClientConfiguration(), + await this.getClientConfiguration(), s3Url, fileFragment, archiveRecordfragment, @@ -259,7 +260,7 @@ export class PermanentFileSystem { public async deleteFile(requestedPath: string): Promise { const account = await getAuthenticatedAccount( - this.getClientConfiguration(), + await this.getClientConfiguration(), ); if (!account.isSftpDeletionEnabled) { throw new OperationNotAllowedError('You must enable SFTP deletion directly in your account settings.'); @@ -274,7 +275,7 @@ export class PermanentFileSystem { ); await deleteArchiveRecord( - this.getClientConfiguration(), + await this.getClientConfiguration(), archiveRecord.id, ); } @@ -338,7 +339,7 @@ export class PermanentFileSystem { childName, ); const populatedArchiveRecord = await getArchiveRecord( - this.getClientConfiguration(), + await this.getClientConfiguration(), archiveRecord.id, archiveId, ); @@ -380,9 +381,10 @@ export class PermanentFileSystem { return this.loadArchiveRecords(archiveRecordPaths); } - private getClientConfiguration(): ClientConfiguration { + private async getClientConfiguration(): Promise { + const authToken = await this.authTokenManager.getAuthToken(); return { - bearerToken: this.authToken, + bearerToken: authToken, baseUrl: process.env.PERMANENT_API_BASE_PATH, }; } @@ -390,7 +392,7 @@ export class PermanentFileSystem { private async loadArchives(): Promise { if (!this.archivesCache) { this.archivesCache = await getArchives( - this.getClientConfiguration(), + await this.getClientConfiguration(), ); } return this.archivesCache; @@ -417,7 +419,7 @@ export class PermanentFileSystem { return cachedArchiveFolders; } const archiveFolders = await getArchiveFolders( - this.getClientConfiguration(), + await this.getClientConfiguration(), archiveId, ); this.archiveFoldersCache.set(archiveId, archiveFolders); @@ -506,7 +508,7 @@ export class PermanentFileSystem { childName, ); const populatedTargetFolder = await getFolder( - this.getClientConfiguration(), + await this.getClientConfiguration(), targetFolder.id, archiveId, ); diff --git a/src/classes/PermanentFileSystemManager.ts b/src/classes/PermanentFileSystemManager.ts index e9415e17..b3d6f86a 100644 --- a/src/classes/PermanentFileSystemManager.ts +++ b/src/classes/PermanentFileSystemManager.ts @@ -1,5 +1,6 @@ import { logger } from '../logger'; import { PermanentFileSystem } from './PermanentFileSystem'; +import type { AuthTokenManager } from './AuthTokenManager'; export class PermanentFileSystemManager { private readonly permanentFileSystems = new Map(); @@ -8,7 +9,7 @@ export class PermanentFileSystemManager { public getCurrentPermanentFileSystemForUser( user: string, - authToken: string, + authTokenManager: AuthTokenManager, ): PermanentFileSystem { logger.silly('Get permanent file system for user', { user }); this.resetDeletionTimeout(user); @@ -16,7 +17,7 @@ export class PermanentFileSystemManager { if (existingFileSystem !== undefined) { return existingFileSystem; } - const permanentFileSystem = new PermanentFileSystem(authToken); + const permanentFileSystem = new PermanentFileSystem(authTokenManager); this.permanentFileSystems.set( user, permanentFileSystem, diff --git a/src/classes/SftpSessionHandler.ts b/src/classes/SftpSessionHandler.ts index 8051344b..634bb138 100644 --- a/src/classes/SftpSessionHandler.ts +++ b/src/classes/SftpSessionHandler.ts @@ -8,7 +8,6 @@ import { generateFileEntry } from '../utils'; import { MissingTemporaryFileError } from '../errors'; import { PermanentFileSystem } from './PermanentFileSystem'; import { TemporaryFileManager } from './TemporaryFileManager'; -import type { AuthenticationSession } from './AuthenticationSession'; import type { PermanentFileSystemManager } from './PermanentFileSystemManager'; import type { TemporaryFile } from './TemporaryFileManager'; import type { @@ -19,6 +18,7 @@ import type { import type { File, } from '@permanentorg/sdk'; +import type { AuthTokenManager } from './AuthTokenManager'; const SFTP_STATUS_CODE = ssh2.utils.sftp.STATUS_CODE; @@ -61,15 +61,15 @@ export class SftpSessionHandler { private readonly permanentFileSystemManager: PermanentFileSystemManager; - private readonly authenticationSession: AuthenticationSession; + private readonly authTokenManager: AuthTokenManager; public constructor( sftpConnection: SFTPWrapper, - authenticationSession: AuthenticationSession, + authTokenManager: AuthTokenManager, permanentFileSystemManager: PermanentFileSystemManager, ) { this.sftpConnection = sftpConnection; - this.authenticationSession = authenticationSession; + this.authTokenManager = authTokenManager; this.permanentFileSystemManager = permanentFileSystemManager; } @@ -94,39 +94,35 @@ export class SftpSessionHandler { attrs, }, ); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.getItemType(filePath) - .then((fileType) => { - switch (fileType) { - case fs.constants.S_IFDIR: - logger.verbose( - 'Response: Status (NO_SUCH_FILE)', - { - reqId, - code: SFTP_STATUS_CODE.NO_SUCH_FILE, - }, - ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); - break; - default: { - this.openExistingFileHandler( - reqId, - filePath, - flags, - ); - break; - } - } - }).catch((err: unknown) => { - logger.debug(err); - this.openNewFileHandler( + const permanentfileSystem = this.getCurrentPermanentFileSystem(); + permanentfileSystem.getItemType(filePath).then((fileType) => { + switch (fileType) { + case fs.constants.S_IFDIR: + logger.verbose( + 'Response: Status (NO_SUCH_FILE)', + { + reqId, + code: SFTP_STATUS_CODE.NO_SUCH_FILE, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); + break; + default: { + this.openExistingFileHandler( reqId, filePath, flags, ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + break; + } + } + }).catch((err: unknown) => { + logger.debug(err); + this.openNewFileHandler( + reqId, + filePath, + flags, + ); }); } @@ -548,7 +544,6 @@ export class SftpSessionHandler { (async () => { let temporaryFile: TemporaryFile; let fileSize: number; - let permanentFileSystem: PermanentFileSystem; try { temporaryFile = await this.temporaryFileManager.getTemporaryFile(virtualFilePath); @@ -589,24 +584,7 @@ export class SftpSessionHandler { return; } - try { - permanentFileSystem = await this.getCurrentPermanentFileSystem(); - } catch (err: unknown) { - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: temporaryFile.virtualPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to access the Permanent File System.', - ); - return; - } + const permanentFileSystem = this.getCurrentPermanentFileSystem(); try { await permanentFileSystem.createFile( @@ -692,49 +670,44 @@ export class SftpSessionHandler { ); const handle = generateHandle(); logger.debug(`Opening directory ${dirPath}:`, handle); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.loadDirectory(dirPath) - .then((fileEntries) => { - logger.debug('Contents:', fileEntries); - const directoryResource = { - virtualFilePath: dirPath, - resourceType: ServerResourceType.Directory as const, - fileEntries, - cursor: 0, - }; - this.activeHandles.set(handle, directoryResource); - logger.verbose( - 'Response: Handle', - { - reqId, - handle, - path: dirPath, - }, - ); - this.sftpConnection.handle( - reqId, - Buffer.from(handle), - ); - }) - .catch((err: unknown) => { - logger.warn(err); - logger.warn('Failed to load path', { reqId, dirPath }); - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: dirPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to load this directory from Permanent.org.', - ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.loadDirectory(dirPath).then((fileEntries) => { + logger.debug('Contents:', fileEntries); + const directoryResource = { + virtualFilePath: dirPath, + resourceType: ServerResourceType.Directory as const, + fileEntries, + cursor: 0, + }; + this.activeHandles.set(handle, directoryResource); + logger.verbose( + 'Response: Handle', + { + reqId, + handle, + path: dirPath, + }, + ); + this.sftpConnection.handle( + reqId, + Buffer.from(handle), + ); + }).catch((err: unknown) => { + logger.warn(err); + logger.warn('Failed to load path', { reqId, dirPath }); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: dirPath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to load this directory from Permanent.org.', + ); }); } @@ -853,37 +826,32 @@ export class SftpSessionHandler { { reqId, filePath }, ); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.deleteFile(filePath) - .then(() => { - logger.verbose( - 'Response: Status (OK)', - { - reqId, - code: SFTP_STATUS_CODE.OK, - path: filePath, - }, - ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); - }) - .catch((err: unknown) => { - logger.debug(err); - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: filePath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to delete this file on Permanent.org.', - ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.deleteFile(filePath).then(() => { + logger.verbose( + 'Response: Status (OK)', + { + reqId, + code: SFTP_STATUS_CODE.OK, + path: filePath, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: filePath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to delete this file on Permanent.org.', + ); }); } @@ -899,37 +867,32 @@ export class SftpSessionHandler { { reqId, directoryPath }, ); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.deleteDirectory(directoryPath) - .then(() => { - logger.verbose( - 'Response: Status (OK)', - { - reqId, - code: SFTP_STATUS_CODE.OK, - path: directoryPath, - }, - ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); - }) - .catch((err: unknown) => { - logger.debug(err); - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: directoryPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to delete this directory on Permanent.org.', - ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.deleteDirectory(directoryPath).then(() => { + logger.verbose( + 'Response: Status (OK)', + { + reqId, + code: SFTP_STATUS_CODE.OK, + path: directoryPath, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: directoryPath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to delete this directory on Permanent.org.', + ); }); } @@ -945,33 +908,29 @@ export class SftpSessionHandler { { reqId, relativePath }, ); const resolvedPath = path.resolve('/', relativePath); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.getItemAttributes(resolvedPath) - .then((attrs) => { - const fileEntry = generateFileEntry( - resolvedPath, - attrs, - ); - const names = [fileEntry]; - logger.verbose( - 'Response: Name', - { reqId, names }, - ); - this.sftpConnection.name(reqId, names); - }) - .catch((err: unknown) => { - logger.debug(err); - logger.verbose( - 'Response: Status (EOF)', - { - reqId, - code: SFTP_STATUS_CODE.NO_SUCH_FILE, - }, - ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.getItemAttributes(resolvedPath).then((attrs) => { + const fileEntry = generateFileEntry( + resolvedPath, + attrs, + ); + const names = [fileEntry]; + logger.verbose( + 'Response: Name', + { reqId, names }, + ); + this.sftpConnection.name(reqId, names); + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (EOF)', + { + reqId, + code: SFTP_STATUS_CODE.NO_SUCH_FILE, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); }); } @@ -1047,34 +1006,29 @@ export class SftpSessionHandler { attrs, }, ); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.createDirectory(dirPath) - .then(() => { - logger.verbose('Response: Status (OK)', { - reqId, - code: SFTP_STATUS_CODE.OK, - path: dirPath, - }); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); - }) - .catch((err: unknown) => { - logger.debug(err); - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: dirPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to create this directory on Permanent.org.', - ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.createDirectory(dirPath).then(() => { + logger.verbose('Response: Status (OK)', { + reqId, + code: SFTP_STATUS_CODE.OK, + path: dirPath, + }); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: dirPath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to create this directory on Permanent.org.', + ); }); } @@ -1126,121 +1080,97 @@ export class SftpSessionHandler { } private genericStatHandler(reqId: number, itemPath: string): void { - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.getItemAttributes(itemPath) - .then((attrs) => { + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.getItemAttributes(itemPath).then((attrs) => { + logger.verbose( + 'Response: Attrs', + { + reqId, + attrs, + path: itemPath.toString(), + }, + ); + this.sftpConnection.attrs( + reqId, + attrs, + ); + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (NO_SUCH_FILE)', + { + reqId, + code: SFTP_STATUS_CODE.NO_SUCH_FILE, + path: itemPath, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); + }); + } + + private openExistingFileHandler( + reqId: number, + filePath: string, + flags: number, + ): void { + const handle = generateHandle(); + const flagsString = ssh2.utils.sftp.flagsToString(flags); + + const permanentFileSystem = this.getCurrentPermanentFileSystem(); + permanentFileSystem.loadFile(filePath, true).then((file) => { + // These flags are explained in the NodeJS fs documentation: + // https://nodejs.org/api/fs.html#file-system-flags + switch (flagsString) { + case 'r': { // read + const permanentFileResource = { + resourceType: ServerResourceType.PermanentFile as const, + virtualFilePath: filePath, + file, + }; + this.activeHandles.set(handle, permanentFileResource); logger.verbose( - 'Response: Attrs', + 'Response: Handle', { reqId, - attrs, - path: itemPath.toString(), + handle, + path: filePath, }, ); - this.sftpConnection.attrs( + this.sftpConnection.handle( reqId, - attrs, + Buffer.from(handle), ); - }) - .catch((err: unknown) => { - logger.debug(err); + break; + } + // We do not currently allow anybody to edit an existing record in any way + case 'r+': // read and write + case 'w': // write + case 'w+': // write and read + case 'a': // append + case 'a+': // append and read logger.verbose( - 'Response: Status (NO_SUCH_FILE)', + 'Response: Status (PERMISSION_DENIED)', { reqId, - code: SFTP_STATUS_CODE.NO_SUCH_FILE, - path: itemPath, + code: SFTP_STATUS_CODE.PERMISSION_DENIED, + path: filePath, }, ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); - }); - } - - private openExistingFileHandler( - reqId: number, - filePath: string, - flags: number, - ): void { - const handle = generateHandle(); - const flagsString = ssh2.utils.sftp.flagsToString(flags); - - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.loadFile(filePath, true) - .then((file) => { - // These flags are explained in the NodeJS fs documentation: - // https://nodejs.org/api/fs.html#file-system-flags - switch (flagsString) { - case 'r': { // read - const permanentFileResource = { - resourceType: ServerResourceType.PermanentFile as const, - virtualFilePath: filePath, - file, - }; - this.activeHandles.set(handle, permanentFileResource); - logger.verbose( - 'Response: Handle', - { - reqId, - handle, - path: filePath, - }, - ); - this.sftpConnection.handle( - reqId, - Buffer.from(handle), - ); - break; - } - // We do not currently allow anybody to edit an existing record in any way - case 'r+': // read and write - case 'w': // write - case 'w+': // write and read - case 'a': // append - case 'a+': // append and read - logger.verbose( - 'Response: Status (PERMISSION_DENIED)', - { - reqId, - code: SFTP_STATUS_CODE.PERMISSION_DENIED, - path: filePath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.PERMISSION_DENIED, - 'This file already exists on Permanent.org. Editing exiting files is not supported.', - ); - break; - // These codes all require the file NOT to exist - case 'wx': // write (file must not exist) - case 'xw': // write (file must not exist) - case 'xw+': // write and read (file must not exist) - case 'ax': // append (file must not exist) - case 'xa': // append (file must not exist) - case 'ax+': // append and write (file must not exist) - case 'xa+': // append and write (file must not exist) - default: - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: filePath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - `This file already exists on Permanent.org, but the specified write mode (${flagsString ?? 'null'}) requires the file to not exist.`, - ); - break; - } - }) - .catch((err: unknown) => { - logger.debug(err); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.PERMISSION_DENIED, + 'This file already exists on Permanent.org. Editing exiting files is not supported.', + ); + break; + // These codes all require the file NOT to exist + case 'wx': // write (file must not exist) + case 'xw': // write (file must not exist) + case 'xw+': // write and read (file must not exist) + case 'ax': // append (file must not exist) + case 'xa': // append (file must not exist) + case 'ax+': // append and write (file must not exist) + case 'xa+': // append and write (file must not exist) + default: logger.verbose( 'Response: Status (FAILURE)', { @@ -1252,11 +1182,25 @@ export class SftpSessionHandler { this.sftpConnection.status( reqId, SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to load this file from Permanent.org.', + `This file already exists on Permanent.org, but the specified write mode (${flagsString ?? 'null'}) requires the file to not exist.`, ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + break; + } + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: filePath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to load this file from Permanent.org.', + ); }); } @@ -1268,80 +1212,61 @@ export class SftpSessionHandler { const handle = generateHandle(); const flagsString = ssh2.utils.sftp.flagsToString(flags); const parentPath = path.dirname(filePath); - this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { - permFileSystem.loadDirectory(parentPath) - .then(() => { - // These flags are explained in the NodeJS fs documentation: - // https://nodejs.org/api/fs.html#file-system-flags - switch (flagsString) { - case 'w': // write - case 'wx': // write (file must not exist) - case 'xw': // write (file must not exist) - case 'w+': // write and read - case 'xw+': // write and read (file must not exist) - case 'ax': // append (file must not exist) - case 'xa': // append (file must not exist) - case 'a+': // append and read - case 'ax+': // append and read (file must not exist) - case 'xa+': // append and read (file must not exist) - case 'a': // append - { - this.temporaryFileManager.createTemporaryFile(filePath).then(() => { - const temporaryFileResource = { - resourceType: ServerResourceType.TemporaryFile as const, - virtualFilePath: filePath, - }; - this.activeHandles.set(handle, temporaryFileResource); - logger.verbose( - 'Response: Handle', - { - reqId, - handle, - path: filePath, - }, - ); - this.sftpConnection.handle( - reqId, - Buffer.from(handle), - ); - }).catch((err) => { - logger.debug(err); - logger.verbose( - 'Response: Status (FAILURE)', - { - reqId, - code: SFTP_STATUS_CODE.FAILURE, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to create the file in temporary storage.', - ); - }); - break; - } - case 'r+': // read and write (error if doesn't exist) - case 'r': // read - default: - logger.verbose( - 'Response: Status (NO_SUCH_FILE)', - { - reqId, - code: SFTP_STATUS_CODE.NO_SUCH_FILE, - path: filePath, - }, - ); - this.sftpConnection.status( + const permanentFilesystem = this.getCurrentPermanentFileSystem(); + permanentFilesystem.loadDirectory(parentPath).then(() => { + // These flags are explained in the NodeJS fs documentation: + // https://nodejs.org/api/fs.html#file-system-flags + switch (flagsString) { + case 'w': // write + case 'wx': // write (file must not exist) + case 'xw': // write (file must not exist) + case 'w+': // write and read + case 'xw+': // write and read (file must not exist) + case 'ax': // append (file must not exist) + case 'xa': // append (file must not exist) + case 'a+': // append and read + case 'ax+': // append and read (file must not exist) + case 'xa+': // append and read (file must not exist) + case 'a': // append + { + this.temporaryFileManager.createTemporaryFile(filePath).then(() => { + const temporaryFileResource = { + resourceType: ServerResourceType.TemporaryFile as const, + virtualFilePath: filePath, + }; + this.activeHandles.set(handle, temporaryFileResource); + logger.verbose( + 'Response: Handle', + { reqId, - SFTP_STATUS_CODE.NO_SUCH_FILE, - 'The specified file does not exist.', - ); - break; - } - }) - .catch((err: unknown) => { - logger.debug(err); + handle, + path: filePath, + }, + ); + this.sftpConnection.handle( + reqId, + Buffer.from(handle), + ); + }).catch((err) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to create the file in temporary storage.', + ); + }); + break; + } + case 'r+': // read and write (error if doesn't exist) + case 'r': // read + default: logger.verbose( 'Response: Status (NO_SUCH_FILE)', { @@ -1353,19 +1278,33 @@ export class SftpSessionHandler { this.sftpConnection.status( reqId, SFTP_STATUS_CODE.NO_SUCH_FILE, - 'The specified parent directory does not exist.', + 'The specified file does not exist.', ); - }); - }).catch((fileSysErr) => { - logger.error(`Error loading file permanent file system ${fileSysErr}`); + break; + } + }).catch((err: unknown) => { + logger.debug(err); + logger.verbose( + 'Response: Status (NO_SUCH_FILE)', + { + reqId, + code: SFTP_STATUS_CODE.NO_SUCH_FILE, + path: filePath, + }, + ); + this.sftpConnection.status( + reqId, + SFTP_STATUS_CODE.NO_SUCH_FILE, + 'The specified parent directory does not exist.', + ); }); } - private async getCurrentPermanentFileSystem(): Promise { + private getCurrentPermanentFileSystem(): PermanentFileSystem { return this.permanentFileSystemManager .getCurrentPermanentFileSystemForUser( - this.authenticationSession.authContext.username, - await this.authenticationSession.getAuthToken(), + this.authTokenManager.username, + this.authTokenManager, ); } } diff --git a/src/classes/SshConnectionHandler.ts b/src/classes/SshConnectionHandler.ts index 4a703220..e84ee7ca 100644 --- a/src/classes/SshConnectionHandler.ts +++ b/src/classes/SshConnectionHandler.ts @@ -1,6 +1,7 @@ import { logger } from '../logger'; import { AuthenticationSession } from './AuthenticationSession'; import { SshSessionHandler } from './SshSessionHandler'; +import { AuthTokenManager } from './AuthTokenManager'; import type { AuthContext, Session, @@ -10,9 +11,7 @@ import type { PermanentFileSystemManager } from './PermanentFileSystemManager'; export class SshConnectionHandler { private readonly permanentFileSystemManager: PermanentFileSystemManager; - private authSession?: AuthenticationSession; - - private fusionAuthSftpAppId = ''; + private authTokenManager?: AuthTokenManager; private fusionAuthSftpClientId = ''; @@ -20,12 +19,10 @@ export class SshConnectionHandler { public constructor( permanentFileSystemManager: PermanentFileSystemManager, - fusionAuthSftpAppId: string, fusionAuthSftpClientId: string, fusionAuthSftpClientSecret: string, ) { this.permanentFileSystemManager = permanentFileSystemManager; - this.fusionAuthSftpAppId = fusionAuthSftpAppId; this.fusionAuthSftpClientId = fusionAuthSftpClientId; this.fusionAuthSftpClientSecret = fusionAuthSftpClientSecret; } @@ -43,12 +40,17 @@ export class SshConnectionHandler { case 'keyboard-interactive': { const authenticationSession = new AuthenticationSession( authContext, - this.fusionAuthSftpAppId, this.fusionAuthSftpClientId, - this.fusionAuthSftpClientSecret, + (refreshToken) => { + this.authTokenManager = new AuthTokenManager( + authContext.username, + refreshToken, + this.fusionAuthSftpClientId, + this.fusionAuthSftpClientSecret, + ); + }, ); authenticationSession.invokeAuthenticationFlow(); - this.authSession = authenticationSession; return; } case 'none': @@ -131,14 +133,14 @@ export class SshConnectionHandler { ): void { logger.verbose('SSH request for a new session'); const session = accept(); - if (this.authSession === undefined) { + if (this.authTokenManager === undefined) { logger.verbose('Closing SSH session immediately (no authentication context)'); session.close(); return; } const sessionHandler = new SshSessionHandler( session, - this.authSession, + this.authTokenManager, this.permanentFileSystemManager, ); session.on('sftp', sessionHandler.onSftp.bind(sessionHandler)); diff --git a/src/classes/SshSessionHandler.ts b/src/classes/SshSessionHandler.ts index de494745..7f08b3a8 100644 --- a/src/classes/SshSessionHandler.ts +++ b/src/classes/SshSessionHandler.ts @@ -1,6 +1,6 @@ import { logger } from '../logger'; import { SftpSessionHandler } from './SftpSessionHandler'; -import type { AuthenticationSession } from './AuthenticationSession'; +import { AuthTokenManager } from './AuthTokenManager'; import type { PermanentFileSystemManager } from './PermanentFileSystemManager'; import type { Session, @@ -10,17 +10,17 @@ import type { export class SshSessionHandler { private readonly permanentFileSystemManager: PermanentFileSystemManager; - private readonly authenticationSession: AuthenticationSession; + private readonly authTokenManager: AuthTokenManager; private readonly session: Session; public constructor( session: Session, - authenticationSession: AuthenticationSession, + authTokenManager: AuthTokenManager, permanentFileSystemManager: PermanentFileSystemManager, ) { this.session = session; - this.authenticationSession = authenticationSession; + this.authTokenManager = authTokenManager; this.permanentFileSystemManager = permanentFileSystemManager; } @@ -35,7 +35,7 @@ export class SshSessionHandler { const sftpConnection = accept(); const sftpSessionHandler = new SftpSessionHandler( sftpConnection, - this.authenticationSession, + this.authTokenManager, this.permanentFileSystemManager, ); sftpConnection.on('OPEN', sftpSessionHandler.openHandler.bind(sftpSessionHandler)); diff --git a/src/server.ts b/src/server.ts index 7fb56e3b..11a2380d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,6 @@ import type { const { SSH_HOST_KEY_PATH, - FUSION_AUTH_SFTP_APP_ID, FUSION_AUTH_SFTP_CLIENT_ID, FUSION_AUTH_SFTP_CLIENT_SECRET, } = requireEnv( @@ -21,7 +20,6 @@ const { 'FUSION_AUTH_KEY', 'PERMANENT_API_BASE_PATH', 'SSH_HOST_KEY_PATH', - 'FUSION_AUTH_SFTP_APP_ID', 'FUSION_AUTH_SFTP_CLIENT_ID', 'FUSION_AUTH_SFTP_CLIENT_SECRET', ); @@ -41,7 +39,6 @@ const connectionListener = (client: Connection): void => { logger.verbose('New connection'); const connectionHandler = new SshConnectionHandler( permanentFileSystemManager, - FUSION_AUTH_SFTP_APP_ID, FUSION_AUTH_SFTP_CLIENT_ID, FUSION_AUTH_SFTP_CLIENT_SECRET, );