diff --git a/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts b/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts index 9decd11b6c5..ac46ecc7526 100644 --- a/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts +++ b/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts @@ -208,13 +208,13 @@ describe('core/auth/auth_impl', () => { }); it('onAuthStateChange does not trigger for user props change', async () => { - user.refreshToken = 'hey look I changed'; + user.photoURL = 'blah'; await auth.updateCurrentUser(user); expect(authStateCallback).not.to.have.been.called; }); it('onIdTokenChange triggers for user props change', async () => { - user.refreshToken = 'hey look I changed'; + user.photoURL = 'hey look I changed'; await auth.updateCurrentUser(user); expect(idTokenCallback).to.have.been.calledWith(user); }); diff --git a/packages-exp/auth-exp/src/core/auth/auth_impl.ts b/packages-exp/auth-exp/src/core/auth/auth_impl.ts index 1b5c78966eb..4dda3a16be0 100644 --- a/packages-exp/auth-exp/src/core/auth/auth_impl.ts +++ b/packages-exp/auth-exp/src/core/auth/auth_impl.ts @@ -81,7 +81,7 @@ export class AuthImpl implements Auth { } this._isInitialized = true; - this._notifyStateListeners(); + this.notifyAuthListeners(); }); } @@ -89,12 +89,15 @@ export class AuthImpl implements Auth { throw new Error('Method not implemented.'); } - updateCurrentUser(user: User | null): Promise { - return this.queue(() => this.directlySetCurrentUser(user)); + async updateCurrentUser(user: User | null): Promise { + return this.queue(async () => { + await this.directlySetCurrentUser(user); + this.notifyAuthListeners(); + }); } - signOut(): Promise { - return this.queue(() => this.directlySetCurrentUser(null)); + async signOut(): Promise { + return this.updateCurrentUser(null); } setPersistence(persistence: Persistence): Promise { @@ -129,7 +132,20 @@ export class AuthImpl implements Auth { ); } - _notifyStateListeners(): void { + async _persistUserIfCurrent(user: User): Promise { + if (user === this.currentUser) { + return this.queue(async () => this.directlySetCurrentUser(user)); + } + } + + /** Notifies listeners only if the user is current */ + _notifyListenersIfCurrent(user: User): void { + if (user === this.currentUser) { + this.notifyAuthListeners(); + } + } + + private notifyAuthListeners(): void { if (!this._isInitialized) { return; } @@ -178,8 +194,6 @@ export class AuthImpl implements Auth { } else { await this.assertedPersistence.removeCurrentUser(); } - - this._notifyStateListeners(); } private queue(action: AsyncAction): Promise { diff --git a/packages-exp/auth-exp/src/core/strategies/credential.ts b/packages-exp/auth-exp/src/core/strategies/credential.ts index e70380756f5..b19bf297ac4 100644 --- a/packages-exp/auth-exp/src/core/strategies/credential.ts +++ b/packages-exp/auth-exp/src/core/strategies/credential.ts @@ -15,16 +15,20 @@ * limitations under the License. */ +import * as externs from '@firebase/auth-types-exp'; import { OperationType, UserCredential } from '@firebase/auth-types-exp'; + import { Auth } from '../../model/auth'; import { AuthCredential } from '../../model/auth_credential'; import { User } from '../../model/user'; import { UserCredentialImpl } from '../user/user_credential_impl'; export async function signInWithCredential( - auth: Auth, - credential: AuthCredential + authExtern: externs.Auth, + credentialExtern: externs.AuthCredential ): Promise { + const auth = authExtern as Auth; + const credential = credentialExtern as AuthCredential; // TODO: handle mfa by wrapping with callApiWithMfaContext const response = await credential._getIdTokenResponse(auth); const userCredential = await UserCredentialImpl._fromIdTokenResponse( diff --git a/packages-exp/auth-exp/src/core/user/account_info.test.ts b/packages-exp/auth-exp/src/core/user/account_info.test.ts new file mode 100644 index 00000000000..ae1e989a389 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/account_info.test.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +import { ProviderId, UserInfo } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { TestAuth, testAuth, testUser } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { User } from '../../model/user'; +import { updateEmail, updatePassword, updateProfile } from './account_info'; + +use(chaiAsPromised); +use(sinonChai); + +const PASSWORD_PROVIDER: UserInfo = { + providerId: ProviderId.PASSWORD, + uid: 'uid', + email: 'email', + displayName: 'old-name', + phoneNumber: 'phone-number', + photoURL: 'old-url' +}; + +describe('core/user/profile', () => { + let user: User; + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + user = testUser(auth, 'uid', '', true); + fetch.setUp(); + }); + + afterEach(() => { + sinon.restore(); + fetch.tearDown(); + }); + + describe('#updateProfile', () => { + it('returns immediately if profile object is empty', async () => { + const ep = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + await updateProfile(user, {}); + expect(ep.calls).to.be.empty; + }); + + it('calls the setAccountInfo endpoint', async () => { + const ep = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + expect(ep.calls[0].request).to.eql({ + idToken: 'access-token', + displayName: 'displayname', + photoUrl: 'photo' + }); + }); + + it('sets the fields on the user based on the response', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + displayName: 'response-name', + photoUrl: 'response-photo' + }); + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + expect(user.displayName).to.eq('response-name'); + expect(user.photoURL).to.eq('response-photo'); + }); + + it('sets the fields on the password provider', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + displayName: 'response-name', + photoUrl: 'response-photo' + }); + user.providerData = [{ ...PASSWORD_PROVIDER }]; + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + const provider = user.providerData[0]; + expect(provider.displayName).to.eq('response-name'); + expect(provider.photoURL).to.eq('response-photo'); + }); + }); + + describe('#updateEmail', () => { + it('calls the setAccountInfo endpoint and reloads the user', async () => { + const set = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'new-uid-to-prove-refresh-got-called' }] + }); + + await updateEmail(user, 'hello@test.com'); + expect(set.calls[0].request).to.eql({ + idToken: 'access-token', + email: 'hello@test.com' + }); + + expect(user.uid).to.eq('new-uid-to-prove-refresh-got-called'); + }); + }); + + describe('#updatePassword', () => { + it('calls the setAccountInfo endpoint and reloads the user', async () => { + const set = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'new-uid-to-prove-refresh-got-called' }] + }); + + await updatePassword(user, 'pass'); + expect(set.calls[0].request).to.eql({ + idToken: 'access-token', + password: 'pass' + }); + + expect(user.uid).to.eq('new-uid-to-prove-refresh-got-called'); + }); + }); + + describe('notifications', () => { + let idTokenChange: sinon.SinonStub; + + beforeEach(async () => { + idTokenChange = sinon.stub(); + auth.onIdTokenChanged(idTokenChange); + + // Flush token change promises which are floating + await auth.updateCurrentUser(user); + auth._isInitialized = true; + idTokenChange.resetHistory(); + }); + + describe('#updateProfile', () => { + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updateProfile(user, { displayName: 'd' }); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updateProfile(user, { displayName: 'd' }); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + + describe('#updateEmail', () => { + beforeEach(() => { + // This is necessary because this method calls reload; we don't care about that though, + // for these tests we're looking at the change listeners + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { users: [{}] }); + }); + + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'email@test.com'); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updateEmail(user, 'email@test.com'); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + + describe('#updatePassword', () => { + beforeEach(() => { + // This is necessary because this method calls reload; we don't care about that though, + // for these tests we're looking at the change listeners + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { users: [{}] }); + }); + + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'pass'); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'pass'); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/account_info.ts b/packages-exp/auth-exp/src/core/user/account_info.ts new file mode 100644 index 00000000000..5c7d01c93c8 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/account_info.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as externs from '@firebase/auth-types-exp'; + +import { + updateEmailPassword as apiUpdateEmailPassword, + UpdateEmailPasswordRequest +} from '../../api/account_management/email_and_password'; +import { updateProfile as apiUpdateProfile } from '../../api/account_management/profile'; +import { User } from '../../model/user'; +import { _reloadWithoutSaving } from './reload'; + +interface Profile { + displayName?: string | null; + photoURL?: string | null; +} + +export async function updateProfile( + externUser: externs.User, + { displayName, photoURL: photoUrl }: Profile +): Promise { + if (displayName === undefined && photoUrl === undefined) { + return; + } + + const user = externUser as User; + const { auth } = user; + const idToken = await user.getIdToken(); + const profileRequest = { idToken, displayName, photoUrl }; + const response = await apiUpdateProfile(user.auth, profileRequest); + + user.displayName = response.displayName || null; + user.photoURL = response.photoUrl || null; + + // Update the password provider as well + const passwordProvider = user.providerData.find( + ({ providerId }) => providerId === externs.ProviderId.PASSWORD + ); + if (passwordProvider) { + passwordProvider.displayName = user.displayName; + passwordProvider.photoURL = user.photoURL; + } + + const tokensRefreshed = user._updateTokensIfNecessary(response); + await auth._persistUserIfCurrent(user); + if (tokensRefreshed) { + auth._notifyListenersIfCurrent(user); + } +} + +export function updateEmail( + externUser: externs.User, + newEmail: string +): Promise { + const user = externUser as User; + return updateEmailOrPassword(user, newEmail, null); +} + +export function updatePassword( + externUser: externs.User, + newPassword: string +): Promise { + const user = externUser as User; + return updateEmailOrPassword(user, null, newPassword); +} + +async function updateEmailOrPassword( + user: User, + email: string | null, + password: string | null +): Promise { + const { auth } = user; + const idToken = await user.getIdToken(); + const request: UpdateEmailPasswordRequest = { idToken }; + + if (email) { + request.email = email; + } + + if (password) { + request.password = password; + } + + const response = await apiUpdateEmailPassword(auth, request); + + const tokensRefreshed = user._updateTokensIfNecessary(response); + await _reloadWithoutSaving(user); + await auth._persistUserIfCurrent(user); + if (tokensRefreshed) { + auth._notifyListenersIfCurrent(user); + } +} diff --git a/packages-exp/auth-exp/src/core/user/reload.test.ts b/packages-exp/auth-exp/src/core/user/reload.test.ts index 151c008f473..ca3333b3785 100644 --- a/packages-exp/auth-exp/src/core/user/reload.test.ts +++ b/packages-exp/auth-exp/src/core/user/reload.test.ts @@ -17,6 +17,7 @@ import { expect, use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; import { ProviderId, UserInfo } from '@firebase/auth-types-exp'; @@ -150,13 +151,19 @@ describe('core/user/reload', () => { ]); }); - it('reload triggers a persistence change after completion', async () => { + it('reload persists the object and notifies listeners', async () => { mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { users: [{}] }); const user = testUser(auth, 'user', '', true); + user.auth.currentUser = user; + + const cb = sinon.stub(); + user.auth.onIdTokenChanged(cb); + await reload(user); + expect(cb).to.have.been.calledWith(user); expect(auth.persistenceLayer.lastObjectSet).to.eql(user.toPlainObject()); }); }); diff --git a/packages-exp/auth-exp/src/core/user/reload.ts b/packages-exp/auth-exp/src/core/user/reload.ts index 174fbaaf044..e0fec052988 100644 --- a/packages-exp/auth-exp/src/core/user/reload.ts +++ b/packages-exp/auth-exp/src/core/user/reload.ts @@ -60,7 +60,8 @@ export async function reload(externUser: externs.User): Promise { // Even though the current user hasn't changed, update // current user will trigger a persistence update w/ the // new info. - return user.auth.updateCurrentUser(user); + await user.auth._persistUserIfCurrent(user); + user.auth._notifyListenersIfCurrent(user); } function mergeProviderData( diff --git a/packages-exp/auth-exp/src/core/user/user_impl.ts b/packages-exp/auth-exp/src/core/user/user_impl.ts index a6b785f3c78..ffec2605b47 100644 --- a/packages-exp/auth-exp/src/core/user/user_impl.ts +++ b/packages-exp/auth-exp/src/core/user/user_impl.ts @@ -51,7 +51,6 @@ export class UserImpl implements User { // For the user object, provider is always Firebase. readonly providerId = ProviderId.FIREBASE; stsTokenManager: StsTokenManager; - refreshToken = ''; uid: string; auth: Auth; @@ -81,11 +80,11 @@ export class UserImpl implements User { const tokens = await this.stsTokenManager.getToken(this.auth, forceRefresh); assert(tokens, this.auth.name); - const { refreshToken, accessToken, wasRefreshed } = tokens; - this.refreshToken = refreshToken || ''; + const { accessToken, wasRefreshed } = tokens; - if (wasRefreshed && this.auth.currentUser === this) { - this.auth._notifyStateListeners(); + if (wasRefreshed) { + await this.auth._persistUserIfCurrent(this); + this.auth._notifyListenersIfCurrent(this); } return accessToken; @@ -99,6 +98,18 @@ export class UserImpl implements User { return reload(this); } + _updateTokensIfNecessary(response: IdTokenResponse): boolean { + if ( + response.idToken && + response.idToken !== this.stsTokenManager.accessToken + ) { + this.stsTokenManager.updateFromServerResponse(response); + return true; + } + + return false; + } + async delete(): Promise { const idToken = await this.getIdToken(); await deleteAccount(this.auth, { idToken }); @@ -121,6 +132,10 @@ export class UserImpl implements User { }; } + get refreshToken(): string { + return this.stsTokenManager.refreshToken || ''; + } + static fromPlainObject(auth: Auth, object: PersistedBlob): User { const { uid, diff --git a/packages-exp/auth-exp/src/index.ts b/packages-exp/auth-exp/src/index.ts index a6fcb0c329a..14d28b3c47e 100644 --- a/packages-exp/auth-exp/src/index.ts +++ b/packages-exp/auth-exp/src/index.ts @@ -27,6 +27,7 @@ export { inMemoryPersistence } from './core/persistence/in_memory'; export { indexedDBLocalPersistence } from './core/persistence/indexed_db'; // core/strategies +export { signInWithCredential } from './core/strategies/credential'; export { sendPasswordResetEmail, confirmPasswordReset, @@ -43,6 +44,11 @@ export { } from './core/strategies/email'; // core/user +export { + updateProfile, + updateEmail, + updatePassword +} from './core/user/account_info'; export { getIdToken, getIdTokenResult } from './core/user/id_token_result'; export { reload } from './core/user/reload'; diff --git a/packages-exp/auth-exp/src/model/auth.d.ts b/packages-exp/auth-exp/src/model/auth.d.ts index d524bb00d96..9e4366ab964 100644 --- a/packages-exp/auth-exp/src/model/auth.d.ts +++ b/packages-exp/auth-exp/src/model/auth.d.ts @@ -51,7 +51,8 @@ export interface Auth extends externs.Auth { error?: ErrorFn, completed?: CompleteFn ): Unsubscribe; - _notifyStateListeners(): void; + _notifyListenersIfCurrent(user: User): void; + _persistUserIfCurrent(user: User): Promise; } export interface Dependencies { diff --git a/packages-exp/auth-exp/src/model/user.d.ts b/packages-exp/auth-exp/src/model/user.d.ts index 539e75a6626..72596d73afe 100644 --- a/packages-exp/auth-exp/src/model/user.d.ts +++ b/packages-exp/auth-exp/src/model/user.d.ts @@ -19,6 +19,11 @@ import * as externs from '@firebase/auth-types-exp'; import { PersistedBlob } from '../core/persistence'; import { Auth } from './auth'; +import { IdTokenResponse } from './id_token'; + +type MutableUserInfo = { + -readonly [K in keyof externs.UserInfo]: externs.UserInfo[K]; +}; export interface User extends externs.User { uid: string; @@ -32,8 +37,9 @@ export interface User extends externs.User { refreshToken: string; emailVerified: boolean; tenantId: string | null; - providerData: externs.UserInfo[]; + providerData: MutableUserInfo[]; metadata: externs.UserMetadata; + _updateTokensIfNecessary(response: IdTokenResponse): boolean; getIdToken(forceRefresh?: boolean): Promise; getIdTokenResult(forceRefresh?: boolean): Promise;