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.

Finally, the tokens are now retrieved just-in-time by the permanent file
system (rather than being passed during the creation of the permanent
file system).  This is a critical fix because (1) it prevents certain
paths that would lead to stale tokens but also (2) it means that
creating a permanent file system becomes a synchronous operation.  This
also resolves a bug where the failure to generate a token could result
in a hanging sftp connection.

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

Issue #288 Permanent file system errors can result in hung connections
Issue #289 Duplicated FusionAuth env vars
  • Loading branch information
slifty committed Nov 7, 2023
1 parent 979a5ec commit a7f797d
Show file tree
Hide file tree
Showing 9 changed files with 493 additions and 534 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
Loading

0 comments on commit a7f797d

Please sign in to comment.