Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh authToken when it expires #192

Merged
merged 2 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +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_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}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
173 changes: 155 additions & 18 deletions src/classes/AuthenticationSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,25 +14,107 @@ enum FusionAuthStatusCode {
}

export class AuthenticationSession {
public authToken = '';
private authToken = '';

public refreshToken = '';

public readonly authContext;

private authTokenExpiresAt = new Date();

private readonly fusionAuthClient;

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();
}

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,
'',
nfebe marked this conversation as resolved.
Show resolved Hide resolved
'',
);
} catch (error: unknown) {
nfebe marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -46,50 +129,101 @@ export class AuthenticationSession {

private processPasswordResponse([password]: string[]): void {
this.fusionAuthClient.login({
applicationId: this.fusionAuthAppId,
loginId: this.authContext.username,
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;
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();
slifty marked this conversation as resolved.
Show resolved Hide resolved
});
return;
}
case FusionAuthStatusCode.SuccessNeedsTwoFactorAuth: {
if (clientResponse.response.twoFactorId !== undefined) {
logger.verbose('Successful password authentication attempt; MFA required.', {
username: this.authContext.username,
});
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<void> {
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 ?? ''}`,
Expand Down Expand Up @@ -170,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();
});
Expand Down
Loading