Skip to content

Commit

Permalink
Move token management to dedicated class
Browse files Browse the repository at this point in the history
We had organically coupled token management with the initial
authentication flow, but they don't actually belong together.

This separates token management (e.g. utilization of refresh tokens)
from the SSH authentication system.  It also refactors the sftp session
handler to use the token manager rather than the authentication session.

While doing these refactors we took out a redundant environment
variable.

Issue #289 Duplicated FusionAuth env vars
  • Loading branch information
slifty committed Nov 7, 2023
1 parent 979a5ec commit 04c7d3c
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 123 deletions.
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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}
99 changes: 99 additions & 0 deletions src/classes/AuthTokenManager.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
}
116 changes: 18 additions & 98 deletions src/classes/AuthenticationSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<void> {
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(
{
Expand All @@ -129,37 +56,30 @@ 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;
}
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();
Expand Down Expand Up @@ -203,7 +123,7 @@ export class AuthenticationSession {
try {
const clientResponse = await this.fusionAuthClient.register(userId, {
registration: {
applicationId: this.fusionAuthAppId,
applicationId: this.fusionAuthClientId,
},
});

Expand Down
12 changes: 6 additions & 6 deletions src/classes/SftpSessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { generateFileEntry } from '../utils';
import { MissingTemporaryFileError } from '../errors';
import { PermanentFileSystem } from './PermanentFileSystem';
import { TemporaryFileManager } from './TemporaryFileManager';
import type { AuthenticationSession } from './AuthenticationSession';
import type { AuthTokenManager } from './AuthTokenManager';
import type { PermanentFileSystemManager } from './PermanentFileSystemManager';
import type { TemporaryFile } from './TemporaryFileManager';
import type {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1364,8 +1364,8 @@ export class SftpSessionHandler {
private async getCurrentPermanentFileSystem(): Promise<PermanentFileSystem> {
return this.permanentFileSystemManager
.getCurrentPermanentFileSystemForUser(
this.authenticationSession.authContext.username,
await this.authenticationSession.getAuthToken(),
this.authTokenManager.username,
await this.authTokenManager.getAuthToken(),
);
}
}
22 changes: 12 additions & 10 deletions src/classes/SshConnectionHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { logger } from '../logger';
import { AuthenticationSession } from './AuthenticationSession';
import { SshSessionHandler } from './SshSessionHandler';
import { AuthTokenManager } from './AuthTokenManager';
import type {
AuthContext,
Session,
Expand All @@ -10,22 +11,18 @@ import type { PermanentFileSystemManager } from './PermanentFileSystemManager';
export class SshConnectionHandler {
private readonly permanentFileSystemManager: PermanentFileSystemManager;

private authSession?: AuthenticationSession;

private fusionAuthSftpAppId = '';
private authTokenManager?: AuthTokenManager;

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;
}
Expand All @@ -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':
Expand Down Expand Up @@ -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));
Expand Down
Loading

0 comments on commit 04c7d3c

Please sign in to comment.