From b9ab43ccc5467ba53df1a9b5da9615d2cb3675f3 Mon Sep 17 00:00:00 2001 From: fenn-cs Date: Tue, 20 Jun 2023 17:35:57 +0100 Subject: [PATCH 1/2] Refresh authToken when it expires Access tokens have lifetimes that might end before the user wants to end their authenticated session. Hence, we need to refresh the user access token after an expiry detection. Resolves: https://github.com/PermanentOrg/sftp-service/issues/175 Signed-off-by: fenn-cs --- .env.example | 1 + src/classes/AuthenticationSession.ts | 35 ++++++++++++++++++++++++++++ src/classes/SftpSessionHandler.ts | 6 +++++ 3 files changed, 42 insertions(+) diff --git a/.env.example b/.env.example index 52b4b335..12dd2928 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,4 @@ 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} diff --git a/src/classes/AuthenticationSession.ts b/src/classes/AuthenticationSession.ts index 9d43f3ba..666dae14 100644 --- a/src/classes/AuthenticationSession.ts +++ b/src/classes/AuthenticationSession.ts @@ -15,10 +15,16 @@ enum FusionAuthStatusCode { export class AuthenticationSession { public authToken = ''; + public refreshToken = ''; + public readonly authContext; + private authTokenExpiresAt = 0; + private readonly fusionAuthClient; + private readonly fusionAuthAppId = process.env.FUSION_AUTH_APP_ID ?? ''; + private twoFactorId = ''; private twoFactorMethods: TwoFactorMethod[] = []; @@ -32,6 +38,32 @@ 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 tokenExpired(): boolean { + const expirationDate = new Date(this.authTokenExpiresAt); + return expirationDate <= new Date(); + } + + public tokenWouldExpireSoon(minutes = 5): boolean { + const expirationDate = new Date(this.authTokenExpiresAt); + const currentTime = new Date(); + const timeDifferenceMinutes = (expirationDate.getTime() - currentTime.getTime()) / (1000 * 60); + return timeDifferenceMinutes <= minutes; + } + private promptForPassword(): void { this.authContext.prompt( { @@ -46,6 +78,7 @@ export class AuthenticationSession { private processPasswordResponse([password]: string[]): void { this.fusionAuthClient.login({ + applicationId: this.fusionAuthAppId, loginId: this.authContext.username, password, }).then((clientResponse) => { @@ -57,6 +90,8 @@ export class AuthenticationSession { username: this.authContext.username, }); this.authToken = clientResponse.response.token; + this.authTokenExpiresAt = clientResponse.response.tokenExpirationInstant ?? 0; + this.refreshToken = clientResponse.response.refreshToken ?? ''; this.authContext.accept(); return; } diff --git a/src/classes/SftpSessionHandler.ts b/src/classes/SftpSessionHandler.ts index 2be54cb3..d86d16d5 100644 --- a/src/classes/SftpSessionHandler.ts +++ b/src/classes/SftpSessionHandler.ts @@ -1070,6 +1070,12 @@ export class SftpSessionHandler { } private getCurrentPermanentFileSystem(): PermanentFileSystem { + if ( + this.authenticationSession.tokenExpired() + || this.authenticationSession.tokenWouldExpireSoon() + ) { + this.authenticationSession.obtainNewAuthTokenUsingRefreshToken(); + } return this.permanentFileSystemManager .getCurrentPermanentFileSystemForUser( this.authenticationSession.authContext.username, From c11bdd4cb7cf885f83821bd2c41f0810bd3fd405 Mon Sep 17 00:00:00 2001 From: "Fon E. Noel NFEBE" Date: Mon, 21 Aug 2023 01:00:22 +0100 Subject: [PATCH 2/2] Register unregistered users & update obtain new token Signed-off-by: Fon E. Noel NFEBE --- .env.example | 4 +- package-lock.json | 11 + package.json | 1 + src/classes/AuthenticationSession.ts | 186 +++++-- src/classes/SftpSessionHandler.ts | 776 ++++++++++++++------------- src/classes/SshConnectionHandler.ts | 25 +- src/errors/AuthTokenRefreshError.ts | 1 + src/fusionAuth.ts | 2 + src/server.ts | 25 +- 9 files changed, 610 insertions(+), 421 deletions(-) create mode 100644 src/errors/AuthTokenRefreshError.ts 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));