Skip to content

Commit 91406c5

Browse files
authored
Auth middleware (beforeAuthStateChanged) (#6068)
1 parent 1cf124e commit 91406c5

File tree

5 files changed

+155
-8
lines changed

5 files changed

+155
-8
lines changed

common/api-review/auth.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function applyActionCode(auth: Auth, oobCode: string): Promise<void>;
8181
// @public
8282
export interface Auth {
8383
readonly app: FirebaseApp;
84+
beforeAuthStateChanged(callback: (user: User | null) => void | Promise<void>): Unsubscribe;
8485
readonly config: Config;
8586
readonly currentUser: User | null;
8687
readonly emulatorConfig: EmulatorConfig | null;

packages/auth/src/core/auth/auth_impl.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import * as reload from '../user/reload';
3434
import { AuthImpl, DefaultConfig } from './auth_impl';
3535
import { _initializeAuthInstance } from './initialize';
3636
import { ClientPlatform } from '../util/version';
37+
import { AuthErrorCode } from '../errors';
3738

3839
use(sinonChai);
3940
use(chaiAsPromised);
@@ -138,6 +139,11 @@ describe('core/auth/auth_impl', () => {
138139
expect(persistenceStub._remove).to.have.been.called;
139140
expect(auth.currentUser).to.be.null;
140141
});
142+
it('is blocked if a beforeAuthStateChanged callback throws', async () => {
143+
await auth._updateCurrentUser(testUser(auth, 'test'));
144+
auth.beforeAuthStateChanged(sinon.stub().throws());
145+
await expect(auth.signOut()).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED);
146+
});
141147
});
142148

143149
describe('#useDeviceLanguage', () => {
@@ -208,20 +214,24 @@ describe('core/auth/auth_impl', () => {
208214
let user: UserInternal;
209215
let authStateCallback: sinon.SinonSpy;
210216
let idTokenCallback: sinon.SinonSpy;
217+
let beforeAuthCallback: sinon.SinonSpy;
211218

212219
beforeEach(() => {
213220
user = testUser(auth, 'uid');
214221
authStateCallback = sinon.spy();
215222
idTokenCallback = sinon.spy();
223+
beforeAuthCallback = sinon.spy();
216224
});
217225

218226
context('initially currentUser is null', () => {
219227
beforeEach(async () => {
220228
auth.onAuthStateChanged(authStateCallback);
221229
auth.onIdTokenChanged(idTokenCallback);
230+
auth.beforeAuthStateChanged(beforeAuthCallback);
222231
await auth._updateCurrentUser(null);
223232
authStateCallback.resetHistory();
224233
idTokenCallback.resetHistory();
234+
beforeAuthCallback.resetHistory();
225235
});
226236

227237
it('onAuthStateChange triggers on log in', async () => {
@@ -233,15 +243,22 @@ describe('core/auth/auth_impl', () => {
233243
await auth._updateCurrentUser(user);
234244
expect(idTokenCallback).to.have.been.calledWith(user);
235245
});
246+
247+
it('beforeAuthStateChanged triggers on log in', async () => {
248+
await auth._updateCurrentUser(user);
249+
expect(beforeAuthCallback).to.have.been.calledWith(user);
250+
});
236251
});
237252

238253
context('initially currentUser is user', () => {
239254
beforeEach(async () => {
240255
auth.onAuthStateChanged(authStateCallback);
241256
auth.onIdTokenChanged(idTokenCallback);
257+
auth.beforeAuthStateChanged(beforeAuthCallback);
242258
await auth._updateCurrentUser(user);
243259
authStateCallback.resetHistory();
244260
idTokenCallback.resetHistory();
261+
beforeAuthCallback.resetHistory();
245262
});
246263

247264
it('onAuthStateChange triggers on log out', async () => {
@@ -254,6 +271,11 @@ describe('core/auth/auth_impl', () => {
254271
expect(idTokenCallback).to.have.been.calledWith(null);
255272
});
256273

274+
it('beforeAuthStateChanged triggers on log out', async () => {
275+
await auth._updateCurrentUser(null);
276+
expect(beforeAuthCallback).to.have.been.calledWith(null);
277+
});
278+
257279
it('onAuthStateChange does not trigger for user props change', async () => {
258280
user.photoURL = 'blah';
259281
await auth._updateCurrentUser(user);
@@ -300,21 +322,61 @@ describe('core/auth/auth_impl', () => {
300322
expect(cb1).to.have.been.calledWith(user);
301323
expect(cb2).to.have.been.calledWith(user);
302324
});
325+
326+
it('beforeAuthStateChange works for multiple listeners', async () => {
327+
const cb1 = sinon.spy();
328+
const cb2 = sinon.spy();
329+
auth.beforeAuthStateChanged(cb1);
330+
auth.beforeAuthStateChanged(cb2);
331+
await auth._updateCurrentUser(null);
332+
cb1.resetHistory();
333+
cb2.resetHistory();
334+
335+
await auth._updateCurrentUser(user);
336+
expect(cb1).to.have.been.calledWith(user);
337+
expect(cb2).to.have.been.calledWith(user);
338+
});
339+
340+
it('_updateCurrentUser throws if a beforeAuthStateChange callback throws', async () => {
341+
await auth._updateCurrentUser(null);
342+
const cb1 = sinon.stub().throws();
343+
const cb2 = sinon.spy();
344+
auth.beforeAuthStateChanged(cb1);
345+
auth.beforeAuthStateChanged(cb2);
346+
347+
await expect(auth._updateCurrentUser(user)).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED);
348+
expect(cb2).not.to.be.called;
349+
});
350+
351+
it('_updateCurrentUser throws if a beforeAuthStateChange callback rejects', async () => {
352+
await auth._updateCurrentUser(null);
353+
const cb1 = sinon.stub().rejects();
354+
const cb2 = sinon.spy();
355+
auth.beforeAuthStateChanged(cb1);
356+
auth.beforeAuthStateChanged(cb2);
357+
358+
await expect(auth._updateCurrentUser(user)).to.be.rejectedWith(AuthErrorCode.LOGIN_BLOCKED);
359+
expect(cb2).not.to.be.called;
360+
});
303361
});
304362
});
305363

306364
describe('#_onStorageEvent', () => {
307365
let authStateCallback: sinon.SinonSpy;
308366
let idTokenCallback: sinon.SinonSpy;
367+
let beforeStateCallback: sinon.SinonSpy;
309368

310369
beforeEach(async () => {
311370
authStateCallback = sinon.spy();
312371
idTokenCallback = sinon.spy();
372+
beforeStateCallback = sinon.spy();
313373
auth.onAuthStateChanged(authStateCallback);
314374
auth.onIdTokenChanged(idTokenCallback);
375+
auth.beforeAuthStateChanged(beforeStateCallback);
315376
await auth._updateCurrentUser(null); // force event handlers to clear out
316377
authStateCallback.resetHistory();
317378
idTokenCallback.resetHistory();
379+
beforeStateCallback.resetHistory();
318380
});
319381

320382
context('previously logged out', () => {
@@ -324,6 +386,7 @@ describe('core/auth/auth_impl', () => {
324386

325387
expect(authStateCallback).not.to.have.been.called;
326388
expect(idTokenCallback).not.to.have.been.called;
389+
expect(beforeStateCallback).not.to.have.been.called;
327390
});
328391
});
329392

@@ -341,6 +404,8 @@ describe('core/auth/auth_impl', () => {
341404
expect(auth.currentUser?.toJSON()).to.eql(user.toJSON());
342405
expect(authStateCallback).to.have.been.called;
343406
expect(idTokenCallback).to.have.been.called;
407+
// This should never be called on a storage event.
408+
expect(beforeStateCallback).not.to.have.been.called;
344409
});
345410
});
346411
});
@@ -353,6 +418,7 @@ describe('core/auth/auth_impl', () => {
353418
await auth._updateCurrentUser(user);
354419
authStateCallback.resetHistory();
355420
idTokenCallback.resetHistory();
421+
beforeStateCallback.resetHistory();
356422
});
357423

358424
context('now logged out', () => {
@@ -366,6 +432,8 @@ describe('core/auth/auth_impl', () => {
366432
expect(auth.currentUser).to.be.null;
367433
expect(authStateCallback).to.have.been.called;
368434
expect(idTokenCallback).to.have.been.called;
435+
// This should never be called on a storage event.
436+
expect(beforeStateCallback).not.to.have.been.called;
369437
});
370438
});
371439

@@ -378,6 +446,7 @@ describe('core/auth/auth_impl', () => {
378446
expect(auth.currentUser?.toJSON()).to.eql(user.toJSON());
379447
expect(authStateCallback).not.to.have.been.called;
380448
expect(idTokenCallback).not.to.have.been.called;
449+
expect(beforeStateCallback).not.to.have.been.called;
381450
});
382451

383452
it('should update fields if they have changed', async () => {
@@ -391,6 +460,7 @@ describe('core/auth/auth_impl', () => {
391460
expect(auth.currentUser?.displayName).to.eq('other-name');
392461
expect(authStateCallback).not.to.have.been.called;
393462
expect(idTokenCallback).not.to.have.been.called;
463+
expect(beforeStateCallback).not.to.have.been.called;
394464
});
395465

396466
it('should update tokens if they have changed', async () => {
@@ -407,6 +477,8 @@ describe('core/auth/auth_impl', () => {
407477
).to.eq('new-access-token');
408478
expect(authStateCallback).not.to.have.been.called;
409479
expect(idTokenCallback).to.have.been.called;
480+
// This should never be called on a storage event.
481+
expect(beforeStateCallback).not.to.have.been.called;
410482
});
411483
});
412484

@@ -420,6 +492,8 @@ describe('core/auth/auth_impl', () => {
420492
expect(auth.currentUser?.toJSON()).to.eql(newUser.toJSON());
421493
expect(authStateCallback).to.have.been.called;
422494
expect(idTokenCallback).to.have.been.called;
495+
// This should never be called on a storage event.
496+
expect(beforeStateCallback).not.to.have.been.called;
423497
});
424498
});
425499
});
@@ -461,7 +535,7 @@ describe('core/auth/auth_impl', () => {
461535
});
462536
});
463537

464-
context ('#_getAdditionalHeaders', () => {
538+
context('#_getAdditionalHeaders', () => {
465539
it('always adds the client version', async () => {
466540
expect(await auth._getAdditionalHeaders()).to.eql({
467541
'X-Client-Version': 'v',

packages/auth/src/core/auth/auth_impl.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
7979
private redirectPersistenceManager?: PersistenceUserManager;
8080
private authStateSubscription = new Subscription<User>(this);
8181
private idTokenSubscription = new Subscription<User>(this);
82+
private beforeStateQueue: Array<(user: User | null) => Promise<void>> = [];
8283
private redirectUser: UserInternal | null = null;
8384
private isProactiveRefreshEnabled = false;
8485

@@ -183,7 +184,8 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
183184
}
184185

185186
// Update current Auth state. Either a new login or logout.
186-
await this._updateCurrentUser(user);
187+
// Skip blocking callbacks, they should not apply to a change in another tab.
188+
await this._updateCurrentUser(user, /* skipBeforeStateCallbacks */ true);
187189
}
188190

189191
private async initializeCurrentUser(
@@ -225,6 +227,14 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
225227
_assert(this._popupRedirectResolver, this, AuthErrorCode.ARGUMENT_ERROR);
226228
await this.getOrInitRedirectPersistenceManager();
227229

230+
// At this point in the flow, this is a redirect user. Run blocking
231+
// middleware callbacks before setting the user.
232+
try {
233+
await this._runBeforeStateCallbacks(storedUser);
234+
} catch(e) {
235+
return;
236+
}
237+
228238
// If the redirect user's event ID matches the current user's event ID,
229239
// DO NOT reload the current user, otherwise they'll be cleared from storage.
230240
// This is important for the reauthenticateWithRedirect() flow.
@@ -315,7 +325,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
315325
return this._updateCurrentUser(user && user._clone(this));
316326
}
317327

318-
async _updateCurrentUser(user: User | null): Promise<void> {
328+
async _updateCurrentUser(user: User | null, skipBeforeStateCallbacks: boolean = false): Promise<void> {
319329
if (this._deleted) {
320330
return;
321331
}
@@ -327,19 +337,41 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
327337
);
328338
}
329339

340+
if (!skipBeforeStateCallbacks) {
341+
await this._runBeforeStateCallbacks(user);
342+
}
343+
330344
return this.queue(async () => {
331345
await this.directlySetCurrentUser(user as UserInternal | null);
332346
this.notifyAuthListeners();
333347
});
334348
}
335349

350+
async _runBeforeStateCallbacks(user: User | null): Promise<void> {
351+
if (this.currentUser === user) {
352+
return;
353+
}
354+
try {
355+
for (const beforeStateCallback of this.beforeStateQueue) {
356+
await beforeStateCallback(user);
357+
}
358+
} catch (e) {
359+
throw this._errorFactory.create(
360+
AuthErrorCode.LOGIN_BLOCKED, { originalMessage: e.message });
361+
}
362+
}
363+
336364
async signOut(): Promise<void> {
365+
// Run first, to block _setRedirectUser() if any callbacks fail.
366+
await this._runBeforeStateCallbacks(null);
337367
// Clear the redirect user when signOut is called
338368
if (this.redirectPersistenceManager || this._popupRedirectResolver) {
339369
await this._setRedirectUser(null);
340370
}
341371

342-
return this._updateCurrentUser(null);
372+
// Prevent callbacks from being called again in _updateCurrentUser, as
373+
// they were already called in the first line.
374+
return this._updateCurrentUser(null, /* skipBeforeStateCallbacks */ true);
343375
}
344376

345377
setPersistence(persistence: Persistence): Promise<void> {
@@ -373,6 +405,32 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
373405
);
374406
}
375407

408+
beforeAuthStateChanged(
409+
callback: (user: User | null) => void | Promise<void>
410+
): Unsubscribe {
411+
// The callback could be sync or async. Wrap it into a
412+
// function that is always async.
413+
const wrappedCallback =
414+
(user: User | null): Promise<void> => new Promise((resolve, reject) => {
415+
try {
416+
const result = callback(user);
417+
// Either resolve with existing promise or wrap a non-promise
418+
// return value into a promise.
419+
resolve(result);
420+
} catch (e) {
421+
// Sync callback throws.
422+
reject(e);
423+
}
424+
});
425+
this.beforeStateQueue.push(wrappedCallback);
426+
const index = this.beforeStateQueue.length - 1;
427+
return () => {
428+
// Unsubscribe. Replace with no-op. Do not remove from array, or it will disturb
429+
// indexing of other elements.
430+
this.beforeStateQueue[index] = () => Promise.resolve();
431+
};
432+
}
433+
376434
onIdTokenChanged(
377435
nextOrObserver: NextOrObserver<User>,
378436
error?: ErrorFn,
@@ -431,7 +489,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
431489
// Make sure we've cleared any pending persistence actions if we're not in
432490
// the initializer
433491
if (this._isInitialized) {
434-
await this.queue(async () => {});
492+
await this.queue(async () => { });
435493
}
436494

437495
if (this._currentUser?._redirectEventId === id) {
@@ -502,7 +560,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
502560
completed?: CompleteFn
503561
): Unsubscribe {
504562
if (this._deleted) {
505-
return () => {};
563+
return () => { };
506564
}
507565

508566
const cb =
@@ -618,7 +676,7 @@ class Subscription<T> {
618676
observer => (this.observer = observer)
619677
);
620678

621-
constructor(readonly auth: AuthInternal) {}
679+
constructor(readonly auth: AuthInternal) { }
622680

623681
get next(): NextFn<T | null> {
624682
_assert(this.observer, this.auth, AuthErrorCode.INTERNAL_ERROR);

0 commit comments

Comments
 (0)