Skip to content

Commit bf4bacb

Browse files
authored
fix: Decoupled proactive token refresh from FirebaseApp (#1194)
* fix: Decoupled proactive token refresh from FirebaseApp * fix: Defined constants for duration values * fix: Logging errors encountered while scheduling a refresh * fix: Renamed some variables for clarity
1 parent 994fd43 commit bf4bacb

File tree

4 files changed

+283
-346
lines changed

4 files changed

+283
-346
lines changed

src/database/database-internal.ts

+44
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@ import { getSdkVersion } from '../utils/index';
2828

2929
import Database = database.Database;
3030

31+
const TOKEN_REFRESH_THRESHOLD_MILLIS = 5 * 60 * 1000;
32+
3133
export class DatabaseService {
3234

3335
private readonly appInternal: FirebaseApp;
36+
private tokenListenerRegistered: boolean;
37+
private tokenRefreshTimeout: NodeJS.Timeout;
3438

3539
private databases: {
3640
[dbUrl: string]: Database;
@@ -50,6 +54,12 @@ export class DatabaseService {
5054
* @internal
5155
*/
5256
public delete(): Promise<void> {
57+
if (this.tokenListenerRegistered) {
58+
this.appInternal.INTERNAL.removeAuthTokenListener(this.onTokenChange);
59+
clearTimeout(this.tokenRefreshTimeout);
60+
this.tokenListenerRegistered = false;
61+
}
62+
5363
const promises = [];
5464
for (const dbUrl of Object.keys(this.databases)) {
5565
const db: DatabaseImpl = ((this.databases[dbUrl] as any) as DatabaseImpl);
@@ -96,9 +106,43 @@ export class DatabaseService {
96106

97107
this.databases[dbUrl] = db;
98108
}
109+
110+
if (!this.tokenListenerRegistered) {
111+
this.tokenListenerRegistered = true;
112+
this.appInternal.INTERNAL.addAuthTokenListener(this.onTokenChange);
113+
}
114+
99115
return db;
100116
}
101117

118+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
119+
private onTokenChange(_: string): void {
120+
this.appInternal.INTERNAL.getToken()
121+
.then((token) => {
122+
const delayMillis = token.expirationTime - TOKEN_REFRESH_THRESHOLD_MILLIS - Date.now();
123+
// If the new token is set to expire soon (unlikely), do nothing. Somebody will eventually
124+
// notice and refresh the token, at which point this callback will fire again.
125+
if (delayMillis > 0) {
126+
this.scheduleTokenRefresh(delayMillis);
127+
}
128+
})
129+
.catch((err) => {
130+
console.error('Unexpected error while attempting to schedule a token refresh:', err);
131+
});
132+
}
133+
134+
private scheduleTokenRefresh(delayMillis: number): void {
135+
clearTimeout(this.tokenRefreshTimeout);
136+
this.tokenRefreshTimeout = setTimeout(() => {
137+
this.appInternal.INTERNAL.getToken(/*forceRefresh=*/ true)
138+
.catch(() => {
139+
// Ignore the error since this might just be an intermittent failure. If we really cannot
140+
// refresh the token, an error will be logged once the existing token expires and we try
141+
// to fetch a fresh one.
142+
});
143+
}, delayMillis);
144+
}
145+
102146
private ensureUrl(url?: string): string {
103147
if (typeof url !== 'undefined') {
104148
return url;

src/firebase-app.ts

+65-145
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { AppOptions, app } from './firebase-namespace-api';
19-
import { credential, GoogleOAuthAccessToken } from './credential/index';
19+
import { credential } from './credential/index';
2020
import { getApplicationDefault } from './credential/credential-internal';
2121
import * as validator from './utils/validator';
2222
import { deepCopy } from './utils/deep-copy';
@@ -39,6 +39,8 @@ import { RemoteConfig } from './remote-config/remote-config';
3939
import Credential = credential.Credential;
4040
import Database = database.Database;
4141

42+
const TOKEN_EXPIRY_THRESHOLD_MILLIS = 5 * 60 * 1000;
43+
4244
/**
4345
* Type representing a callback which is called every time an app lifecycle event occurs.
4446
*/
@@ -57,129 +59,80 @@ export interface FirebaseAccessToken {
5759
* Internals of a FirebaseApp instance.
5860
*/
5961
export class FirebaseAppInternals {
60-
private isDeleted_ = false;
6162
private cachedToken_: FirebaseAccessToken;
62-
private cachedTokenPromise_: Promise<FirebaseAccessToken> | null;
6363
private tokenListeners_: Array<(token: string) => void>;
64-
private tokenRefreshTimeout_: NodeJS.Timer;
6564

6665
constructor(private credential_: Credential) {
6766
this.tokenListeners_ = [];
6867
}
6968

70-
/**
71-
* Gets an auth token for the associated app.
72-
*
73-
* @param {boolean} forceRefresh Whether or not to force a token refresh.
74-
* @return {Promise<FirebaseAccessToken>} A Promise that will be fulfilled with the current or
75-
* new token.
76-
*/
77-
public getToken(forceRefresh?: boolean): Promise<FirebaseAccessToken> {
78-
const expired = this.cachedToken_ && this.cachedToken_.expirationTime < Date.now();
79-
if (this.cachedTokenPromise_ && !forceRefresh && !expired) {
80-
return this.cachedTokenPromise_
81-
.catch((error) => {
82-
// Update the cached token promise to avoid caching errors. Set it to resolve with the
83-
// cached token if we have one (and return that promise since the token has still not
84-
// expired).
85-
if (this.cachedToken_) {
86-
this.cachedTokenPromise_ = Promise.resolve(this.cachedToken_);
87-
return this.cachedTokenPromise_;
88-
}
89-
90-
// Otherwise, set the cached token promise to null so that it will force a refresh next
91-
// time getToken() is called.
92-
this.cachedTokenPromise_ = null;
93-
94-
// And re-throw the caught error.
95-
throw error;
96-
});
97-
} else {
98-
// Clear the outstanding token refresh timeout. This is a noop if the timeout is undefined.
99-
clearTimeout(this.tokenRefreshTimeout_);
100-
101-
// this.credential_ may be an external class; resolving it in a promise helps us
102-
// protect against exceptions and upgrades the result to a promise in all cases.
103-
this.cachedTokenPromise_ = Promise.resolve(this.credential_.getAccessToken())
104-
.then((result: GoogleOAuthAccessToken) => {
105-
// Since the developer can provide the credential implementation, we want to weakly verify
106-
// the return type until the type is properly exported.
107-
if (!validator.isNonNullObject(result) ||
108-
typeof result.expires_in !== 'number' ||
109-
typeof result.access_token !== 'string') {
110-
throw new FirebaseAppError(
111-
AppErrorCodes.INVALID_CREDENTIAL,
112-
`Invalid access token generated: "${JSON.stringify(result)}". Valid access ` +
113-
'tokens must be an object with the "expires_in" (number) and "access_token" ' +
114-
'(string) properties.',
115-
);
116-
}
117-
118-
const token: FirebaseAccessToken = {
119-
accessToken: result.access_token,
120-
expirationTime: Date.now() + (result.expires_in * 1000),
121-
};
122-
123-
const hasAccessTokenChanged = (this.cachedToken_ && this.cachedToken_.accessToken !== token.accessToken);
124-
const hasExpirationChanged = (this.cachedToken_ && this.cachedToken_.expirationTime !== token.expirationTime);
125-
if (!this.cachedToken_ || hasAccessTokenChanged || hasExpirationChanged) {
126-
this.cachedToken_ = token;
127-
this.tokenListeners_.forEach((listener) => {
128-
listener(token.accessToken);
129-
});
130-
}
131-
132-
// Establish a timeout to proactively refresh the token every minute starting at five
133-
// minutes before it expires. Once a token refresh succeeds, no further retries are
134-
// needed; if it fails, retry every minute until the token expires (resulting in a total
135-
// of four retries: at 4, 3, 2, and 1 minutes).
136-
let refreshTimeInSeconds = (result.expires_in - (5 * 60));
137-
let numRetries = 4;
138-
139-
// In the rare cases the token is short-lived (that is, it expires in less than five
140-
// minutes from when it was fetched), establish the timeout to refresh it after the
141-
// current minute ends and update the number of retries that should be attempted before
142-
// the token expires.
143-
if (refreshTimeInSeconds <= 0) {
144-
refreshTimeInSeconds = result.expires_in % 60;
145-
numRetries = Math.floor(result.expires_in / 60) - 1;
146-
}
147-
148-
// The token refresh timeout keeps the Node.js process alive, so only create it if this
149-
// instance has not already been deleted.
150-
if (numRetries && !this.isDeleted_) {
151-
this.setTokenRefreshTimeout(refreshTimeInSeconds * 1000, numRetries);
152-
}
153-
154-
return token;
155-
})
156-
.catch((error) => {
157-
let errorMessage = (typeof error === 'string') ? error : error.message;
158-
159-
errorMessage = 'Credential implementation provided to initializeApp() via the ' +
160-
'"credential" property failed to fetch a valid Google OAuth2 access token with the ' +
161-
`following error: "${errorMessage}".`;
162-
163-
if (errorMessage.indexOf('invalid_grant') !== -1) {
164-
errorMessage += ' There are two likely causes: (1) your server time is not properly ' +
165-
'synced or (2) your certificate key file has been revoked. To solve (1), re-sync the ' +
166-
'time on your server. To solve (2), make sure the key ID for your key file is still ' +
167-
'present at https://console.firebase.google.com/iam-admin/serviceaccounts/project. If ' +
168-
'not, generate a new key file at ' +
169-
'https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk.';
170-
}
171-
172-
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage);
173-
});
174-
175-
return this.cachedTokenPromise_;
69+
public getToken(forceRefresh = false): Promise<FirebaseAccessToken> {
70+
if (forceRefresh || this.shouldRefresh()) {
71+
return this.refreshToken();
17672
}
73+
74+
return Promise.resolve(this.cachedToken_);
75+
}
76+
77+
private refreshToken(): Promise<FirebaseAccessToken> {
78+
return Promise.resolve(this.credential_.getAccessToken())
79+
.then((result) => {
80+
// Since the developer can provide the credential implementation, we want to weakly verify
81+
// the return type until the type is properly exported.
82+
if (!validator.isNonNullObject(result) ||
83+
typeof result.expires_in !== 'number' ||
84+
typeof result.access_token !== 'string') {
85+
throw new FirebaseAppError(
86+
AppErrorCodes.INVALID_CREDENTIAL,
87+
`Invalid access token generated: "${JSON.stringify(result)}". Valid access ` +
88+
'tokens must be an object with the "expires_in" (number) and "access_token" ' +
89+
'(string) properties.',
90+
);
91+
}
92+
93+
const token = {
94+
accessToken: result.access_token,
95+
expirationTime: Date.now() + (result.expires_in * 1000),
96+
};
97+
if (!this.cachedToken_
98+
|| this.cachedToken_.accessToken !== token.accessToken
99+
|| this.cachedToken_.expirationTime !== token.expirationTime) {
100+
this.cachedToken_ = token;
101+
this.tokenListeners_.forEach((listener) => {
102+
listener(token.accessToken);
103+
});
104+
}
105+
106+
return token;
107+
})
108+
.catch((error) => {
109+
let errorMessage = (typeof error === 'string') ? error : error.message;
110+
111+
errorMessage = 'Credential implementation provided to initializeApp() via the ' +
112+
'"credential" property failed to fetch a valid Google OAuth2 access token with the ' +
113+
`following error: "${errorMessage}".`;
114+
115+
if (errorMessage.indexOf('invalid_grant') !== -1) {
116+
errorMessage += ' There are two likely causes: (1) your server time is not properly ' +
117+
'synced or (2) your certificate key file has been revoked. To solve (1), re-sync the ' +
118+
'time on your server. To solve (2), make sure the key ID for your key file is still ' +
119+
'present at https://console.firebase.google.com/iam-admin/serviceaccounts/project. If ' +
120+
'not, generate a new key file at ' +
121+
'https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk.';
122+
}
123+
124+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage);
125+
});
126+
}
127+
128+
private shouldRefresh(): boolean {
129+
return !this.cachedToken_ || (this.cachedToken_.expirationTime - Date.now()) <= TOKEN_EXPIRY_THRESHOLD_MILLIS;
177130
}
178131

179132
/**
180133
* Adds a listener that is called each time a token changes.
181134
*
182-
* @param {function(string)} listener The listener that will be called with each new token.
135+
* @param listener The listener that will be called with each new token.
183136
*/
184137
public addAuthTokenListener(listener: (token: string) => void): void {
185138
this.tokenListeners_.push(listener);
@@ -191,42 +144,11 @@ export class FirebaseAppInternals {
191144
/**
192145
* Removes a token listener.
193146
*
194-
* @param {function(string)} listener The listener to remove.
147+
* @param listener The listener to remove.
195148
*/
196149
public removeAuthTokenListener(listener: (token: string) => void): void {
197150
this.tokenListeners_ = this.tokenListeners_.filter((other) => other !== listener);
198151
}
199-
200-
/**
201-
* Deletes the FirebaseAppInternals instance.
202-
*/
203-
public delete(): void {
204-
this.isDeleted_ = true;
205-
206-
// Clear the token refresh timeout so it doesn't keep the Node.js process alive.
207-
clearTimeout(this.tokenRefreshTimeout_);
208-
}
209-
210-
/**
211-
* Establishes timeout to refresh the Google OAuth2 access token used by the SDK.
212-
*
213-
* @param {number} delayInMilliseconds The delay to use for the timeout.
214-
* @param {number} numRetries The number of times to retry fetching a new token if the prior fetch
215-
* failed.
216-
*/
217-
private setTokenRefreshTimeout(delayInMilliseconds: number, numRetries: number): void {
218-
this.tokenRefreshTimeout_ = setTimeout(() => {
219-
this.getToken(/* forceRefresh */ true)
220-
.catch(() => {
221-
// Ignore the error since this might just be an intermittent failure. If we really cannot
222-
// refresh the token, an error will be logged once the existing token expires and we try
223-
// to fetch a fresh one.
224-
if (numRetries > 0) {
225-
this.setTokenRefreshTimeout(60 * 1000, numRetries - 1);
226-
}
227-
});
228-
}, delayInMilliseconds);
229-
}
230152
}
231153

232154
/**
@@ -419,8 +341,6 @@ export class FirebaseApp implements app.App {
419341
this.checkDestroyed_();
420342
this.firebaseInternals_.removeApp(this.name_);
421343

422-
this.INTERNAL.delete();
423-
424344
return Promise.all(Object.keys(this.services_).map((serviceName) => {
425345
const service = this.services_[serviceName];
426346
if (isStateful(service)) {

0 commit comments

Comments
 (0)