diff --git a/.changeset/twelve-actors-enjoy.md b/.changeset/twelve-actors-enjoy.md new file mode 100644 index 00000000000..e599c5bc68a --- /dev/null +++ b/.changeset/twelve-actors-enjoy.md @@ -0,0 +1,6 @@ +--- +'@firebase/auth': minor +'firebase': minor +--- + +Implemented `authStateReady()`, which returns a promise that resolves immediately when the initial auth state is settled and currentUser is available. When the promise is resolved, the current user might be a valid user or null if there is no user signed in currently. diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index f53b81f8280..74ab8839fe0 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -81,6 +81,7 @@ export function applyActionCode(auth: Auth, oobCode: string): Promise; // @public export interface Auth { readonly app: FirebaseApp; + authStateReady(): Promise; beforeAuthStateChanged(callback: (user: User | null) => void | Promise, onAbort?: () => void): Unsubscribe; readonly config: Config; readonly currentUser: User | null; diff --git a/docs-devsite/auth.auth.md b/docs-devsite/auth.auth.md index 4f49465a094..bea0aae514f 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -37,6 +37,7 @@ export interface Auth | Method | Description | | --- | --- | +| [authStateReady()](./auth.auth.md#authauthstateready) | returns a promise that resolves immediately when the initial auth state is settled. When the promise resolves, the current user might be a valid user or null if the user signed out. | | [beforeAuthStateChanged(callback, onAbort)](./auth.auth.md#authbeforeauthstatechanged) | Adds a blocking callback that runs before an auth state change sets a new user. | | [onAuthStateChanged(nextOrObserver, error, completed)](./auth.auth.md#authonauthstatechanged) | Adds an observer for changes to the user's sign-in state. | | [onIdTokenChanged(nextOrObserver, error, completed)](./auth.auth.md#authonidtokenchanged) | Adds an observer for changes to the signed-in user's ID token. | @@ -144,6 +145,19 @@ const result = await signInWithEmailAndPassword(auth, email, password); ``` +## Auth.authStateReady() + +returns a promise that resolves immediately when the initial auth state is settled. When the promise resolves, the current user might be a valid user or `null` if the user signed out. + +Signature: + +```typescript +authStateReady(): Promise; +``` +Returns: + +Promise<void> + ## Auth.beforeAuthStateChanged() Adds a blocking callback that runs before an auth state change sets a new user. diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index a3abe1e3f37..8a1037c2f72 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -114,7 +114,7 @@ const providersIcons = { }; /** - * Returns the active user (i.e. currentUser or lastUser). + * Returns active user (currentUser or lastUser). * @return {!firebase.User} */ function activeUser() { @@ -126,63 +126,88 @@ function activeUser() { } } +/** + * Blocks until there is a valid user + * then returns the valid user (currentUser or lastUser). + * @return {!firebase.User} + */ +async function getActiveUserBlocking() { + const type = $('input[name=toggle-user-selection]:checked').val(); + if (type === 'lastUser') { + return lastUser; + } else { + try { + await auth.authStateReady(); + return auth.currentUser; + } catch (e) { + log(e); + } + } +} + /** * Refreshes the current user data in the UI, displaying a user info box if * a user is signed in, or removing it. */ -function refreshUserData() { - if (activeUser()) { - const user = activeUser(); - $('.profile').show(); - $('body').addClass('user-info-displayed'); - $('div.profile-email,span.profile-email').text(user.email || 'No Email'); - $('div.profile-phone,span.profile-phone').text( - user.phoneNumber || 'No Phone' - ); - $('div.profile-uid,span.profile-uid').text(user.uid); - $('div.profile-name,span.profile-name').text(user.displayName || 'No Name'); - $('input.profile-name').val(user.displayName); - $('input.photo-url').val(user.photoURL); - if (user.photoURL != null) { - let photoURL = user.photoURL; - // Append size to the photo URL for Google hosted images to avoid requesting - // the image with its original resolution (using more bandwidth than needed) - // when it is going to be presented in smaller size. - if ( - photoURL.indexOf('googleusercontent.com') !== -1 || - photoURL.indexOf('ggpht.com') !== -1 - ) { - photoURL = photoURL + '?sz=' + $('img.profile-image').height(); +async function refreshUserData() { + try { + let user = await getActiveUserBlocking(); + if (user) { + $('.profile').show(); + $('body').addClass('user-info-displayed'); + $('div.profile-email,span.profile-email').text(user.email || 'No Email'); + $('div.profile-phone,span.profile-phone').text( + user.phoneNumber || 'No Phone' + ); + $('div.profile-uid,span.profile-uid').text(user.uid); + $('div.profile-name,span.profile-name').text( + user.displayName || 'No Name' + ); + $('input.profile-name').val(user.displayName); + $('input.photo-url').val(user.photoURL); + if (user.photoURL != null) { + let photoURL = user.photoURL; + // Append size to the photo URL for Google hosted images to avoid requesting + // the image with its original resolution (using more bandwidth than needed) + // when it is going to be presented in smaller size. + if ( + photoURL.indexOf('googleusercontent.com') !== -1 || + photoURL.indexOf('ggpht.com') !== -1 + ) { + photoURL = photoURL + '?sz=' + $('img.profile-image').height(); + } + $('img.profile-image').attr('src', photoURL).show(); + } else { + $('img.profile-image').hide(); } - $('img.profile-image').attr('src', photoURL).show(); - } else { - $('img.profile-image').hide(); - } - $('.profile-email-verified').toggle(user.emailVerified); - $('.profile-email-not-verified').toggle(!user.emailVerified); - $('.profile-anonymous').toggle(user.isAnonymous); - // Display/Hide providers icons. - $('.profile-providers').empty(); - if (user['providerData'] && user['providerData'].length) { - const providersCount = user['providerData'].length; - for (let i = 0; i < providersCount; i++) { - addProviderIcon(user['providerData'][i]['providerId']); + $('.profile-email-verified').toggle(user.emailVerified); + $('.profile-email-not-verified').toggle(!user.emailVerified); + $('.profile-anonymous').toggle(user.isAnonymous); + // Display/Hide providers icons. + $('.profile-providers').empty(); + if (user['providerData'] && user['providerData'].length) { + const providersCount = user['providerData'].length; + for (let i = 0; i < providersCount; i++) { + addProviderIcon(user['providerData'][i]['providerId']); + } + } + // Show enrolled second factors if available for the active user. + showMultiFactorStatus(user); + // Change color. + if (user === auth.currentUser) { + $('#user-info').removeClass('last-user'); + $('#user-info').addClass('current-user'); + } else { + $('#user-info').removeClass('current-user'); + $('#user-info').addClass('last-user'); } - } - // Show enrolled second factors if available for the active user. - showMultiFactorStatus(user); - // Change color. - if (user === auth.currentUser) { - $('#user-info').removeClass('last-user'); - $('#user-info').addClass('current-user'); } else { - $('#user-info').removeClass('current-user'); - $('#user-info').addClass('last-user'); + $('.profile').slideUp(); + $('body').removeClass('user-info-displayed'); + $('input.profile-data').val(''); } - } else { - $('.profile').slideUp(); - $('body').removeClass('user-info-displayed'); - $('input.profile-data').val(''); + } catch (error) { + log(error); } } @@ -456,7 +481,7 @@ function onReauthenticateWithEmailAndPassword() { reauthenticateWithCredential(activeUser(), credential).then(result => { logAdditionalUserInfo(result); refreshUserData(); - alertSuccess('User reauthenticated with email/password!'); + alertSuccess('User reauthenticated with email/password'); }, onAuthError); } @@ -1052,7 +1077,7 @@ function onApplyActionCode() { * or not. */ function getIdToken(forceRefresh) { - if (activeUser() == null) { + if (!activeUser()) { alertError('No user logged in.'); return; } @@ -1077,7 +1102,7 @@ function getIdToken(forceRefresh) { * or not */ function getIdTokenResult(forceRefresh) { - if (activeUser() == null) { + if (!activeUser()) { alertError('No user logged in.'); return; } diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 5435489c878..55fb6a65a5b 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -786,4 +786,111 @@ describe('core/auth/auth_impl', () => { expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigOFF); }); }); + + describe('AuthStateReady', () => { + let user: UserInternal; + let authStateChangedSpy: sinon.SinonSpy; + + beforeEach(async () => { + user = testUser(auth, 'uid'); + + authStateChangedSpy = sinon.spy(auth, 'onAuthStateChanged'); + + await auth._updateCurrentUser(null); + }); + + it('immediately returns resolved promise if the user is previously logged in', async () => { + await auth._updateCurrentUser(user); + + await auth + .authStateReady() + .then(() => { + expect(authStateChangedSpy).to.not.have.been.called; + expect(auth.currentUser).to.eq(user); + }) + .catch(error => { + throw new Error(error); + }); + }); + + it('calls onAuthStateChanged if there is no currentUser available, and returns resolved promise once the user is updated', async () => { + expect(authStateChangedSpy).to.not.have.been.called; + const promiseVar = auth.authStateReady(); + expect(authStateChangedSpy).to.be.calledOnce; + + await auth._updateCurrentUser(user); + + await promiseVar + .then(() => { + expect(auth.currentUser).to.eq(user); + }) + .catch(error => { + throw new Error(error); + }); + + expect(authStateChangedSpy).to.be.calledOnce; + }); + + it('resolves the promise during repeated logout', async () => { + expect(authStateChangedSpy).to.not.have.been.called; + const promiseVar = auth.authStateReady(); + expect(authStateChangedSpy).to.be.calledOnce; + + await auth._updateCurrentUser(null); + + await promiseVar + .then(() => { + expect(auth.currentUser).to.eq(null); + }) + .catch(error => { + throw new Error(error); + }); + + expect(authStateChangedSpy).to.be.calledOnce; + }); + + it('resolves the promise with currentUser being null during log in failure', async () => { + expect(authStateChangedSpy).to.not.have.been.called; + const promiseVar = auth.authStateReady(); + expect(authStateChangedSpy).to.be.calledOnce; + + const auth2 = await testAuth(); + Object.assign(auth2.config, { apiKey: 'not-the-right-auth' }); + const user = testUser(auth2, 'uid'); + await expect(auth.updateCurrentUser(user)).to.be.rejectedWith( + FirebaseError, + 'auth/invalid-user-token' + ); + + await promiseVar + .then(() => { + expect(auth.currentUser).to.eq(null); + }) + .catch(error => { + throw new Error(error); + }); + + expect(authStateChangedSpy).to.be.calledOnce; + }); + + it('resolves the promise in a delayed user log in process', async () => { + setTimeout(async () => { + await auth._updateCurrentUser(user); + }, 5000); + + const promiseVar = auth.authStateReady(); + expect(auth.currentUser).to.eq(null); + expect(authStateChangedSpy).to.be.calledOnce; + + await setTimeout(() => { + promiseVar + .then(async () => { + await expect(auth.currentUser).to.eq(user); + }) + .catch(error => { + throw new Error(error); + }); + }, 10000); + }); + }); }); diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index d7308c03fcc..be4c3b2d7c7 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -467,6 +467,19 @@ export class AuthImpl implements AuthInternal, _FirebaseService { ); } + authStateReady(): Promise { + return new Promise((resolve, reject) => { + if (this.currentUser) { + resolve(); + } else { + const unsubscribe = this.onAuthStateChanged(() => { + unsubscribe(); + resolve(); + }, reject); + } + }); + } + toJSON(): object { return { apiKey: this.config.apiKey, diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 30f202a1b8e..87bd04c2361 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -291,6 +291,12 @@ export interface Auth { error?: ErrorFn, completed?: CompleteFn ): Unsubscribe; + /** + * returns a promise that resolves immediately when the initial + * auth state is settled. When the promise resolves, the current user might be a valid user + * or `null` if the user signed out. + */ + authStateReady(): Promise; /** The currently signed-in user (or null). */ readonly currentUser: User | null; /** The current emulator configuration (or null). */