Skip to content

Commit

Permalink
Merge pull request #305 from PermanentOrg/288-fix-hanging-risk
Browse files Browse the repository at this point in the history
Refactor refresh tokens
  • Loading branch information
slifty authored Nov 9, 2023
2 parents 0fbd284 + cd0d162 commit bef391d
Show file tree
Hide file tree
Showing 9 changed files with 516 additions and 542 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;
}
}
144 changes: 38 additions & 106 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 = '';
type SuccessHandler = (refreshToken: string) => void;

public readonly authContext;

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 Expand Up @@ -285,17 +205,29 @@ export class AuthenticationSession {
}).then((clientResponse) => {
switch (clientResponse.statusCode) {
case FusionAuthStatusCode.Success:
case FusionAuthStatusCode.SuccessButUnregisteredInApp:
if (clientResponse.response.token !== undefined) {
logger.verbose('Successful 2FA authentication attempt.', {
username: this.authContext.username,
});
this.authToken = clientResponse.response.token;
logger.verbose('Successful 2FA authentication attempt.', {
username: this.authContext.username,
});
if (clientResponse.response.refreshToken) {
this.successHandler(clientResponse.response.refreshToken);
this.authContext.accept();
return;
} else {
logger.warn('No refresh token in response :', clientResponse.response);
this.authContext.reject();
}
this.authContext.reject();
return;
case FusionAuthStatusCode.SuccessButUnregisteredInApp: {
const userId = clientResponse.response.user?.id ?? '';
this.registerUserInApp(userId)
.then(() => {
this.processTwoFactorCodeResponse([twoFactorCode]);
})
.catch((error) => {
logger.warn('Error during registration and authentication:', error);
this.authContext.reject();
});
return;
}
default:
logger.verbose('Failed 2FA authentication attempt.', {
username: this.authContext.username,
Expand Down
Loading

0 comments on commit bef391d

Please sign in to comment.