Skip to content

Commit 50b86b6

Browse files
authored
Merge pull request #192 from PermanentOrg/175-refresh-fusion-auth-token
Refresh authToken when it expires
2 parents d3d754d + c11bdd4 commit 50b86b6

9 files changed

+621
-390
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ 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}
44+
FUSION_AUTH_SFTP_CLIENT_ID=${FUSION_AUTH_SFTP_CLIENT_ID}
45+
FUSION_AUTH_SFTP_CLIENT_SECRET=${FUSION_AUTH_SFTP_CLIENT_SECRET}

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"dotenv": "^16.3.1",
6262
"logform": "^2.3.2",
6363
"node-fetch": "^2.7.0",
64+
"require-env-variable": "^4.0.1",
6465
"ssh2": "^1.14.0",
6566
"tmp": "^0.2.1",
6667
"uuid": "^9.0.1",

src/classes/AuthenticationSession.ts

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

@@ -13,25 +14,107 @@ enum FusionAuthStatusCode {
1314
}
1415

1516
export class AuthenticationSession {
16-
public authToken = '';
17+
private authToken = '';
18+
19+
public refreshToken = '';
1720

1821
public readonly authContext;
1922

23+
private authTokenExpiresAt = new Date();
24+
2025
private readonly fusionAuthClient;
2126

2227
private twoFactorId = '';
2328

2429
private twoFactorMethods: TwoFactorMethod[] = [];
2530

26-
public constructor(authContext: KeyboardAuthContext) {
31+
private fusionAuthAppId = '';
32+
33+
private fusionAuthClientId = '';
34+
35+
private fusionAuthClientSecret = '';
36+
37+
public constructor(
38+
authContext: KeyboardAuthContext,
39+
fusionAuthAppId: string,
40+
fusionAuthClientId: string,
41+
fusionAuthClientSecret: string,
42+
) {
2743
this.authContext = authContext;
44+
this.fusionAuthAppId = fusionAuthAppId;
45+
this.fusionAuthClientId = fusionAuthClientId;
46+
this.fusionAuthClientSecret = fusionAuthClientSecret;
2847
this.fusionAuthClient = getFusionAuthClient();
2948
}
3049

3150
public invokeAuthenticationFlow(): void {
3251
this.promptForPassword();
3352
}
3453

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+
35118
private promptForPassword(): void {
36119
this.authContext.prompt(
37120
{
@@ -46,50 +129,101 @@ export class AuthenticationSession {
46129

47130
private processPasswordResponse([password]: string[]): void {
48131
this.fusionAuthClient.login({
132+
applicationId: this.fusionAuthAppId,
49133
loginId: this.authContext.username,
50134
password,
51135
}).then((clientResponse) => {
52136
switch (clientResponse.statusCode) {
53-
case FusionAuthStatusCode.Success:
54-
case FusionAuthStatusCode.SuccessButUnregisteredInApp:
137+
case FusionAuthStatusCode.Success: {
55138
if (clientResponse.response.token !== undefined) {
56139
logger.verbose('Successful password authentication attempt.', {
57140
username: this.authContext.username,
58141
});
59142
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+
}
60152
this.authContext.accept();
61-
return;
153+
} else {
154+
logger.warn('No auth token in response', clientResponse.response);
155+
this.authContext.reject();
62156
}
63-
this.authContext.reject();
64157
return;
65-
case FusionAuthStatusCode.SuccessNeedsTwoFactorAuth:
158+
}
159+
case FusionAuthStatusCode.SuccessButUnregisteredInApp: {
160+
const userId: string = clientResponse.response.user?.id ?? '';
161+
this.registerUserInApp(userId)
162+
.then(() => { this.processPasswordResponse([password]); })
163+
.catch((error) => {
164+
logger.warn('Error during registration and authentication:', error);
165+
this.authContext.reject();
166+
});
167+
return;
168+
}
169+
case FusionAuthStatusCode.SuccessNeedsTwoFactorAuth: {
66170
if (clientResponse.response.twoFactorId !== undefined) {
67171
logger.verbose('Successful password authentication attempt; MFA required.', {
68172
username: this.authContext.username,
69173
});
70174
this.twoFactorId = clientResponse.response.twoFactorId;
71175
this.twoFactorMethods = clientResponse.response.methods ?? [];
72176
this.promptForTwoFactorMethod();
73-
return;
177+
} else {
178+
this.authContext.reject();
74179
}
75-
this.authContext.reject();
76180
return;
77-
default:
181+
}
182+
default: {
78183
logger.verbose('Failed password authentication attempt.', {
79184
username: this.authContext.username,
80185
response: clientResponse.response,
81186
});
82187
this.authContext.reject();
188+
}
189+
}
190+
}).catch((error) => {
191+
let message: string;
192+
if (isPartialClientResponse(error)) {
193+
message = error.exception.message;
194+
} else {
195+
message = error instanceof Error ? error.message : JSON.stringify(error);
83196
}
84-
}).catch((clientResponse: unknown) => {
85-
const message = isPartialClientResponse(clientResponse)
86-
? clientResponse.exception.message
87-
: '';
88197
logger.warn(`Unexpected exception with FusionAuth password login: ${message}`);
89198
this.authContext.reject();
90199
});
91200
}
92201

202+
private async registerUserInApp(userId: string): Promise<void> {
203+
try {
204+
const clientResponse = await this.fusionAuthClient.register(userId, {
205+
registration: {
206+
applicationId: this.fusionAuthAppId,
207+
},
208+
});
209+
210+
switch (clientResponse.statusCode) {
211+
case FusionAuthStatusCode.Success:
212+
logger.verbose('User registered successfully after authentication.', {
213+
userId,
214+
});
215+
break;
216+
default:
217+
logger.verbose('User registration after authentication failed.', {
218+
userId,
219+
response: clientResponse.response,
220+
});
221+
}
222+
} catch (error) {
223+
logger.warn('Error during user registration after authentication:', error);
224+
}
225+
}
226+
93227
private promptForTwoFactorMethod(): void {
94228
const promptOptions = this.twoFactorMethods.map(
95229
(method, index) => `[${index + 1}] ${method.method ?? ''}`,
@@ -170,10 +304,13 @@ export class AuthenticationSession {
170304
});
171305
this.authContext.reject();
172306
}
173-
}).catch((clientResponse: unknown) => {
174-
const message = isPartialClientResponse(clientResponse)
175-
? clientResponse.exception.message
176-
: '';
307+
}).catch((error) => {
308+
let message: string;
309+
if (isPartialClientResponse(error)) {
310+
message = error.exception.message;
311+
} else {
312+
message = error instanceof Error ? error.message : JSON.stringify(error);
313+
}
177314
logger.warn(`Unexpected exception with FusionAuth 2FA login: ${message}`);
178315
this.authContext.reject();
179316
});

0 commit comments

Comments
 (0)