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, );