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/package-lock.json b/package-lock.json index 9a6ca184..7c4df50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^16.3.1", "logform": "^2.3.2", "node-fetch": "^2.7.0", + "require-env-variable": "^4.0.1", "ssh2": "^1.14.0", "tmp": "^0.2.1", "uuid": "^9.0.0", @@ -8748,6 +8749,11 @@ "node": ">=0.10.0" } }, + "node_modules/require-env-variable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/require-env-variable/-/require-env-variable-4.0.1.tgz", + "integrity": "sha512-2GCxnZqKSNIC9Ag9O38CF/HkWdd7kDNKzTuSxdIVMYa0WMZIntEBTeGY9b4IPy64FciEjyvh1iL2IWh2PERB0w==" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -16149,6 +16155,11 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "require-env-variable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/require-env-variable/-/require-env-variable-4.0.1.tgz", + "integrity": "sha512-2GCxnZqKSNIC9Ag9O38CF/HkWdd7kDNKzTuSxdIVMYa0WMZIntEBTeGY9b4IPy64FciEjyvh1iL2IWh2PERB0w==" + }, "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index aa8ad4ed..4e3e9087 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "dotenv": "^16.3.1", "logform": "^2.3.2", "node-fetch": "^2.7.0", + "require-env-variable": "^4.0.1", "ssh2": "^1.14.0", "tmp": "^0.2.1", "uuid": "^9.0.0", diff --git a/src/classes/AuthenticationSession.ts b/src/classes/AuthenticationSession.ts index 666dae14..aae6e35f 100644 --- a/src/classes/AuthenticationSession.ts +++ b/src/classes/AuthenticationSession.ts @@ -3,6 +3,7 @@ import { getFusionAuthClient, isPartialClientResponse, } from '../fusionAuth'; +import { AuthTokenRefreshError } from '../errors/AuthTokenRefreshError'; import type { KeyboardAuthContext } from 'ssh2'; import type { TwoFactorMethod } from '@fusionauth/typescript-client'; @@ -13,24 +14,36 @@ enum FusionAuthStatusCode { } export class AuthenticationSession { - public authToken = ''; + 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[] = []; - public constructor(authContext: KeyboardAuthContext) { + private fusionAuthAppId = ''; + + private fusionAuthClientId = ''; + + private fusionAuthClientSecret = ''; + + public constructor( + authContext: KeyboardAuthContext, + fusionAuthAppId: string, + fusionAuthClientId: string, + fusionAuthClientSecret: string, + ) { this.authContext = authContext; + this.fusionAuthAppId = fusionAuthAppId; + this.fusionAuthClientId = fusionAuthClientId; + this.fusionAuthClientSecret = fusionAuthClientSecret; this.fusionAuthClient = getFusionAuthClient(); } @@ -38,30 +51,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 getAuthToken() { + if (this.tokenWouldExpireSoon()) { + await this.getAuthTokenUsingRefreshToken(); + } + return this.authToken; } - public tokenExpired(): boolean { - const expirationDate = new Date(this.authTokenExpiresAt); - return expirationDate <= new Date(); + 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); } - public tokenWouldExpireSoon(minutes = 5): boolean { - const expirationDate = new Date(this.authTokenExpiresAt); + private tokenWouldExpireSoon(expirationThresholdInSeconds = 300): boolean { const currentTime = new Date(); - const timeDifferenceMinutes = (expirationDate.getTime() - currentTime.getTime()) / (1000 * 60); - return timeDifferenceMinutes <= minutes; + const remainingTokenLife = ( + (this.authTokenExpiresAt.getTime() - currentTime.getTime()) / 1000 + ); + return remainingTokenLife <= expirationThresholdInSeconds; } private promptForPassword(): void { @@ -83,21 +134,39 @@ export class AuthenticationSession { 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.refreshToken = clientResponse.response.refreshToken ?? ''; + 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(); + } this.authContext.accept(); - return; + } else { + logger.warn('No auth token in response', clientResponse.response); + 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,26 +174,56 @@ 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((error) => { + let message: string; + if (isPartialClientResponse(error)) { + message = error.exception.message; + } else { + message = error instanceof Error ? error.message : JSON.stringify(error); } - }).catch((clientResponse: unknown) => { - const message = isPartialClientResponse(clientResponse) - ? clientResponse.exception.message - : ''; logger.warn(`Unexpected exception with FusionAuth password login: ${message}`); this.authContext.reject(); }); } + private async registerUserInApp(userId: string): Promise { + try { + const clientResponse = await this.fusionAuthClient.register(userId, { + registration: { + applicationId: this.fusionAuthAppId, + }, + }); + + 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 ?? ''}`, @@ -205,10 +304,13 @@ export class AuthenticationSession { }); this.authContext.reject(); } - }).catch((clientResponse: unknown) => { - const message = isPartialClientResponse(clientResponse) - ? clientResponse.exception.message - : ''; + }).catch((error) => { + let message: string; + if (isPartialClientResponse(error)) { + message = error.exception.message; + } else { + message = error instanceof Error ? error.message : JSON.stringify(error); + } logger.warn(`Unexpected exception with FusionAuth 2FA login: ${message}`); this.authContext.reject(); }); diff --git a/src/classes/SftpSessionHandler.ts b/src/classes/SftpSessionHandler.ts index d86d16d5..17474fa0 100644 --- a/src/classes/SftpSessionHandler.ts +++ b/src/classes/SftpSessionHandler.ts @@ -6,8 +6,8 @@ import ssh2 from 'ssh2'; import tmp from 'tmp'; import { logger } from '../logger'; import { generateFileEntry } from '../utils'; +import { PermanentFileSystem } from './PermanentFileSystem'; import type { AuthenticationSession } from './AuthenticationSession'; -import type { PermanentFileSystem } from './PermanentFileSystem'; import type { PermanentFileSystemManager } from './PermanentFileSystemManager'; import type { FileResult } from 'tmp'; import type { @@ -73,36 +73,40 @@ export class SftpSessionHandler { attrs, }, ); - this.getCurrentPermanentFileSystem().getItemType(filePath) - .then((fileType) => { - switch (fileType) { - case fs.constants.S_IFDIR: - logger.verbose( - 'Response: Status (NO_SUCH_FILE)', - { + 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, - 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; + filePath, + flags, + ); + break; + } } - } - }).catch((err: unknown) => { - logger.debug(err); - this.openNewFileHandler( - reqId, - filePath, - flags, - ); - }); + }).catch((err: unknown) => { + logger.debug(err); + this.openNewFileHandler( + reqId, + filePath, + flags, + ); + }); + }).catch((fileSysErr) => { + logger.error(`Error loading file permanent file system ${fileSysErr}`); + }); } /** @@ -423,37 +427,41 @@ export class SftpSessionHandler { return; } const { size } = stats; - this.getCurrentPermanentFileSystem().createFile( - temporaryFile.virtualPath, - fs.createReadStream(temporaryFile.name), - size, - ).then(() => { - temporaryFile.removeCallback(); - this.openTemporaryFiles.delete(handle.toString()); - logger.verbose( - 'Response: Status (OK)', - { - reqId, - code: SFTP_STATUS_CODE.OK, - path: temporaryFile.virtualPath, - }, - ); - this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); - }).catch((err) => { - logger.debug(err); - logger.verbose( - 'Response: Status (FAILURE)', - { + this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { + permFileSystem.createFile( + temporaryFile.virtualPath, + fs.createReadStream(temporaryFile.name), + size, + ).then(() => { + temporaryFile.removeCallback(); + this.openTemporaryFiles.delete(handle.toString()); + logger.verbose( + 'Response: Status (OK)', + { + reqId, + code: SFTP_STATUS_CODE.OK, + path: temporaryFile.virtualPath, + }, + ); + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.OK); + }).catch((err) => { + logger.debug(err); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: temporaryFile.virtualPath, + }, + ); + this.sftpConnection.status( reqId, - code: SFTP_STATUS_CODE.FAILURE, - path: temporaryFile.virtualPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to register this file on Permanent.org.', - ); + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to register this file on Permanent.org.', + ); + }); + }).catch((fileSysErr) => { + logger.error(`Error loading file permanent file system ${fileSysErr}`); }); }, ); @@ -484,40 +492,44 @@ export class SftpSessionHandler { ); const handle = generateHandle(); logger.debug(`Opening directory ${dirPath}:`, handle); - this.getCurrentPermanentFileSystem().loadDirectory(dirPath) - .then((fileEntries) => { - logger.debug('Contents:', fileEntries); - this.openDirectories.set(handle, fileEntries); - logger.verbose( - 'Response: Handle', - { + this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { + permFileSystem.loadDirectory(dirPath) + .then((fileEntries) => { + logger.debug('Contents:', fileEntries); + this.openDirectories.set(handle, fileEntries); + logger.verbose( + 'Response: Handle', + { + reqId, + handle, + path: dirPath, + }, + ); + this.sftpConnection.handle( reqId, - handle, - path: dirPath, - }, - ); - this.sftpConnection.handle( - reqId, - Buffer.from(handle), - ); - }) - .catch((reason: unknown) => { - logger.warn('Failed to load path', { reqId, dirPath }); - logger.warn(reason); - logger.verbose( - 'Response: Status (FAILURE)', - { + Buffer.from(handle), + ); + }) + .catch((reason: unknown) => { + logger.warn('Failed to load path', { reqId, dirPath }); + logger.warn(reason); + logger.verbose( + 'Response: Status (FAILURE)', + { + reqId, + code: SFTP_STATUS_CODE.FAILURE, + path: dirPath, + }, + ); + this.sftpConnection.status( 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.', - ); - }); + 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}`); + }); } /** @@ -598,34 +610,38 @@ export class SftpSessionHandler { { reqId, filePath }, ); - this.getCurrentPermanentFileSystem().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)', - { + 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, - 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.', - ); - }); + 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}`); + }); } /** @@ -640,34 +656,38 @@ export class SftpSessionHandler { { reqId, directoryPath }, ); - this.getCurrentPermanentFileSystem().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)', - { + 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, - 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.', - ); - }); + 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}`); + }); } /** @@ -682,30 +702,34 @@ export class SftpSessionHandler { { reqId, relativePath }, ); const resolvedPath = path.resolve('/', relativePath); - this.getCurrentPermanentFileSystem().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); - }); + 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}`); + }); } /** @@ -780,31 +804,35 @@ export class SftpSessionHandler { attrs, }, ); - this.getCurrentPermanentFileSystem().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)', - { + this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { + permFileSystem.createDirectory(dirPath) + .then(() => { + logger.verbose('Response: Status (OK)', { reqId, - code: SFTP_STATUS_CODE.FAILURE, + code: SFTP_STATUS_CODE.OK, path: dirPath, - }, - ); - this.sftpConnection.status( - reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to create this directory on Permanent.org.', - ); - }); + }); + 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}`); + }); } /** @@ -855,33 +883,37 @@ export class SftpSessionHandler { } private genericStatHandler(reqId: number, itemPath: string): void { - this.getCurrentPermanentFileSystem().getItemAttributes(itemPath) - .then((attrs) => { - logger.verbose( - 'Response: Attrs', - { + this.getCurrentPermanentFileSystem().then((permFileSystem: PermanentFileSystem) => { + permFileSystem.getItemAttributes(itemPath) + .then((attrs) => { + logger.verbose( + 'Response: Attrs', + { + reqId, + attrs, + path: itemPath.toString(), + }, + ); + this.sftpConnection.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); - }); + ); + }) + .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); + }); + }).catch((fileSysErr) => { + logger.error(`Error loading file permanent file system ${fileSysErr}`); + }); } private openExistingFileHandler( @@ -891,88 +923,92 @@ export class SftpSessionHandler { ): void { const handle = generateHandle(); const flagsString = ssh2.utils.sftp.flagsToString(flags); - this.getCurrentPermanentFileSystem().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 - this.openFiles.set(handle, file); - this.openFilePaths.set(handle, filePath); - logger.verbose( - 'Response: Handle', - { + 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 + this.openFiles.set(handle, file); + this.openFilePaths.set(handle, filePath); + logger.verbose( + 'Response: Handle', + { + reqId, + handle, + path: filePath, + }, + ); + this.sftpConnection.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)', - { + 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, - 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)', - { + 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, - code: SFTP_STATUS_CODE.FAILURE, - path: filePath, - }, - ); - this.sftpConnection.status( + 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); + logger.verbose( + 'Response: Status (FAILURE)', + { 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); - logger.verbose( - 'Response: Status (FAILURE)', - { + code: SFTP_STATUS_CODE.FAILURE, + path: filePath, + }, + ); + this.sftpConnection.status( 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.', - ); - }); + SFTP_STATUS_CODE.FAILURE, + 'An error occurred when attempting to load this file from Permanent.org.', + ); + }); + }).catch((fileSysErr) => { + logger.error(`Error loading file permanent file system ${fileSysErr}`); + }); } private openNewFileHandler( @@ -983,103 +1019,101 @@ export class SftpSessionHandler { const handle = generateHandle(); const flagsString = ssh2.utils.sftp.flagsToString(flags); const parentPath = path.dirname(filePath); - this.getCurrentPermanentFileSystem().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 - { - tmp.file((err, name, fd, removeCallback) => { - if (err) { + 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 + { + tmp.file((err, name, fd, removeCallback) => { + if (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.', + ); + return; + } + const temporaryFile = { + name, + fd, + removeCallback, + }; + this.openTemporaryFiles.set(handle, { + ...temporaryFile, + virtualPath: filePath, + }); logger.verbose( - 'Response: Status (FAILURE)', + 'Response: Handle', { reqId, - code: SFTP_STATUS_CODE.FAILURE, + handle, + path: filePath, }, ); - this.sftpConnection.status( + this.sftpConnection.handle( reqId, - SFTP_STATUS_CODE.FAILURE, - 'An error occurred when attempting to create the file in temporary storage.', + Buffer.from(handle), ); - return; - } - const temporaryFile = { - name, - fd, - removeCallback, - }; - this.openTemporaryFiles.set(handle, { - ...temporaryFile, - virtualPath: filePath, }); + break; + } + case 'r+': // read and write (error if doesn't exist) + case 'r': // read + default: logger.verbose( - 'Response: Handle', + 'Response: Status (NO_SUCH_FILE)', { reqId, - handle, + code: SFTP_STATUS_CODE.NO_SUCH_FILE, path: filePath, }, ); - this.sftpConnection.handle( - reqId, - Buffer.from(handle), - ); - }); - break; + this.sftpConnection.status(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); + 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(reqId, SFTP_STATUS_CODE.NO_SUCH_FILE); - 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); - }); + }) + .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); + }); + }).catch((fileSysErr) => { + logger.error(`Error loading file permanent file system ${fileSysErr}`); + }); } - private getCurrentPermanentFileSystem(): PermanentFileSystem { - if ( - this.authenticationSession.tokenExpired() - || this.authenticationSession.tokenWouldExpireSoon() - ) { - this.authenticationSession.obtainNewAuthTokenUsingRefreshToken(); - } + private async getCurrentPermanentFileSystem(): Promise { return this.permanentFileSystemManager .getCurrentPermanentFileSystemForUser( this.authenticationSession.authContext.username, - this.authenticationSession.authToken, + await this.authenticationSession.getAuthToken(), ); } } diff --git a/src/classes/SshConnectionHandler.ts b/src/classes/SshConnectionHandler.ts index 7f1d3803..4a703220 100644 --- a/src/classes/SshConnectionHandler.ts +++ b/src/classes/SshConnectionHandler.ts @@ -12,8 +12,22 @@ export class SshConnectionHandler { private authSession?: AuthenticationSession; - public constructor(permanentFileSystemManager: PermanentFileSystemManager) { + private fusionAuthSftpAppId = ''; + + private fusionAuthSftpClientId = ''; + + private fusionAuthSftpClientSecret = ''; + + public constructor( + permanentFileSystemManager: PermanentFileSystemManager, + fusionAuthSftpAppId: string, + fusionAuthSftpClientId: string, + fusionAuthSftpClientSecret: string, + ) { this.permanentFileSystemManager = permanentFileSystemManager; + this.fusionAuthSftpAppId = fusionAuthSftpAppId; + this.fusionAuthSftpClientId = fusionAuthSftpClientId; + this.fusionAuthSftpClientSecret = fusionAuthSftpClientSecret; } /** @@ -21,13 +35,18 @@ export class SshConnectionHandler { * https://datatracker.ietf.org/doc/html/rfc4252#section-5 */ public onAuthentication(authContext: AuthContext): void { - logger.verbose('SSH authentication request recieved.', { + logger.verbose('SSH authentication request received.', { username: authContext.username, method: authContext.method, }); switch (authContext.method) { case 'keyboard-interactive': { - const authenticationSession = new AuthenticationSession(authContext); + const authenticationSession = new AuthenticationSession( + authContext, + this.fusionAuthSftpAppId, + this.fusionAuthSftpClientId, + this.fusionAuthSftpClientSecret, + ); authenticationSession.invokeAuthenticationFlow(); this.authSession = authenticationSession; return; diff --git a/src/errors/AuthTokenRefreshError.ts b/src/errors/AuthTokenRefreshError.ts new file mode 100644 index 00000000..6093a762 --- /dev/null +++ b/src/errors/AuthTokenRefreshError.ts @@ -0,0 +1 @@ +export class AuthTokenRefreshError extends Error {} 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; }; } diff --git a/src/server.ts b/src/server.ts index 246e3803..4254eebc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'fs'; import { Server } from 'ssh2'; +import { requireEnv } from 'require-env-variable'; import { logger } from './logger'; import { SshConnectionHandler, @@ -10,10 +11,21 @@ import type { ServerConfig, } from 'ssh2'; +const { + SSH_HOST_KEY_PATH, + FUSION_AUTH_SFTP_APP_ID, + FUSION_AUTH_SFTP_CLIENT_ID, + FUSION_AUTH_SFTP_CLIENT_SECRET, +} = requireEnv( + 'SSH_HOST_KEY_PATH', + 'FUSION_AUTH_SFTP_APP_ID', + 'FUSION_AUTH_SFTP_CLIENT_ID', + 'FUSION_AUTH_SFTP_CLIENT_SECRET', +); + const hostKeys = []; -if (typeof process.env.SSH_HOST_KEY_PATH === 'string') { - hostKeys.push(readFileSync(process.env.SSH_HOST_KEY_PATH)); -} + +hostKeys.push(readFileSync(SSH_HOST_KEY_PATH)); const serverConfig: ServerConfig = { hostKeys, @@ -24,7 +36,12 @@ const permanentFileSystemManager = new PermanentFileSystemManager(); const connectionListener = (client: Connection): void => { logger.verbose('New connection'); - const connectionHandler = new SshConnectionHandler(permanentFileSystemManager); + const connectionHandler = new SshConnectionHandler( + permanentFileSystemManager, + FUSION_AUTH_SFTP_APP_ID, + FUSION_AUTH_SFTP_CLIENT_ID, + FUSION_AUTH_SFTP_CLIENT_SECRET, + ); client.on('authentication', connectionHandler.onAuthentication.bind(connectionHandler)); client.on('close', connectionHandler.onClose.bind(connectionHandler)); client.on('end', connectionHandler.onEnd.bind(connectionHandler));