Skip to content

Commit a7f797d

Browse files
committed
Move token management to dedicated class
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
1 parent 979a5ec commit a7f797d

9 files changed

+493
-534
lines changed

.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,5 @@ PERMANENT_API_BASE_PATH=${LOCAL_TEMPORARY_AUTH_TOKEN}
4040
# See https://fusionauth.io/docs/v1/tech/apis/api-keys
4141
FUSION_AUTH_HOST=${FUSION_AUTH_HOST}
4242
FUSION_AUTH_KEY=${FUSION_AUTH_KEY}
43-
FUSION_AUTH_SFTP_APP_ID=${FUSION_AUTH_SFTP_APP_ID}
4443
FUSION_AUTH_SFTP_CLIENT_ID=${FUSION_AUTH_SFTP_CLIENT_ID}
4544
FUSION_AUTH_SFTP_CLIENT_SECRET=${FUSION_AUTH_SFTP_CLIENT_SECRET}

src/classes/AuthTokenManager.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { logger } from '../logger';
2+
import { AuthTokenRefreshError } from '../errors/AuthTokenRefreshError';
3+
import {
4+
getFusionAuthClient,
5+
isPartialClientResponse,
6+
} from '../fusionAuth';
7+
8+
export class AuthTokenManager {
9+
public readonly username: string;
10+
11+
private readonly fusionAuthClient;
12+
13+
private refreshToken = '';
14+
15+
private authToken = '';
16+
17+
private authTokenExpiresAt = new Date();
18+
19+
private fusionAuthClientId = '';
20+
21+
private fusionAuthClientSecret = '';
22+
23+
public constructor(
24+
username: string,
25+
refreshToken: string,
26+
fusionAuthClientId: string,
27+
fusionAuthClientSecret: string,
28+
) {
29+
this.username = username;
30+
this.refreshToken = refreshToken;
31+
this.fusionAuthClientId = fusionAuthClientId;
32+
this.fusionAuthClientSecret = fusionAuthClientSecret;
33+
this.fusionAuthClient = getFusionAuthClient();
34+
}
35+
36+
public async getAuthToken() {
37+
if (this.tokenWouldExpireSoon()) {
38+
await this.resetAuthTokenUsingRefreshToken();
39+
}
40+
return this.authToken;
41+
}
42+
43+
private async resetAuthTokenUsingRefreshToken(): Promise<void> {
44+
let clientResponse;
45+
try {
46+
/**
47+
* Fusion auth sdk wrongly mandates last two params (scope, user_code)
48+
* hence the need to pass two empty strings here.
49+
* See: https://github.com/FusionAuth/fusionauth-typescript-client/issues/42
50+
*/
51+
clientResponse = await this.fusionAuthClient.exchangeRefreshTokenForAccessToken(
52+
this.refreshToken,
53+
this.fusionAuthClientId,
54+
this.fusionAuthClientSecret,
55+
'',
56+
'',
57+
);
58+
} catch (error: unknown) {
59+
let message: string;
60+
if (isPartialClientResponse(error)) {
61+
message = error.exception.error_description ?? error.exception.message;
62+
} else {
63+
message = error instanceof Error ? error.message : JSON.stringify(error);
64+
}
65+
logger.verbose(`Error obtaining refresh token: ${message}`);
66+
throw new AuthTokenRefreshError(`Error obtaining refresh token: ${message}`);
67+
}
68+
69+
if (!clientResponse.response.access_token) {
70+
logger.warn('No access token in response:', clientResponse.response);
71+
throw new AuthTokenRefreshError('Response does not contain access_token');
72+
}
73+
74+
if (!clientResponse.response.expires_in) {
75+
logger.warn('Response lacks token TTL (expires_in):', clientResponse.response);
76+
throw new AuthTokenRefreshError('Response lacks token TTL (expires_in)');
77+
}
78+
79+
/**
80+
* The exchange refresh token for access token endpoint does not return a timestamp,
81+
* it returns expires_in in seconds.
82+
* So we need to create the timestamp to be consistent with what is first
83+
* returned upon initial authentication
84+
*/
85+
this.authToken = clientResponse.response.access_token;
86+
this.authTokenExpiresAt = new Date(
87+
Date.now() + (clientResponse.response.expires_in * 1000),
88+
);
89+
logger.debug('New access token obtained:', clientResponse.response);
90+
}
91+
92+
private tokenWouldExpireSoon(expirationThresholdInSeconds = 300): boolean {
93+
const currentTime = new Date();
94+
const remainingTokenLife = (
95+
(this.authTokenExpiresAt.getTime() - currentTime.getTime()) / 1000
96+
);
97+
return remainingTokenLife <= expirationThresholdInSeconds;
98+
}
99+
}

src/classes/AuthenticationSession.ts

Lines changed: 18 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
getFusionAuthClient,
44
isPartialClientResponse,
55
} from '../fusionAuth';
6-
import { AuthTokenRefreshError } from '../errors/AuthTokenRefreshError';
76
import type { KeyboardAuthContext } from 'ssh2';
87
import type { TwoFactorMethod } from '@fusionauth/typescript-client';
98

@@ -13,108 +12,36 @@ enum FusionAuthStatusCode {
1312
SuccessNeedsTwoFactorAuth = 242,
1413
}
1514

16-
export class AuthenticationSession {
17-
private authToken = '';
18-
19-
public refreshToken = '';
20-
21-
public readonly authContext;
15+
type SuccessHandler = (refreshToken: string) => void;
2216

23-
private authTokenExpiresAt = new Date();
17+
export class AuthenticationSession {
18+
private readonly authContext;
2419

2520
private readonly fusionAuthClient;
2621

22+
private readonly successHandler: SuccessHandler;
23+
2724
private twoFactorId = '';
2825

2926
private twoFactorMethods: TwoFactorMethod[] = [];
3027

31-
private fusionAuthAppId = '';
32-
3328
private fusionAuthClientId = '';
3429

35-
private fusionAuthClientSecret = '';
36-
3730
public constructor(
3831
authContext: KeyboardAuthContext,
39-
fusionAuthAppId: string,
4032
fusionAuthClientId: string,
41-
fusionAuthClientSecret: string,
33+
successHandler: SuccessHandler,
4234
) {
4335
this.authContext = authContext;
44-
this.fusionAuthAppId = fusionAuthAppId;
4536
this.fusionAuthClientId = fusionAuthClientId;
46-
this.fusionAuthClientSecret = fusionAuthClientSecret;
4737
this.fusionAuthClient = getFusionAuthClient();
38+
this.successHandler = successHandler;
4839
}
4940

5041
public invokeAuthenticationFlow(): void {
5142
this.promptForPassword();
5243
}
5344

54-
public async getAuthToken() {
55-
if (this.tokenWouldExpireSoon()) {
56-
await this.getAuthTokenUsingRefreshToken();
57-
}
58-
return this.authToken;
59-
}
60-
61-
private async getAuthTokenUsingRefreshToken(): Promise<void> {
62-
let clientResponse;
63-
try {
64-
/**
65-
* Fusion auth sdk wrongly mandates last two params (scope, user_code)
66-
* hence the need to pass two empty strings here.
67-
* See: https://github.com/FusionAuth/fusionauth-typescript-client/issues/42
68-
*/
69-
clientResponse = await this.fusionAuthClient.exchangeRefreshTokenForAccessToken(
70-
this.refreshToken,
71-
this.fusionAuthClientId,
72-
this.fusionAuthClientSecret,
73-
'',
74-
'',
75-
);
76-
} catch (error: unknown) {
77-
let message: string;
78-
if (isPartialClientResponse(error)) {
79-
message = error.exception.message;
80-
} else {
81-
message = error instanceof Error ? error.message : JSON.stringify(error);
82-
}
83-
logger.verbose(`Error obtaining refresh token: ${message}`);
84-
throw new AuthTokenRefreshError(`Error obtaining refresh token: ${message}`);
85-
}
86-
87-
if (!clientResponse.response.access_token) {
88-
logger.warn('No access token in response:', clientResponse.response);
89-
throw new AuthTokenRefreshError('Response does not contain access_token');
90-
}
91-
92-
if (!clientResponse.response.expires_in) {
93-
logger.warn('Response lacks token TTL (expires_in):', clientResponse.response);
94-
throw new AuthTokenRefreshError('Response lacks token TTL (expires_in)');
95-
}
96-
97-
/**
98-
* The exchange refresh token for access token endpoint does not return a timestamp,
99-
* it returns expires_in in seconds.
100-
* So we need to create the timestamp to be consistent with what is first
101-
* returned upon initial authentication
102-
*/
103-
this.authToken = clientResponse.response.access_token;
104-
this.authTokenExpiresAt = new Date(
105-
Date.now() + (clientResponse.response.expires_in * 1000),
106-
);
107-
logger.debug('New access token obtained:', clientResponse.response);
108-
}
109-
110-
private tokenWouldExpireSoon(expirationThresholdInSeconds = 300): boolean {
111-
const currentTime = new Date();
112-
const remainingTokenLife = (
113-
(this.authTokenExpiresAt.getTime() - currentTime.getTime()) / 1000
114-
);
115-
return remainingTokenLife <= expirationThresholdInSeconds;
116-
}
117-
11845
private promptForPassword(): void {
11946
this.authContext.prompt(
12047
{
@@ -129,37 +56,30 @@ export class AuthenticationSession {
12956

13057
private processPasswordResponse([password]: string[]): void {
13158
this.fusionAuthClient.login({
132-
applicationId: this.fusionAuthAppId,
59+
applicationId: this.fusionAuthClientId,
13360
loginId: this.authContext.username,
13461
password,
13562
}).then((clientResponse) => {
13663
switch (clientResponse.statusCode) {
13764
case FusionAuthStatusCode.Success: {
138-
if (clientResponse.response.token !== undefined) {
139-
logger.verbose('Successful password authentication attempt.', {
140-
username: this.authContext.username,
141-
});
142-
this.authToken = clientResponse.response.token;
143-
if (clientResponse.response.refreshToken) {
144-
this.refreshToken = clientResponse.response.refreshToken;
145-
this.authTokenExpiresAt = new Date(
146-
clientResponse.response.tokenExpirationInstant ?? 0,
147-
);
148-
} else {
149-
logger.warn('No refresh token in response :', clientResponse.response);
150-
this.authContext.reject();
151-
}
65+
logger.verbose('Successful password authentication attempt.', {
66+
username: this.authContext.username,
67+
});
68+
if (clientResponse.response.refreshToken) {
69+
this.successHandler(clientResponse.response.refreshToken);
15270
this.authContext.accept();
15371
} else {
154-
logger.warn('No auth token in response', clientResponse.response);
72+
logger.warn('No refresh token in response :', clientResponse.response);
15573
this.authContext.reject();
15674
}
15775
return;
15876
}
15977
case FusionAuthStatusCode.SuccessButUnregisteredInApp: {
16078
const userId: string = clientResponse.response.user?.id ?? '';
16179
this.registerUserInApp(userId)
162-
.then(() => { this.processPasswordResponse([password]); })
80+
.then(() => {
81+
this.processPasswordResponse([password]);
82+
})
16383
.catch((error) => {
16484
logger.warn('Error during registration and authentication:', error);
16585
this.authContext.reject();
@@ -203,7 +123,7 @@ export class AuthenticationSession {
203123
try {
204124
const clientResponse = await this.fusionAuthClient.register(userId, {
205125
registration: {
206-
applicationId: this.fusionAuthAppId,
126+
applicationId: this.fusionAuthClientId,
207127
},
208128
});
209129

0 commit comments

Comments
 (0)