diff --git a/.changeset/light-poets-eat.md b/.changeset/light-poets-eat.md new file mode 100644 index 00000000000..f3be44ce89c --- /dev/null +++ b/.changeset/light-poets-eat.md @@ -0,0 +1,7 @@ +--- +'@firebase/auth': minor +'@firebase/auth-compat': patch +--- + +Add `beforeAuthStateChanged()` middleware function which allows the user to provide callbacks that are run before an auth state change +sets a new user. diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 354ab2b2ee8..71abf31003e 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; + beforeAuthStateChanged(callback: (user: User | null) => void | Promise, onAbort?: () => void): Unsubscribe; readonly config: Config; readonly currentUser: User | null; readonly emulatorConfig: EmulatorConfig | null; @@ -241,6 +242,9 @@ export interface AuthSettings { appVerificationDisabledForTesting: boolean; } +// @public +export function beforeAuthStateChanged(auth: Auth, callback: (user: User | null) => void | Promise, onAbort?: () => void): Unsubscribe; + // @public export const browserLocalPersistence: Persistence; diff --git a/packages/auth-compat/src/popup_redirect.test.ts b/packages/auth-compat/src/popup_redirect.test.ts index 80bc382e4fa..e7686193e57 100644 --- a/packages/auth-compat/src/popup_redirect.test.ts +++ b/packages/auth-compat/src/popup_redirect.test.ts @@ -175,6 +175,7 @@ describe('popup_redirect/CompatPopupRedirectResolver', () => { class FakeResolver implements exp.PopupRedirectResolverInternal { _completeRedirectFn = async (): Promise => null; + _overrideRedirectResult = (): void => {}; _redirectPersistence = exp.inMemoryPersistence; _shouldInitProactively = true; diff --git a/packages/auth-compat/src/popup_redirect.ts b/packages/auth-compat/src/popup_redirect.ts index d896bc63e5f..9fc1a92dcc9 100644 --- a/packages/auth-compat/src/popup_redirect.ts +++ b/packages/auth-compat/src/popup_redirect.ts @@ -38,6 +38,7 @@ export class CompatPopupRedirectResolver resolver: exp.PopupRedirectResolver, bypassAuthState: boolean ) => Promise = exp._getRedirectResult; + _overrideRedirectResult = exp._overrideRedirectResult; async _initialize(auth: exp.AuthImpl): Promise { await this.selectUnderlyingResolver(); diff --git a/packages/auth/internal/index.ts b/packages/auth/internal/index.ts index aeaf0833e27..4745ea66558 100644 --- a/packages/auth/internal/index.ts +++ b/packages/auth/internal/index.ts @@ -45,6 +45,7 @@ export { TaggedWithTokenResponse } from '../src/model/id_token'; export { _fail, _assert } from '../src/core/util/assert'; export { AuthPopup } from '../src/platform_browser/util/popup'; export { _getRedirectResult } from '../src/platform_browser/strategies/redirect'; +export { _overrideRedirectResult } from '../src/core/strategies/redirect'; export { cordovaPopupRedirectResolver } from '../src/platform_cordova/popup_redirect/popup_redirect'; export { FetchProvider } from '../src/core/util/fetch_provider'; export { SAMLAuthCredential } from '../src/core/credentials/saml'; diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index bfc8ca75223..35198213bbd 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -34,6 +34,7 @@ import * as reload from '../user/reload'; import { AuthImpl, DefaultConfig } from './auth_impl'; import { _initializeAuthInstance } from './initialize'; import { ClientPlatform } from '../util/version'; +import { AuthErrorCode } from '../errors'; use(sinonChai); use(chaiAsPromised); @@ -138,6 +139,11 @@ describe('core/auth/auth_impl', () => { expect(persistenceStub._remove).to.have.been.called; expect(auth.currentUser).to.be.null; }); + it('is blocked if a beforeAuthStateChanged callback throws', async () => { + await auth._updateCurrentUser(testUser(auth, 'test')); + auth.beforeAuthStateChanged(sinon.stub().throws()); + await expect(auth.signOut()).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED); + }); }); describe('#useDeviceLanguage', () => { @@ -208,20 +214,24 @@ describe('core/auth/auth_impl', () => { let user: UserInternal; let authStateCallback: sinon.SinonSpy; let idTokenCallback: sinon.SinonSpy; + let beforeAuthCallback: sinon.SinonSpy; beforeEach(() => { user = testUser(auth, 'uid'); authStateCallback = sinon.spy(); idTokenCallback = sinon.spy(); + beforeAuthCallback = sinon.spy(); }); context('initially currentUser is null', () => { beforeEach(async () => { auth.onAuthStateChanged(authStateCallback); auth.onIdTokenChanged(idTokenCallback); + auth.beforeAuthStateChanged(beforeAuthCallback); await auth._updateCurrentUser(null); authStateCallback.resetHistory(); idTokenCallback.resetHistory(); + beforeAuthCallback.resetHistory(); }); it('onAuthStateChange triggers on log in', async () => { @@ -233,15 +243,22 @@ describe('core/auth/auth_impl', () => { await auth._updateCurrentUser(user); expect(idTokenCallback).to.have.been.calledWith(user); }); + + it('beforeAuthStateChanged triggers on log in', async () => { + await auth._updateCurrentUser(user); + expect(beforeAuthCallback).to.have.been.calledWith(user); + }); }); context('initially currentUser is user', () => { beforeEach(async () => { auth.onAuthStateChanged(authStateCallback); auth.onIdTokenChanged(idTokenCallback); + auth.beforeAuthStateChanged(beforeAuthCallback); await auth._updateCurrentUser(user); authStateCallback.resetHistory(); idTokenCallback.resetHistory(); + beforeAuthCallback.resetHistory(); }); it('onAuthStateChange triggers on log out', async () => { @@ -254,6 +271,11 @@ describe('core/auth/auth_impl', () => { expect(idTokenCallback).to.have.been.calledWith(null); }); + it('beforeAuthStateChanged triggers on log out', async () => { + await auth._updateCurrentUser(null); + expect(beforeAuthCallback).to.have.been.calledWith(null); + }); + it('onAuthStateChange does not trigger for user props change', async () => { user.photoURL = 'blah'; await auth._updateCurrentUser(user); @@ -300,21 +322,61 @@ describe('core/auth/auth_impl', () => { expect(cb1).to.have.been.calledWith(user); expect(cb2).to.have.been.calledWith(user); }); + + it('beforeAuthStateChange works for multiple listeners', async () => { + const cb1 = sinon.spy(); + const cb2 = sinon.spy(); + auth.beforeAuthStateChanged(cb1); + auth.beforeAuthStateChanged(cb2); + await auth._updateCurrentUser(null); + cb1.resetHistory(); + cb2.resetHistory(); + + await auth._updateCurrentUser(user); + expect(cb1).to.have.been.calledWith(user); + expect(cb2).to.have.been.calledWith(user); + }); + + it('_updateCurrentUser throws if a beforeAuthStateChange callback throws', async () => { + await auth._updateCurrentUser(null); + const cb1 = sinon.stub().throws(); + const cb2 = sinon.spy(); + auth.beforeAuthStateChanged(cb1); + auth.beforeAuthStateChanged(cb2); + + await expect(auth._updateCurrentUser(user)).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED); + expect(cb2).not.to.be.called; + }); + + it('_updateCurrentUser throws if a beforeAuthStateChange callback rejects', async () => { + await auth._updateCurrentUser(null); + const cb1 = sinon.stub().rejects(); + const cb2 = sinon.spy(); + auth.beforeAuthStateChanged(cb1); + auth.beforeAuthStateChanged(cb2); + + await expect(auth._updateCurrentUser(user)).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED); + expect(cb2).not.to.be.called; + }); }); }); describe('#_onStorageEvent', () => { let authStateCallback: sinon.SinonSpy; let idTokenCallback: sinon.SinonSpy; + let beforeStateCallback: sinon.SinonSpy; beforeEach(async () => { authStateCallback = sinon.spy(); idTokenCallback = sinon.spy(); + beforeStateCallback = sinon.spy(); auth.onAuthStateChanged(authStateCallback); auth.onIdTokenChanged(idTokenCallback); + auth.beforeAuthStateChanged(beforeStateCallback); await auth._updateCurrentUser(null); // force event handlers to clear out authStateCallback.resetHistory(); idTokenCallback.resetHistory(); + beforeStateCallback.resetHistory(); }); context('previously logged out', () => { @@ -324,6 +386,7 @@ describe('core/auth/auth_impl', () => { expect(authStateCallback).not.to.have.been.called; expect(idTokenCallback).not.to.have.been.called; + expect(beforeStateCallback).not.to.have.been.called; }); }); @@ -341,6 +404,8 @@ describe('core/auth/auth_impl', () => { expect(auth.currentUser?.toJSON()).to.eql(user.toJSON()); expect(authStateCallback).to.have.been.called; expect(idTokenCallback).to.have.been.called; + // This should never be called on a storage event. + expect(beforeStateCallback).not.to.have.been.called; }); }); }); @@ -353,6 +418,7 @@ describe('core/auth/auth_impl', () => { await auth._updateCurrentUser(user); authStateCallback.resetHistory(); idTokenCallback.resetHistory(); + beforeStateCallback.resetHistory(); }); context('now logged out', () => { @@ -366,6 +432,8 @@ describe('core/auth/auth_impl', () => { expect(auth.currentUser).to.be.null; expect(authStateCallback).to.have.been.called; expect(idTokenCallback).to.have.been.called; + // This should never be called on a storage event. + expect(beforeStateCallback).not.to.have.been.called; }); }); @@ -378,6 +446,7 @@ describe('core/auth/auth_impl', () => { expect(auth.currentUser?.toJSON()).to.eql(user.toJSON()); expect(authStateCallback).not.to.have.been.called; expect(idTokenCallback).not.to.have.been.called; + expect(beforeStateCallback).not.to.have.been.called; }); it('should update fields if they have changed', async () => { @@ -391,6 +460,7 @@ describe('core/auth/auth_impl', () => { expect(auth.currentUser?.displayName).to.eq('other-name'); expect(authStateCallback).not.to.have.been.called; expect(idTokenCallback).not.to.have.been.called; + expect(beforeStateCallback).not.to.have.been.called; }); it('should update tokens if they have changed', async () => { @@ -407,6 +477,8 @@ describe('core/auth/auth_impl', () => { ).to.eq('new-access-token'); expect(authStateCallback).not.to.have.been.called; expect(idTokenCallback).to.have.been.called; + // This should never be called on a storage event. + expect(beforeStateCallback).not.to.have.been.called; }); }); @@ -420,6 +492,8 @@ describe('core/auth/auth_impl', () => { expect(auth.currentUser?.toJSON()).to.eql(newUser.toJSON()); expect(authStateCallback).to.have.been.called; expect(idTokenCallback).to.have.been.called; + // This should never be called on a storage event. + expect(beforeStateCallback).not.to.have.been.called; }); }); }); @@ -461,7 +535,7 @@ describe('core/auth/auth_impl', () => { }); }); - context ('#_getAdditionalHeaders', () => { + context('#_getAdditionalHeaders', () => { it('always adds the client version', async () => { expect(await auth._getAdditionalHeaders()).to.eql({ 'X-Client-Version': 'v', diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index d20c1596874..aca6798bf2a 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -60,6 +60,7 @@ import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; import { HttpHeader } from '../../api'; +import { AuthMiddlewareQueue } from './middleware'; interface AsyncAction { (): Promise; @@ -79,6 +80,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { private redirectPersistenceManager?: PersistenceUserManager; private authStateSubscription = new Subscription(this); private idTokenSubscription = new Subscription(this); + private readonly beforeStateQueue = new AuthMiddlewareQueue(this); private redirectUser: UserInternal | null = null; private isProactiveRefreshEnabled = false; @@ -183,19 +185,22 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } // Update current Auth state. Either a new login or logout. - await this._updateCurrentUser(user); + // Skip blocking callbacks, they should not apply to a change in another tab. + await this._updateCurrentUser(user, /* skipBeforeStateCallbacks */ true); } private async initializeCurrentUser( popupRedirectResolver?: PopupRedirectResolver ): Promise { // First check to see if we have a pending redirect event. - let storedUser = + const previouslyStoredUser = (await this.assertedPersistence.getCurrentUser()) as UserInternal | null; + let futureCurrentUser = previouslyStoredUser; + let needsTocheckMiddleware = false; if (popupRedirectResolver && this.config.authDomain) { await this.getOrInitRedirectPersistenceManager(); const redirectUserEventId = this.redirectUser?._redirectEventId; - const storedUserEventId = storedUser?._redirectEventId; + const storedUserEventId = futureCurrentUser?._redirectEventId; const result = await this.tryRedirectSignIn(popupRedirectResolver); // If the stored user (i.e. the old "currentUser") has a redirectId that @@ -206,20 +211,35 @@ export class AuthImpl implements AuthInternal, _FirebaseService { (!redirectUserEventId || redirectUserEventId === storedUserEventId) && result?.user ) { - storedUser = result.user as UserInternal; + futureCurrentUser = result.user as UserInternal; + needsTocheckMiddleware = true; } } // If no user in persistence, there is no current user. Set to null. - if (!storedUser) { + if (!futureCurrentUser) { return this.directlySetCurrentUser(null); } - if (!storedUser._redirectEventId) { - // This isn't a redirect user, we can reload and bail - // This will also catch the redirected user, if available, as that method - // strips the _redirectEventId - return this.reloadAndSetCurrentUserOrClear(storedUser); + if (!futureCurrentUser._redirectEventId) { + // This isn't a redirect link operation, we can reload and bail. + // First though, ensure that we check the middleware is happy. + if (needsTocheckMiddleware) { + try { + await this.beforeStateQueue.runMiddleware(futureCurrentUser); + } catch(e) { + futureCurrentUser = previouslyStoredUser; + // We know this is available since the bit is only set when the + // resolver is available + this._popupRedirectResolver!._overrideRedirectResult(this, () => Promise.reject(e)); + } + } + + if (futureCurrentUser) { + return this.reloadAndSetCurrentUserOrClear(futureCurrentUser); + } else { + return this.directlySetCurrentUser(null); + } } _assert(this._popupRedirectResolver, this, AuthErrorCode.ARGUMENT_ERROR); @@ -230,12 +250,12 @@ export class AuthImpl implements AuthInternal, _FirebaseService { // This is important for the reauthenticateWithRedirect() flow. if ( this.redirectUser && - this.redirectUser._redirectEventId === storedUser._redirectEventId + this.redirectUser._redirectEventId === futureCurrentUser._redirectEventId ) { - return this.directlySetCurrentUser(storedUser); + return this.directlySetCurrentUser(futureCurrentUser); } - return this.reloadAndSetCurrentUserOrClear(storedUser); + return this.reloadAndSetCurrentUserOrClear(futureCurrentUser); } private async tryRedirectSignIn( @@ -315,7 +335,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { return this._updateCurrentUser(user && user._clone(this)); } - async _updateCurrentUser(user: User | null): Promise { + async _updateCurrentUser(user: User | null, skipBeforeStateCallbacks: boolean = false): Promise { if (this._deleted) { return; } @@ -327,6 +347,10 @@ export class AuthImpl implements AuthInternal, _FirebaseService { ); } + if (!skipBeforeStateCallbacks) { + await this.beforeStateQueue.runMiddleware(user); + } + return this.queue(async () => { await this.directlySetCurrentUser(user as UserInternal | null); this.notifyAuthListeners(); @@ -334,12 +358,16 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } async signOut(): Promise { + // Run first, to block _setRedirectUser() if any callbacks fail. + await this.beforeStateQueue.runMiddleware(null); // Clear the redirect user when signOut is called if (this.redirectPersistenceManager || this._popupRedirectResolver) { await this._setRedirectUser(null); } - return this._updateCurrentUser(null); + // Prevent callbacks from being called again in _updateCurrentUser, as + // they were already called in the first line. + return this._updateCurrentUser(null, /* skipBeforeStateCallbacks */ true); } setPersistence(persistence: Persistence): Promise { @@ -373,6 +401,13 @@ export class AuthImpl implements AuthInternal, _FirebaseService { ); } + beforeAuthStateChanged( + callback: (user: User | null) => void | Promise, + onAbort?: () => void, + ): Unsubscribe { + return this.beforeStateQueue.pushCallback(callback, onAbort); + } + onIdTokenChanged( nextOrObserver: NextOrObserver, error?: ErrorFn, @@ -431,7 +466,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { // Make sure we've cleared any pending persistence actions if we're not in // the initializer if (this._isInitialized) { - await this.queue(async () => {}); + await this.queue(async () => { }); } if (this._currentUser?._redirectEventId === id) { @@ -502,7 +537,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { completed?: CompleteFn ): Unsubscribe { if (this._deleted) { - return () => {}; + return () => { }; } const cb = @@ -618,7 +653,7 @@ class Subscription { observer => (this.observer = observer) ); - constructor(readonly auth: AuthInternal) {} + constructor(readonly auth: AuthInternal) { } get next(): NextFn { _assert(this.observer, this.auth, AuthErrorCode.INTERNAL_ERROR); diff --git a/packages/auth/src/core/auth/initialize.test.ts b/packages/auth/src/core/auth/initialize.test.ts index e1c2a6255b9..3fd3238ba7c 100644 --- a/packages/auth/src/core/auth/initialize.test.ts +++ b/packages/auth/src/core/auth/initialize.test.ts @@ -126,6 +126,8 @@ describe('core/auth/initialize', () => { ): Promise { return null; } + async _overrideRedirectResult(): Promise { + } } const fakePopupRedirectResolver: PopupRedirectResolver = diff --git a/packages/auth/src/core/auth/middleware.test.ts b/packages/auth/src/core/auth/middleware.test.ts new file mode 100644 index 00000000000..84887c9eaad --- /dev/null +++ b/packages/auth/src/core/auth/middleware.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2022 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 chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { testAuth, testUser } from '../../../test/helpers/mock_auth'; +import { AuthInternal } from '../../model/auth'; +import { User } from '../../model/public_types'; +import { AuthMiddlewareQueue } from './middleware'; + +use(chaiAsPromised); +use(sinonChai); + +describe('Auth middleware', () => { + let middlewareQueue: AuthMiddlewareQueue; + let user: User; + let auth: AuthInternal; + + beforeEach(async () => { + auth = await testAuth(); + user = testUser(auth, 'uid'); + middlewareQueue = new AuthMiddlewareQueue(auth); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls middleware in order', async () => { + const calls: number[] = []; + + middlewareQueue.pushCallback(() => {calls.push(1);}); + middlewareQueue.pushCallback(() => {calls.push(2);}); + middlewareQueue.pushCallback(() => {calls.push(3);}); + + await middlewareQueue.runMiddleware(user); + + expect(calls).to.eql([1, 2, 3]); + }); + + it('rejects on error', async () => { + middlewareQueue.pushCallback(() => { + throw new Error('no'); + }); + await expect(middlewareQueue.runMiddleware(user)).to.be.rejectedWith('auth/login-blocked'); + }); + + it('rejects on promise rejection', async () => { + middlewareQueue.pushCallback(() => Promise.reject('no')); + await expect(middlewareQueue.runMiddleware(user)).to.be.rejectedWith('auth/login-blocked'); + }); + + it('awaits middleware completion before calling next', async () => { + const firstCb = sinon.spy(); + const secondCb = sinon.spy(); + + middlewareQueue.pushCallback(() => { + // Force the first one to run one tick later + return new Promise(resolve => { + setTimeout(() => { + firstCb(); + resolve(); + }, 1); + }); + }); + middlewareQueue.pushCallback(secondCb); + + await middlewareQueue.runMiddleware(user); + expect(secondCb).to.have.been.calledAfter(firstCb); + }); + + it('subsequent middleware not run after rejection', async () => { + const spy = sinon.spy(); + + middlewareQueue.pushCallback(() => { + throw new Error('no'); + }); + middlewareQueue.pushCallback(spy); + + await expect(middlewareQueue.runMiddleware(user)).to.be.rejectedWith('auth/login-blocked'); + expect(spy).not.to.have.been.called; + }); + + it('calls onAbort if provided but only for earlier runs', async () => { + const firstOnAbort = sinon.spy(); + const secondOnAbort = sinon.spy(); + + middlewareQueue.pushCallback(() => {}, firstOnAbort); + middlewareQueue.pushCallback(() => { + throw new Error('no'); + }, secondOnAbort); + + await expect(middlewareQueue.runMiddleware(user)).to.be.rejectedWith('auth/login-blocked'); + expect(firstOnAbort).to.have.been.called; + expect(secondOnAbort).not.to.have.been.called; + }); + + it('calls onAbort in reverse order', async () => { + const calls: number[] = []; + + middlewareQueue.pushCallback(() => {}, () => {calls.push(1);}); + middlewareQueue.pushCallback(() => {}, () => {calls.push(2);}); + middlewareQueue.pushCallback(() => {}, () => {calls.push(3);}); + middlewareQueue.pushCallback(() => { + throw new Error('no'); + }); + + await expect(middlewareQueue.runMiddleware(user)).to.be.rejectedWith('auth/login-blocked'); + expect(calls).to.eql([3, 2, 1]); + }); + + it('does not call any middleware if user matches null', async () => { + const spy = sinon.spy(); + + middlewareQueue.pushCallback(spy); + await middlewareQueue.runMiddleware(null); + + expect(spy).not.to.have.been.called; + }); + + it('does not call any middleware if user matches object', async () => { + const spy = sinon.spy(); + + // Directly set it manually since the public function creates a + // copy of the user. + auth.currentUser = user; + + middlewareQueue.pushCallback(spy); + await middlewareQueue.runMiddleware(user); + + expect(spy).not.to.have.been.called; + }); +}); \ No newline at end of file diff --git a/packages/auth/src/core/auth/middleware.ts b/packages/auth/src/core/auth/middleware.ts new file mode 100644 index 00000000000..01fe8e40168 --- /dev/null +++ b/packages/auth/src/core/auth/middleware.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2022 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 { AuthInternal } from '../../model/auth'; +import { Unsubscribe, User } from '../../model/public_types'; +import { AuthErrorCode } from '../errors'; + +interface MiddlewareEntry { + (user: User | null): Promise; + onAbort?: () => void; +} + +export class AuthMiddlewareQueue { + private readonly queue: MiddlewareEntry[] = []; + + constructor(private readonly auth: AuthInternal) {} + + pushCallback( + callback: (user: User | null) => void | Promise, + onAbort?: () => void): Unsubscribe { + // The callback could be sync or async. Wrap it into a + // function that is always async. + const wrappedCallback: MiddlewareEntry = + (user: User | null): Promise => new Promise((resolve, reject) => { + try { + const result = callback(user); + // Either resolve with existing promise or wrap a non-promise + // return value into a promise. + resolve(result); + } catch (e) { + // Sync callback throws. + reject(e); + } + }); + // Attach the onAbort if present + wrappedCallback.onAbort = onAbort; + this.queue.push(wrappedCallback); + + const index = this.queue.length - 1; + return () => { + // Unsubscribe. Replace with no-op. Do not remove from array, or it will disturb + // indexing of other elements. + this.queue[index] = () => Promise.resolve(); + }; + } + + async runMiddleware(nextUser: User | null): Promise { + if (this.auth.currentUser === nextUser) { + return; + } + + // While running the middleware, build a temporary stack of onAbort + // callbacks to call if one middleware callback rejects. + + const onAbortStack: Array<() => void> = []; + try { + for (const beforeStateCallback of this.queue) { + await beforeStateCallback(nextUser); + + // Only push the onAbort if the callback succeeds + if (beforeStateCallback.onAbort) { + onAbortStack.push(beforeStateCallback.onAbort); + } + } + } catch (e) { + // Run all onAbort, with separate try/catch to ignore any errors and + // continue + onAbortStack.reverse(); + for (const onAbort of onAbortStack) { + try { + onAbort(); + } catch (_) { /* swallow error */} + } + + throw this.auth._errorFactory.create( + AuthErrorCode.LOGIN_BLOCKED, { originalMessage: e.message }); + } + } +} \ No newline at end of file diff --git a/packages/auth/src/core/errors.ts b/packages/auth/src/core/errors.ts index a140f968da7..984f4afc509 100644 --- a/packages/auth/src/core/errors.ts +++ b/packages/auth/src/core/errors.ts @@ -75,6 +75,7 @@ export const enum AuthErrorCode { INVALID_SENDER = 'invalid-sender', INVALID_SESSION_INFO = 'invalid-verification-id', INVALID_TENANT_ID = 'invalid-tenant-id', + LOGIN_BLOCKED = 'login-blocked', MFA_INFO_NOT_FOUND = 'multi-factor-info-not-found', MFA_REQUIRED = 'multi-factor-auth-required', MISSING_ANDROID_PACKAGE_NAME = 'missing-android-pkg-name', @@ -245,6 +246,7 @@ function _debugErrorMap(): ErrorMap { 'The verification ID used to create the phone auth credential is invalid.', [AuthErrorCode.INVALID_TENANT_ID]: "The Auth instance's tenant ID is invalid.", + [AuthErrorCode.LOGIN_BLOCKED]: "Login blocked by user-provided method: {$originalMessage}", [AuthErrorCode.MISSING_ANDROID_PACKAGE_NAME]: 'An Android Package Name must be provided if the Android App is required to be installed.', [AuthErrorCode.MISSING_AUTH_DOMAIN]: @@ -414,9 +416,10 @@ type GenericAuthErrorParams = { | AuthErrorCode.NO_AUTH_EVENT | AuthErrorCode.OPERATION_NOT_SUPPORTED >]: { - appName: AppName; + appName?: AppName; email?: string; phoneNumber?: string; + message?: string; }; }; @@ -427,6 +430,7 @@ export interface AuthErrorParams extends GenericAuthErrorParams { [AuthErrorCode.ARGUMENT_ERROR]: { appName?: AppName }; [AuthErrorCode.DEPENDENT_SDK_INIT_BEFORE_AUTH]: { appName?: AppName }; [AuthErrorCode.INTERNAL_ERROR]: { appName?: AppName }; + [AuthErrorCode.LOGIN_BLOCKED]: { appName?: AppName, originalMessage?: string }; [AuthErrorCode.OPERATION_NOT_SUPPORTED]: { appName?: AppName }; [AuthErrorCode.NO_AUTH_EVENT]: { appName?: AppName }; [AuthErrorCode.MFA_REQUIRED]: { diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index 473475f7c38..54bfb3491d5 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -83,6 +83,26 @@ export function onIdTokenChanged( completed ); } +/** + * Adds a blocking callback that runs before an auth state change + * sets a new user. + * + * @param auth - The {@link Auth} instance. + * @param callback - callback triggered before new user value is set. + * If this throws, it blocks the user from being set. + * @param onAbort - callback triggered if a later `beforeAuthStateChanged()` + * callback throws, allowing you to undo any side effects. + */ + export function beforeAuthStateChanged( + auth: Auth, + callback: (user: User|null) => void | Promise, + onAbort?: () => void, +): Unsubscribe { + return getModularInstance(auth).beforeAuthStateChanged( + callback, + onAbort + ); +} /** * Adds an observer for changes to the user's sign-in state. * diff --git a/packages/auth/src/core/strategies/redirect.ts b/packages/auth/src/core/strategies/redirect.ts index df6b692655d..d0d11375eb0 100644 --- a/packages/auth/src/core/strategies/redirect.ts +++ b/packages/auth/src/core/strategies/redirect.ts @@ -139,6 +139,10 @@ export function _clearRedirectOutcomes(): void { redirectOutcomeMap.clear(); } +export function _overrideRedirectResult(auth: AuthInternal, result: () => Promise): void { + redirectOutcomeMap.set(auth._key(), result); +} + function resolverPersistence( resolver: PopupRedirectResolverInternal ): PersistenceInternal { diff --git a/packages/auth/src/model/popup_redirect.ts b/packages/auth/src/model/popup_redirect.ts index d600a784559..b5133d45bd5 100644 --- a/packages/auth/src/model/popup_redirect.ts +++ b/packages/auth/src/model/popup_redirect.ts @@ -26,6 +26,7 @@ import { FirebaseError } from '@firebase/util'; import { AuthPopup } from '../platform_browser/util/popup'; import { AuthInternal } from './auth'; +import { UserCredentialInternal } from './user'; export const enum EventFilter { POPUP, @@ -121,10 +122,11 @@ export interface PopupRedirectResolverInternal extends PopupRedirectResolver { _redirectPersistence: Persistence; _originValidation(auth: Auth): Promise; - // This is needed so that auth does not have a hard dependency on redirect + // These are needed so that auth does not have a hard dependency on redirect _completeRedirectFn: ( auth: Auth, resolver: PopupRedirectResolver, bypassAuthState: boolean ) => Promise; + _overrideRedirectResult: (auth: AuthInternal, resultGetter: () => Promise) => void; } diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 1664d56313d..5aab9972eb0 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -254,6 +254,19 @@ export interface Auth { error?: ErrorFn, completed?: CompleteFn ): Unsubscribe; + /** + * Adds a blocking callback that runs before an auth state change + * sets a new user. + * + * @param callback - callback triggered before new user value is set. + * If this throws, it blocks the user from being set. + * @param onAbort - callback triggered if a later `beforeAuthStateChanged()` + * callback throws, allowing you to undo any side effects. + */ + beforeAuthStateChanged( + callback: (user: User | null) => void | Promise, + onAbort?: () => void, + ): Unsubscribe; /** * Adds an observer for changes to the signed-in user's ID token. * diff --git a/packages/auth/src/platform_browser/auth.test.ts b/packages/auth/src/platform_browser/auth.test.ts index ad104dfceb7..21d459dd4de 100644 --- a/packages/auth/src/platform_browser/auth.test.ts +++ b/packages/auth/src/platform_browser/auth.test.ts @@ -130,7 +130,8 @@ describe('core/auth/initializeAuth', () => { async function initAndWait( persistence: Persistence | Persistence[], popupRedirectResolver?: PopupRedirectResolver, - authDomain = FAKE_APP.options.authDomain + authDomain = FAKE_APP.options.authDomain, + blockMiddleware = false, ): Promise { const auth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, @@ -146,6 +147,12 @@ describe('core/auth/initializeAuth', () => { persistence, popupRedirectResolver }); + + if (blockMiddleware) { + auth.beforeAuthStateChanged(() => { + throw new Error('blocked'); + }); + } // Auth initializes async. We can make sure the initialization is // flushed by awaiting a method on the queue. await auth.setPersistence(inMemoryPersistence); @@ -408,6 +415,84 @@ describe('core/auth/initializeAuth', () => { expect(user).not.to.be.null; expect(auth.currentUser).to.eq(user); }); + + it('does not halt old user load if middleware throws', async () => { + const stub = sinon.stub( + _getInstance(inMemoryPersistence) + ); + const oldUser = testUser(oldAuth, 'old-uid'); + stub._get.returns(Promise.resolve(oldUser.toJSON())); + const overrideSpy = sinon.spy(_getInstance(browserPopupRedirectResolver), '_overrideRedirectResult'); + const auth = await initAndWait( + [inMemoryPersistence], + browserPopupRedirectResolver, + FAKE_APP.options.authDomain, + /* blockMiddleware */ true + ); + + expect(auth.currentUser!.uid).to.eq(oldUser.uid); + expect(reload._reloadWithoutSaving).to.have.been.called; + expect(overrideSpy).not.to.have.been.called; + }); + + it('Reloads and uses old user if middleware throws', async () => { + const stub = sinon.stub( + _getInstance(inMemoryPersistence) + ); + const oldUser = testUser(oldAuth, 'old-uid'); + stub._get.returns(Promise.resolve(oldUser.toJSON())); + const overrideSpy = sinon.spy(_getInstance(browserPopupRedirectResolver), '_overrideRedirectResult'); + + let user: UserInternal | null = null; + completeRedirectFnStub.callsFake((auth: AuthInternal) => { + user = testUser(auth, 'uid', 'redirectUser@test.com'); + return Promise.resolve( + new UserCredentialImpl({ + operationType: OperationType.SIGN_IN, + user, + providerId: null + }) + ); + }); + + const auth = await initAndWait( + [inMemoryPersistence], + browserPopupRedirectResolver, + FAKE_APP.options.authDomain, + /* blockMiddleware */ true + ); + expect(user).not.to.be.null; + expect(auth.currentUser!.uid).to.eq(oldUser.uid); + expect(reload._reloadWithoutSaving).to.have.been.called; + expect(overrideSpy).to.have.been.called; + }); + + it('Nulls current user if redirect blocked by middleware', async () => { + const stub = sinon.stub( + _getInstance(inMemoryPersistence) + ); + stub._get.returns(Promise.resolve(null)); + completeRedirectFnStub.callsFake((auth: AuthInternal) => { + const user = testUser(auth, 'uid', 'redirectUser@test.com'); + return Promise.resolve( + new UserCredentialImpl({ + operationType: OperationType.SIGN_IN, + user, + providerId: null + }) + ); + }); + + const auth = await initAndWait( + [inMemoryPersistence], + browserPopupRedirectResolver, + FAKE_APP.options.authDomain, + /* blockMiddleware */ true + ); + expect(completeRedirectFnStub).to.have.been.called; + expect(auth.currentUser).to.be.null; + expect(reload._reloadWithoutSaving).not.to.have.been.called; + }); }); }); }); diff --git a/packages/auth/src/platform_browser/popup_redirect.ts b/packages/auth/src/platform_browser/popup_redirect.ts index aa95d0e3d0a..2b4129f5d27 100644 --- a/packages/auth/src/platform_browser/popup_redirect.ts +++ b/packages/auth/src/platform_browser/popup_redirect.ts @@ -38,6 +38,7 @@ import { _open, AuthPopup } from './util/popup'; import { _getRedirectResult } from './strategies/redirect'; import { _getRedirectUrl } from '../core/util/handler'; import { _isIOS, _isMobileBrowser, _isSafari } from '../core/util/browser'; +import { _overrideRedirectResult } from '../core/strategies/redirect'; /** * The special web storage event @@ -176,6 +177,8 @@ class BrowserPopupRedirectResolver implements PopupRedirectResolverInternal { } _completeRedirectFn = _getRedirectResult; + + _overrideRedirectResult = _overrideRedirectResult; } /** diff --git a/packages/auth/src/platform_cordova/popup_redirect/popup_redirect.ts b/packages/auth/src/platform_cordova/popup_redirect/popup_redirect.ts index 7c49a651f1d..7ad75728059 100644 --- a/packages/auth/src/platform_cordova/popup_redirect/popup_redirect.ts +++ b/packages/auth/src/platform_cordova/popup_redirect/popup_redirect.ts @@ -42,7 +42,7 @@ import { } from './events'; import { AuthEventManager } from '../../core/auth/auth_event_manager'; import { _getRedirectResult } from '../../platform_browser/strategies/redirect'; -import { _clearRedirectOutcomes } from '../../core/strategies/redirect'; +import { _clearRedirectOutcomes, _overrideRedirectResult } from '../../core/strategies/redirect'; import { _cordovaWindow } from '../plugins'; /** @@ -58,6 +58,7 @@ class CordovaPopupRedirectResolver implements PopupRedirectResolverInternal { private readonly originValidationPromises: Record> = {}; _completeRedirectFn = _getRedirectResult; + _overrideRedirectResult = _overrideRedirectResult; async _initialize(auth: AuthInternal): Promise { const key = auth._key(); diff --git a/packages/auth/test/helpers/mock_popup_redirect_resolver.ts b/packages/auth/test/helpers/mock_popup_redirect_resolver.ts index 83372936667..259fee60f30 100644 --- a/packages/auth/test/helpers/mock_popup_redirect_resolver.ts +++ b/packages/auth/test/helpers/mock_popup_redirect_resolver.ts @@ -60,6 +60,8 @@ export function makeMockPopupRedirectResolver( async _completeRedirectFn(): Promise {} + async _overrideRedirectResult(): Promise {} + async _originValidation(): Promise {} }; } diff --git a/packages/auth/test/integration/flows/anonymous.test.ts b/packages/auth/test/integration/flows/anonymous.test.ts index 3e085dbcd37..eb260dba365 100644 --- a/packages/auth/test/integration/flows/anonymous.test.ts +++ b/packages/auth/test/integration/flows/anonymous.test.ts @@ -37,6 +37,7 @@ import { getTestInstance, randomEmail } from '../../helpers/integration/helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -128,4 +129,8 @@ describe('Integration test: anonymous auth', () => { ); }); }); + + generateMiddlewareTests(() => auth, () => { + return signInAnonymously(auth); + }); }); diff --git a/packages/auth/test/integration/flows/custom.local.test.ts b/packages/auth/test/integration/flows/custom.local.test.ts index c9b1f0d1d42..a35fa40d78a 100644 --- a/packages/auth/test/integration/flows/custom.local.test.ts +++ b/packages/auth/test/integration/flows/custom.local.test.ts @@ -39,6 +39,7 @@ import { getTestInstance, randomEmail } from '../../helpers/integration/helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -225,4 +226,8 @@ describe('Integration test: custom auth', () => { ); }); }); + + generateMiddlewareTests(() => auth, () => { + return signInWithCustomToken(auth, customToken); + }); }); diff --git a/packages/auth/test/integration/flows/email.test.ts b/packages/auth/test/integration/flows/email.test.ts index 12a27e84358..ced1e2ef7e4 100644 --- a/packages/auth/test/integration/flows/email.test.ts +++ b/packages/auth/test/integration/flows/email.test.ts @@ -38,6 +38,7 @@ import { getTestInstance, randomEmail } from '../../helpers/integration/helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -168,5 +169,9 @@ describe('Integration test: email/password auth', () => { ); expect(userA.uid).to.eq(userB.uid); }); + + generateMiddlewareTests(() => auth, () => { + return signInWithEmailAndPassword(auth, email, 'password'); + }); }); }); diff --git a/packages/auth/test/integration/flows/idp.local.test.ts b/packages/auth/test/integration/flows/idp.local.test.ts index 4e42ac9d405..07fa788cd77 100644 --- a/packages/auth/test/integration/flows/idp.local.test.ts +++ b/packages/auth/test/integration/flows/idp.local.test.ts @@ -41,6 +41,7 @@ import { getTestInstance, randomEmail } from '../../helpers/integration/helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -285,4 +286,11 @@ describe('Integration test: headless IdP', () => { 'github.com' ]); }); + + generateMiddlewareTests(() => auth, () => { + return signInWithCredential( + auth, + GoogleAuthProvider.credential(oauthIdToken) + ); + }); }); diff --git a/packages/auth/test/integration/flows/middleware_test_generator.ts b/packages/auth/test/integration/flows/middleware_test_generator.ts new file mode 100644 index 00000000000..69deffad71b --- /dev/null +++ b/packages/auth/test/integration/flows/middleware_test_generator.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2022 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 chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import {Auth, createUserWithEmailAndPassword, User} from '@firebase/auth'; +import { randomEmail } from '../../helpers/integration/helpers'; + +use(chaiAsPromised); +use(sinonChai); + +export function generateMiddlewareTests(authGetter: () => Auth, signIn: () => Promise): void { + context('middleware', () => { + let auth: Auth; + let unsubscribes: Array<() => void>; + + beforeEach(() => { + auth = authGetter(); + unsubscribes = []; + }); + + afterEach(() => { + for (const u of unsubscribes) { + u(); + } + }); + + /** + * Helper function for adding beforeAuthStateChanged that will + * automatically unsubscribe after every test (since some tests may + * perform cleanup after that would be affected by the middleware) + */ + function beforeAuthStateChanged(callback: (user: User | null) => void | Promise, onAbort?: () => void): void { + unsubscribes.push(auth.beforeAuthStateChanged(callback, onAbort)); + } + + it('can prevent user sign in', async () => { + beforeAuthStateChanged(() => { + throw new Error('stop sign in'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.be.null; + }); + + it('can prevent user sign in as a promise', async () => { + beforeAuthStateChanged(() => { + return Promise.reject('stop sign in'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.be.null; + }); + + it('keeps previously-logged in user if blocked', async () => { + // Use a random email/password sign in for the base user + const {user: baseUser} = await createUserWithEmailAndPassword(auth, randomEmail(), 'password'); + + beforeAuthStateChanged(() => { + throw new Error('stop sign in'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.eq(baseUser); + }); + + it('can allow sign in', async () => { + beforeAuthStateChanged(() => { + // Pass + }); + + await expect(signIn()).not.to.be.rejected; + expect(auth.currentUser).not.to.be.null; + }); + + it('can allow sign in as a promise', async () => { + beforeAuthStateChanged(() => { + return Promise.resolve(); + }); + + await expect(signIn()).not.to.be.rejected; + expect(auth.currentUser).not.to.be.null; + }); + + it('overrides previous user if allowed', async () => { + // Use a random email/password sign in for the base user + const {user: baseUser} = await createUserWithEmailAndPassword(auth, randomEmail(), 'password'); + + beforeAuthStateChanged(() => { + // Pass + }); + + await expect(signIn()).not.to.be.rejected; + expect(auth.currentUser).not.to.eq(baseUser); + }); + + it('will reject if one callback fails', async () => { + // Also check that the function is called multiple + // times + const spy = sinon.spy(); + + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + beforeAuthStateChanged(() => { + throw new Error('stop sign in'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.be.null; + expect(spy).to.have.been.calledThrice; + }); + + it('keeps previously-logged in user if one rejects', async () => { + // Use a random email/password sign in for the base user + const {user: baseUser} = await createUserWithEmailAndPassword(auth, randomEmail(), 'password'); + + // Also check that the function is called multiple + // times + const spy = sinon.spy(); + + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + beforeAuthStateChanged(() => { + throw new Error('stop sign in'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.eq(baseUser); + expect(spy).to.have.been.calledThrice; + }); + + it('allows sign in with multiple callbacks all pass', async () => { + // Use a random email/password sign in for the base user + const {user: baseUser} = await createUserWithEmailAndPassword(auth, randomEmail(), 'password'); + + // Also check that the function is called multiple + // times + const spy = sinon.spy(); + + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + beforeAuthStateChanged(spy); + + await expect(signIn()).not.to.be.rejected; + expect(auth.currentUser).not.to.eq(baseUser); + expect(spy).to.have.been.calledThrice; + }); + + it('does not call subsequent callbacks after rejection', async () => { + const firstSpy = sinon.spy(); + const secondSpy = sinon.spy(); + + beforeAuthStateChanged(firstSpy); + beforeAuthStateChanged(() => { + throw new Error('stop sign in'); + }); + beforeAuthStateChanged(secondSpy); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(firstSpy).to.have.been.calledOnce; + expect(secondSpy).not.to.have.been.called; + }); + + it('can prevent sign-out', async () => { + await signIn(); + const user = auth.currentUser; + + beforeAuthStateChanged(() => { + throw new Error('block sign out'); + }); + + await expect(auth.signOut()).to.be.rejectedWith('auth/login-blocked'); + expect(auth.currentUser).to.eq(user); + }); + + it('calls onAbort after rejection', async () => { + const onAbort = sinon.spy(); + beforeAuthStateChanged(() => { + // Pass + }, onAbort); + beforeAuthStateChanged(() => { + throw new Error('block sign out'); + }); + + await expect(signIn()).to.be.rejectedWith('auth/login-blocked'); + expect(onAbort).to.have.been.called; + }); + }); +} \ No newline at end of file diff --git a/packages/auth/test/integration/flows/oob.local.test.ts b/packages/auth/test/integration/flows/oob.local.test.ts index 059313a4ce7..49b6f45280f 100644 --- a/packages/auth/test/integration/flows/oob.local.test.ts +++ b/packages/auth/test/integration/flows/oob.local.test.ts @@ -53,6 +53,7 @@ import { getTestInstance, randomEmail } from '../../helpers/integration/helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -267,6 +268,14 @@ describe('Integration test: oob codes', () => { signInWithEmailLink(auth, email, otherSession.oobLink) ).to.be.rejectedWith(FirebaseError, 'auth/invalid-email'); }); + + generateMiddlewareTests(() => auth, () => { + return signInWithEmailLink( + auth, + email, + oobSession.oobLink + ); + }); }); it('can be used to verify email', async () => { diff --git a/packages/auth/test/integration/flows/phone.test.ts b/packages/auth/test/integration/flows/phone.test.ts index 54ed016c81a..2711292d046 100644 --- a/packages/auth/test/integration/flows/phone.test.ts +++ b/packages/auth/test/integration/flows/phone.test.ts @@ -42,6 +42,7 @@ import { getTestInstance } from '../../helpers/integration/helpers'; import { getPhoneVerificationCodes } from '../../helpers/integration/emulator_rest_helpers'; +import { generateMiddlewareTests } from './middleware_test_generator'; use(chaiAsPromised); @@ -306,4 +307,9 @@ describe('Integration test: phone auth', () => { expect(errorUserCred.user.uid).to.eq(signUpCred.user.uid); }); }); + + generateMiddlewareTests(() => auth, async () => { + const cr = await signInWithPhoneNumber(auth, PHONE_A.phoneNumber, verifier); + await cr.confirm(await code(cr, PHONE_A.code)); + }); }); diff --git a/packages/auth/test/integration/webdriver/persistence.test.ts b/packages/auth/test/integration/webdriver/persistence.test.ts index bc76609a0fa..dd2bc0ec455 100644 --- a/packages/auth/test/integration/webdriver/persistence.test.ts +++ b/packages/auth/test/integration/webdriver/persistence.test.ts @@ -17,18 +17,22 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { UserCredential } from '@firebase/auth'; -import { expect } from 'chai'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import { createAnonAccount } from '../../helpers/integration/emulator_rest_helpers'; import { API_KEY } from '../../helpers/integration/settings'; import { START_FUNCTION } from './util/auth_driver'; import { AnonFunction, CoreFunction, + MiddlewareFunction, PersistenceFunction } from './util/functions'; import { JsLoadCondition } from './util/js_load_condition'; import { browserDescribe } from './util/test_runner'; +use(chaiAsPromised); + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async function testPersistedUser() { const account = await createAnonAccount(); @@ -280,13 +284,13 @@ browserDescribe('WebDriver persistence test', (driver, browser) => { .that.contains({ uid: user.uid }); }); - it('migrates user when switching from indexedDB to localStorage', async () => { + it('migrates user when switching from indexedDB to localStorage', async function() { // This test only works in the modular SDK: the compat package does not // make the distinction between indexedDB and local storage (both are just // 'local'). if (driver.isCompatLayer()) { console.warn('Skipping indexedDB to local migration in compat test'); - return; + this.skip(); } await driver.call(AnonFunction.SIGN_IN_ANONYMOUSLY); @@ -458,6 +462,39 @@ browserDescribe('WebDriver persistence test', (driver, browser) => { expect(await driver.getUserSnapshot()).to.contain({ uid: uid2 }); }); + it('middleware does not block tab sync', async function() { + if (driver.isCompatLayer()) { + // Compat layer is skipped because it doesn't support middleware + console.warn('Skipping middleware tabs in compat test'); + this.skip(); + } + + // Blocking middleware in main page + await driver.call(MiddlewareFunction.ATTACH_BLOCKING_MIDDLEWARE); + + // Check that it blocks basic sign in + await expect(driver.call( + AnonFunction.SIGN_IN_ANONYMOUSLY + )).to.be.rejectedWith('auth/login-blocked'); + const userInPopup = await driver.getUserSnapshot(); + expect(userInPopup).to.be.null; + + // Now sign in in new page + await driver.webDriver.executeScript('window.open(".");'); + await driver.selectPopupWindow(); + await driver.webDriver.wait(new JsLoadCondition(START_FUNCTION)); + await driver.injectConfigAndInitAuth(); + await driver.waitForAuthInit(); + const cred: UserCredential = await driver.call( + AnonFunction.SIGN_IN_ANONYMOUSLY + ); + + // And make sure it was updated in main window + await driver.selectMainWindow({ noWait: true }); + await driver.pause(700); + expect((await driver.getUserSnapshot()).uid).to.eq(cred.user.uid); + }); + it('sync current user across windows with localStorage', async () => { await driver.webDriver.navigate().refresh(); // Simulate browsers that do not support indexedDB. diff --git a/packages/auth/test/integration/webdriver/popup.test.ts b/packages/auth/test/integration/webdriver/popup.test.ts index e90823cb512..5260026f113 100644 --- a/packages/auth/test/integration/webdriver/popup.test.ts +++ b/packages/auth/test/integration/webdriver/popup.test.ts @@ -30,6 +30,7 @@ import { AnonFunction, CoreFunction, EmailFunction, + MiddlewareFunction, PopupFunction } from './util/functions'; @@ -63,6 +64,34 @@ browserDescribe('Popup IdP tests', driver => { expect(result.user).to.eql(currentUser); }); + it('is blocked by auth middleware', async function () { + if (driver.isCompatLayer()) { + // Compat layer doesn't support middleware yet + this.skip(); + } + + await driver.call(MiddlewareFunction.ATTACH_BLOCKING_MIDDLEWARE); + await driver.callNoWait(PopupFunction.IDP_POPUP); + await driver.selectPopupWindow(); + const widget = new IdPPage(driver.webDriver); + + // We're now on the widget page; wait for load + await widget.pageLoad(); + await widget.clickAddAccount(); + await widget.fillEmail('bob@bob.test'); + await widget.fillDisplayName('Bob Test'); + await widget.fillScreenName('bob.test'); + await widget.fillProfilePhoto('http://bob.test/bob.png'); + await widget.clickSignIn(); + + await driver.selectMainWindow(); + await expect(driver.call( + PopupFunction.POPUP_RESULT + )).to.be.rejectedWith('auth/login-blocked'); + const currentUser = await driver.getUserSnapshot(); + expect(currentUser).to.be.null; + }); + it('can link with another account account', async () => { // First, sign in anonymously const { user: anonUser }: UserCredential = await driver.call( diff --git a/packages/auth/test/integration/webdriver/redirect.test.ts b/packages/auth/test/integration/webdriver/redirect.test.ts index 2bc17411637..a4824cc3602 100644 --- a/packages/auth/test/integration/webdriver/redirect.test.ts +++ b/packages/auth/test/integration/webdriver/redirect.test.ts @@ -30,8 +30,11 @@ import { AnonFunction, CoreFunction, EmailFunction, + MiddlewareFunction, RedirectFunction } from './util/functions'; +import { JsLoadCondition } from './util/js_load_condition'; +import { START_FUNCTION } from './util/auth_driver'; use(chaiAsPromised); @@ -70,6 +73,32 @@ browserDescribe('WebDriver redirect IdP test', driver => { expect(await driver.call(RedirectFunction.REDIRECT_RESULT)).to.be.null; }); + // Redirect works with middleware for now + it('is blocked by middleware', async function () { + if (driver.isCompatLayer()) { + console.warn('Skipping middleware tests in compat'); + this.skip(); + } + + await driver.callNoWait(RedirectFunction.IDP_REDIRECT); + const widget = new IdPPage(driver.webDriver); + + // We're now on the widget page; wait for load + await widget.pageLoad(); + await widget.clickAddAccount(); + await widget.fillEmail('bob@bob.test'); + await widget.fillDisplayName('Bob Test'); + await widget.fillScreenName('bob.test'); + await widget.fillProfilePhoto('http://bob.test/bob.png'); + await widget.clickSignIn(); + await driver.webDriver.wait(new JsLoadCondition(START_FUNCTION)); + await driver.call(MiddlewareFunction.ATTACH_BLOCKING_MIDDLEWARE_ON_START); + + await driver.reinitOnRedirect(); + await expect(driver.call(RedirectFunction.REDIRECT_RESULT)).to.be.rejectedWith('auth/login-blocked'); + expect(await driver.getUserSnapshot()).to.be.null; + }); + it('can link with another account account', async () => { // First, sign in anonymously const { user: anonUser }: UserCredential = await driver.call( diff --git a/packages/auth/test/integration/webdriver/static/index.js b/packages/auth/test/integration/webdriver/static/index.js index 86471a6bd31..0dff994fcc1 100644 --- a/packages/auth/test/integration/webdriver/static/index.js +++ b/packages/auth/test/integration/webdriver/static/index.js @@ -21,6 +21,7 @@ import * as core from './core'; import * as popup from './popup'; import * as email from './email'; import * as persistence from './persistence'; +import * as middleware from './middleware'; import { initializeApp } from '@firebase/app'; import { getAuth, connectAuthEmulator } from '@firebase/auth'; @@ -30,6 +31,7 @@ window.redirect = redirect; window.popup = popup; window.email = email; window.persistence = persistence; +window.middleware = middleware; window.auth = null; window.legacyAuth = null; diff --git a/packages/auth/test/integration/webdriver/static/middleware.js b/packages/auth/test/integration/webdriver/static/middleware.js new file mode 100644 index 00000000000..a4a2d90c801 --- /dev/null +++ b/packages/auth/test/integration/webdriver/static/middleware.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2022 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. + */ + +export async function attachBlockingMiddleware() { + auth.beforeAuthStateChanged(() => { + throw new Error('block state change'); + }); +} + +export async function attachBlockingMiddlewareOnStart() { + // Attaches the blocking middleware _immediately_ after auth is initialized, + // allowing us to test redirect operations. + const oldStartAuth = window.startAuth; + + window.startAuth = async () => { + oldStartAuth(); + await attachBlockingMiddleware(); + } +} \ No newline at end of file diff --git a/packages/auth/test/integration/webdriver/util/functions.ts b/packages/auth/test/integration/webdriver/util/functions.ts index 6350f59e9f9..42c8efd8b35 100644 --- a/packages/auth/test/integration/webdriver/util/functions.ts +++ b/packages/auth/test/integration/webdriver/util/functions.ts @@ -77,6 +77,11 @@ export enum PersistenceFunction { SET_PERSISTENCE_LOCAL_STORAGE = 'persistence.setPersistenceLocalStorage' } +export enum MiddlewareFunction { + ATTACH_BLOCKING_MIDDLEWARE = 'middleware.attachBlockingMiddleware', + ATTACH_BLOCKING_MIDDLEWARE_ON_START = 'middleware.attachBlockingMiddlewareOnStart', +} + /** Available firebase UI functions (only for compat tests) */ export enum UiFunction { LOAD = 'ui.loadUiCode',