Skip to content
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
86 changes: 77 additions & 9 deletions src/firebase-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ export type FirebaseAccessToken = {
* Internals of a FirebaseApp instance.
*/
export class FirebaseAppInternals {
private isDeleted_ = false;
private cachedToken_: FirebaseAccessToken;
private cachedTokenPromise_: Promise<FirebaseAccessToken>;
private tokenListeners_: Array<(token: string) => void>;
private tokenRefreshTimeout_: NodeJS.Timer;

constructor(private credential_: Credential) {
this.tokenListeners_ = [];
Expand All @@ -54,8 +56,27 @@ export class FirebaseAppInternals {
public getToken(forceRefresh?: boolean): Promise<FirebaseAccessToken> {
const expired = this.cachedToken_ && this.cachedToken_.expirationTime < Date.now();
if (this.cachedTokenPromise_ && !forceRefresh && !expired) {
return this.cachedTokenPromise_;
return this.cachedTokenPromise_
.catch((error) => {
// Update the cached token promise to avoid caching errors. Set it to resolve with the
// cached token if we have one (and return that promise since the token has still not
// expired).
if (this.cachedToken_) {
this.cachedTokenPromise_ = Promise.resolve(this.cachedToken_);
return this.cachedTokenPromise_;
}

// Otherwise, set the cached token promise to null so that it will force a refresh next
// time getToken() is called.
this.cachedTokenPromise_ = null;

// And re-throw the caught error.
throw error;
});
} else {
// Clear the outstanding token refresh timeout. This is a noop if the timeout is undefined.
clearTimeout(this.tokenRefreshTimeout_);

// this.credential_ may be an external class; resolving it in a promise helps us
// protect against exceptions and upgrades the result to a promise in all cases.
this.cachedTokenPromise_ = Promise.resolve(this.credential_.getAccessToken())
Expand Down Expand Up @@ -87,17 +108,31 @@ export class FirebaseAppInternals {
});
}

// Establish a timeout to proactively refresh the token every minute starting at five
// minutes before it expires. Once a token refresh succeeds, no further retries are
// needed; if it fails, retry every minute until the token expires (resulting in a total
// of four retries: at 4, 3, 2, and 1 minutes).
let refreshTimeInSeconds = (result.expires_in - (5 * 60));
let numRetries = 4;

// In the rare cases the token is short-lived (that is, it expires in less than five
// minutes from when it was fetched), establish the timeout to refresh it after the
// current minute ends and update the number of retries that should be attempted before
// the token expires.
if (refreshTimeInSeconds <= 0) {
refreshTimeInSeconds = result.expires_in % 60;
numRetries = Math.floor(result.expires_in / 60) - 1;
}

// The token refresh timeout keeps the Node.js process alive, so only create it if this
// instance has not already been deleted.
if (numRetries && !this.isDeleted_) {
this.setTokenRefreshTimeout(refreshTimeInSeconds * 1000, numRetries);
}

return token;
})
.catch((error) => {
// Update the cached token promise to avoid caching errors. Set it to resolve with the
// cached token if we have one; otherwise, set it to null.
if (this.cachedToken_) {
this.cachedTokenPromise_ = Promise.resolve(this.cachedToken_);
} else {
this.cachedTokenPromise_ = null;
}

let errorMessage = (typeof error === 'string') ? error : error.message;

errorMessage = 'Credential implementation provided to initializeApp() via the ' +
Expand Down Expand Up @@ -140,6 +175,37 @@ export class FirebaseAppInternals {
public removeAuthTokenListener(listener: (token: string) => void) {
this.tokenListeners_ = this.tokenListeners_.filter((other) => other !== listener);
}

/**
* Deletes the FirebaseAppInternals instance.
*/
public delete(): void {
this.isDeleted_ = true;

// Clear the token refresh timeout so it doesn't keep the Node.js process alive.
clearTimeout(this.tokenRefreshTimeout_);
}

/**
* Establishes timeout to refresh the Google OAuth2 access token used by the SDK.
*
* @param {number} delayInMilliseconds The delay to use for the timeout.
* @param {number} numRetries The number of times to retry fetching a new token if the prior fetch
* failed.
*/
private setTokenRefreshTimeout(delayInMilliseconds: number, numRetries: number): void {
this.tokenRefreshTimeout_ = setTimeout(() => {
this.getToken(/* forceRefresh */ true)
.catch((error) => {
// Ignore the error since this might just be an intermittent failure. If we really cannot
// refresh the token, an error will be logged once the existing token expires and we try
// to fetch a fresh one.
if (numRetries > 0) {
this.setTokenRefreshTimeout(60 * 1000, numRetries - 1);
}
});
}, delayInMilliseconds);
}
}


Expand Down Expand Up @@ -278,6 +344,8 @@ export class FirebaseApp {
this.checkDestroyed_();
this.firebaseInternals_.removeApp(this.name_);

this.INTERNAL.delete();

return Promise.all(Object.keys(this.services_).map((serviceName) => {
return this.services_[serviceName].INTERNAL.delete();
})).then(() => {
Expand Down
4 changes: 3 additions & 1 deletion test/resources/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export let appOptionsNoDatabaseUrl: FirebaseAppOptions = {
};

export function app(): FirebaseApp {
return new FirebaseApp(appOptions, appName, new FirebaseNamespace().INTERNAL);
const namespaceInternals = new FirebaseNamespace().INTERNAL;
namespaceInternals.removeApp = _.noop;
return new FirebaseApp(appOptions, appName, namespaceInternals);
}

export function applicationDefaultApp(): FirebaseApp {
Expand Down
1 change: 1 addition & 0 deletions test/unit/auth/auth-api-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ describe('FirebaseAuthRequestHandler', () => {
afterEach(() => {
_.forEach(stubs, (stub) => stub.restore());
_.forEach(mockedRequests, (mockedRequest) => mockedRequest.done());
return mockApp.delete();
});

describe('Constructor', () => {
Expand Down
1 change: 1 addition & 0 deletions test/unit/auth/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('Auth', () => {

afterEach(() => {
process.env = oldProcessEnv;
return mockApp.delete();
});


Expand Down
Loading