Skip to content

Commit 1467de4

Browse files
author
Jacob Wenger
authored
Proactively refresh access tokens before expiration (#9)
1 parent 8505e1f commit 1467de4

File tree

9 files changed

+335
-22
lines changed

9 files changed

+335
-22
lines changed

src/firebase-app.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ export type FirebaseAccessToken = {
3737
* Internals of a FirebaseApp instance.
3838
*/
3939
export class FirebaseAppInternals {
40+
private isDeleted_ = false;
4041
private cachedToken_: FirebaseAccessToken;
4142
private cachedTokenPromise_: Promise<FirebaseAccessToken>;
4243
private tokenListeners_: Array<(token: string) => void>;
44+
private tokenRefreshTimeout_: NodeJS.Timer;
4345

4446
constructor(private credential_: Credential) {
4547
this.tokenListeners_ = [];
@@ -54,8 +56,27 @@ export class FirebaseAppInternals {
5456
public getToken(forceRefresh?: boolean): Promise<FirebaseAccessToken> {
5557
const expired = this.cachedToken_ && this.cachedToken_.expirationTime < Date.now();
5658
if (this.cachedTokenPromise_ && !forceRefresh && !expired) {
57-
return this.cachedTokenPromise_;
59+
return this.cachedTokenPromise_
60+
.catch((error) => {
61+
// Update the cached token promise to avoid caching errors. Set it to resolve with the
62+
// cached token if we have one (and return that promise since the token has still not
63+
// expired).
64+
if (this.cachedToken_) {
65+
this.cachedTokenPromise_ = Promise.resolve(this.cachedToken_);
66+
return this.cachedTokenPromise_;
67+
}
68+
69+
// Otherwise, set the cached token promise to null so that it will force a refresh next
70+
// time getToken() is called.
71+
this.cachedTokenPromise_ = null;
72+
73+
// And re-throw the caught error.
74+
throw error;
75+
});
5876
} else {
77+
// Clear the outstanding token refresh timeout. This is a noop if the timeout is undefined.
78+
clearTimeout(this.tokenRefreshTimeout_);
79+
5980
// this.credential_ may be an external class; resolving it in a promise helps us
6081
// protect against exceptions and upgrades the result to a promise in all cases.
6182
this.cachedTokenPromise_ = Promise.resolve(this.credential_.getAccessToken())
@@ -87,17 +108,31 @@ export class FirebaseAppInternals {
87108
});
88109
}
89110

111+
// Establish a timeout to proactively refresh the token every minute starting at five
112+
// minutes before it expires. Once a token refresh succeeds, no further retries are
113+
// needed; if it fails, retry every minute until the token expires (resulting in a total
114+
// of four retries: at 4, 3, 2, and 1 minutes).
115+
let refreshTimeInSeconds = (result.expires_in - (5 * 60));
116+
let numRetries = 4;
117+
118+
// In the rare cases the token is short-lived (that is, it expires in less than five
119+
// minutes from when it was fetched), establish the timeout to refresh it after the
120+
// current minute ends and update the number of retries that should be attempted before
121+
// the token expires.
122+
if (refreshTimeInSeconds <= 0) {
123+
refreshTimeInSeconds = result.expires_in % 60;
124+
numRetries = Math.floor(result.expires_in / 60) - 1;
125+
}
126+
127+
// The token refresh timeout keeps the Node.js process alive, so only create it if this
128+
// instance has not already been deleted.
129+
if (numRetries && !this.isDeleted_) {
130+
this.setTokenRefreshTimeout(refreshTimeInSeconds * 1000, numRetries);
131+
}
132+
90133
return token;
91134
})
92135
.catch((error) => {
93-
// Update the cached token promise to avoid caching errors. Set it to resolve with the
94-
// cached token if we have one; otherwise, set it to null.
95-
if (this.cachedToken_) {
96-
this.cachedTokenPromise_ = Promise.resolve(this.cachedToken_);
97-
} else {
98-
this.cachedTokenPromise_ = null;
99-
}
100-
101136
let errorMessage = (typeof error === 'string') ? error : error.message;
102137

103138
errorMessage = 'Credential implementation provided to initializeApp() via the ' +
@@ -140,6 +175,37 @@ export class FirebaseAppInternals {
140175
public removeAuthTokenListener(listener: (token: string) => void) {
141176
this.tokenListeners_ = this.tokenListeners_.filter((other) => other !== listener);
142177
}
178+
179+
/**
180+
* Deletes the FirebaseAppInternals instance.
181+
*/
182+
public delete(): void {
183+
this.isDeleted_ = true;
184+
185+
// Clear the token refresh timeout so it doesn't keep the Node.js process alive.
186+
clearTimeout(this.tokenRefreshTimeout_);
187+
}
188+
189+
/**
190+
* Establishes timeout to refresh the Google OAuth2 access token used by the SDK.
191+
*
192+
* @param {number} delayInMilliseconds The delay to use for the timeout.
193+
* @param {number} numRetries The number of times to retry fetching a new token if the prior fetch
194+
* failed.
195+
*/
196+
private setTokenRefreshTimeout(delayInMilliseconds: number, numRetries: number): void {
197+
this.tokenRefreshTimeout_ = setTimeout(() => {
198+
this.getToken(/* forceRefresh */ true)
199+
.catch((error) => {
200+
// Ignore the error since this might just be an intermittent failure. If we really cannot
201+
// refresh the token, an error will be logged once the existing token expires and we try
202+
// to fetch a fresh one.
203+
if (numRetries > 0) {
204+
this.setTokenRefreshTimeout(60 * 1000, numRetries - 1);
205+
}
206+
});
207+
}, delayInMilliseconds);
208+
}
143209
}
144210

145211

@@ -278,6 +344,8 @@ export class FirebaseApp {
278344
this.checkDestroyed_();
279345
this.firebaseInternals_.removeApp(this.name_);
280346

347+
this.INTERNAL.delete();
348+
281349
return Promise.all(Object.keys(this.services_).map((serviceName) => {
282350
return this.services_[serviceName].INTERNAL.delete();
283351
})).then(() => {

test/resources/mocks.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ export let appOptionsNoDatabaseUrl: FirebaseAppOptions = {
4545
};
4646

4747
export function app(): FirebaseApp {
48-
return new FirebaseApp(appOptions, appName, new FirebaseNamespace().INTERNAL);
48+
const namespaceInternals = new FirebaseNamespace().INTERNAL;
49+
namespaceInternals.removeApp = _.noop;
50+
return new FirebaseApp(appOptions, appName, namespaceInternals);
4951
}
5052

5153
export function applicationDefaultApp(): FirebaseApp {

test/unit/auth/auth-api-request.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ describe('FirebaseAuthRequestHandler', () => {
386386
afterEach(() => {
387387
_.forEach(stubs, (stub) => stub.restore());
388388
_.forEach(mockedRequests, (mockedRequest) => mockedRequest.done());
389+
return mockApp.delete();
389390
});
390391

391392
describe('Constructor', () => {

test/unit/auth/auth.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ describe('Auth', () => {
100100

101101
afterEach(() => {
102102
process.env = oldProcessEnv;
103+
return mockApp.delete();
103104
});
104105

105106

0 commit comments

Comments
 (0)