Skip to content

Commit b010078

Browse files
authored
Merge f12ebec into 3bb6376
2 parents 3bb6376 + f12ebec commit b010078

File tree

10 files changed

+574
-21
lines changed

10 files changed

+574
-21
lines changed

packages-exp/auth-exp/src/api/account_management/account.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { Endpoint, HttpMethod, performApiRequest } from '..';
18+
import { Endpoint, HttpMethod, performApiRequest } from '../';
1919
import { Auth } from '../../model/auth';
2020
import { APIMFAInfo } from '../../model/id_token';
2121

@@ -74,7 +74,7 @@ export interface APIUserInfo {
7474
createdAt?: number;
7575
tenantId?: string;
7676
passwordHash?: string;
77-
providerUserInfo: ProviderUserInfo[];
77+
providerUserInfo?: ProviderUserInfo[];
7878
mfaInfo?: APIMFAInfo[];
7979
}
8080

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { FirebaseError } from '@firebase/util';
2424

2525
import { testUser } from '../../../test/mock_auth';
2626
import { Auth } from '../../model/auth';
27+
import { User } from '../../model/user';
2728
import { Persistence } from '../persistence';
2829
import { browserLocalPersistence } from '../persistence/browser';
2930
import { inMemoryPersistence } from '../persistence/in_memory';
@@ -117,6 +118,139 @@ describe('AuthImpl', () => {
117118
);
118119
});
119120
});
121+
122+
describe('change listeners', () => {
123+
// // Helpers to convert auth state change results to promise
124+
// function onAuthStateChange(callback: NextFn<User|null>)
125+
126+
it('immediately calls authStateChange if initialization finished', done => {
127+
const user = testUser('uid');
128+
auth.currentUser = user;
129+
auth._isInitialized = true;
130+
auth.onAuthStateChanged(user => {
131+
expect(user).to.eq(user);
132+
done();
133+
});
134+
});
135+
136+
it('immediately calls idTokenChange if initialization finished', done => {
137+
const user = testUser('uid');
138+
auth.currentUser = user;
139+
auth._isInitialized = true;
140+
auth.onIdTokenChange(user => {
141+
expect(user).to.eq(user);
142+
done();
143+
});
144+
});
145+
146+
it('immediate callback is done async', () => {
147+
auth._isInitialized = true;
148+
let callbackCalled = false;
149+
auth.onIdTokenChange(() => {
150+
callbackCalled = true;
151+
});
152+
153+
expect(callbackCalled).to.be.false;
154+
});
155+
156+
describe('user logs in/out, tokens refresh', () => {
157+
let user: User;
158+
let authStateCallback: sinon.SinonSpy;
159+
let idTokenCallback: sinon.SinonSpy;
160+
161+
beforeEach(() => {
162+
user = testUser('uid');
163+
authStateCallback = sinon.spy();
164+
idTokenCallback = sinon.spy();
165+
});
166+
167+
context('initially currentUser is null', () => {
168+
beforeEach(async () => {
169+
auth.onAuthStateChanged(authStateCallback);
170+
auth.onIdTokenChange(idTokenCallback);
171+
await auth.updateCurrentUser(null);
172+
authStateCallback.resetHistory();
173+
idTokenCallback.resetHistory();
174+
});
175+
176+
it('onAuthStateChange triggers on log in', async () => {
177+
await auth.updateCurrentUser(user);
178+
expect(authStateCallback).to.have.been.calledWith(user);
179+
});
180+
181+
it('onIdTokenChange triggers on log in', async () => {
182+
await auth.updateCurrentUser(user);
183+
expect(idTokenCallback).to.have.been.calledWith(user);
184+
});
185+
});
186+
187+
context('initially currentUser is user', () => {
188+
beforeEach(async () => {
189+
auth.onAuthStateChanged(authStateCallback);
190+
auth.onIdTokenChange(idTokenCallback);
191+
await auth.updateCurrentUser(user);
192+
authStateCallback.resetHistory();
193+
idTokenCallback.resetHistory();
194+
});
195+
196+
it('onAuthStateChange triggers on log out', async () => {
197+
await auth.updateCurrentUser(null);
198+
expect(authStateCallback).to.have.been.calledWith(null);
199+
});
200+
201+
it('onIdTokenChange triggers on log out', async () => {
202+
await auth.updateCurrentUser(null);
203+
expect(idTokenCallback).to.have.been.calledWith(null);
204+
});
205+
206+
it('onAuthStateChange does not trigger for user props change', async () => {
207+
user.refreshToken = 'hey look I changed';
208+
await auth.updateCurrentUser(user);
209+
expect(authStateCallback).not.to.have.been.called;
210+
});
211+
212+
it('onIdTokenChange triggers for user props change', async () => {
213+
user.refreshToken = 'hey look I changed';
214+
await auth.updateCurrentUser(user);
215+
expect(idTokenCallback).to.have.been.calledWith(user);
216+
});
217+
218+
it('onAuthStateChange triggers if uid changes', async () => {
219+
const newUser = testUser('different-uid');
220+
await auth.updateCurrentUser(newUser);
221+
expect(authStateCallback).to.have.been.calledWith(newUser);
222+
});
223+
});
224+
225+
it('onAuthStateChange works for multiple listeners', async () => {
226+
const cb1 = sinon.spy();
227+
const cb2 = sinon.spy();
228+
auth.onAuthStateChanged(cb1);
229+
auth.onAuthStateChanged(cb2);
230+
await auth.updateCurrentUser(null);
231+
cb1.resetHistory();
232+
cb2.resetHistory();
233+
234+
await auth.updateCurrentUser(user);
235+
expect(cb1).to.have.been.calledWith(user);
236+
expect(cb2).to.have.been.calledWith(user);
237+
});
238+
239+
it('onIdTokenChange works for multiple listeners', async () => {
240+
const cb1 = sinon.spy();
241+
const cb2 = sinon.spy();
242+
auth.onIdTokenChange(cb1);
243+
auth.onIdTokenChange(cb2);
244+
await auth.updateCurrentUser(null);
245+
cb1.resetHistory();
246+
cb2.resetHistory();
247+
248+
await auth.updateCurrentUser(user);
249+
expect(cb1).to.have.been.calledWith(user);
250+
expect(cb2).to.have.been.calledWith(user);
251+
});
252+
});
253+
});
120254
});
121255

122256
describe('initializeAuth', () => {

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

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717

1818
import { getApp } from '@firebase/app-exp';
1919
import { FirebaseApp } from '@firebase/app-types-exp';
20+
import {
21+
CompleteFn, createSubscribe, ErrorFn, NextFn, Observer, Subscribe, Unsubscribe
22+
} from '@firebase/util';
2023

21-
import { Auth, Config, Dependencies } from '../../model/auth';
24+
import { Auth, Config, Dependencies, NextOrObserver } from '../../model/auth';
2225
import { User } from '../../model/user';
2326
import { AuthErrorCode } from '../errors';
2427
import { Persistence } from '../persistence';
@@ -37,6 +40,13 @@ class AuthImpl implements Auth {
3740
currentUser: User | null = null;
3841
private operations = Promise.resolve();
3942
private persistenceManager?: PersistenceUserManager;
43+
private authStateSubscription = new Subscription<User>(this);
44+
private idTokenSubscription = new Subscription<User>(this);
45+
_isInitialized = false;
46+
47+
// Tracks the last notified UID for state change listeners to prevent
48+
// repeated calls to the callbacks
49+
private lastNotifiedUid: string | undefined = undefined;
4050

4151
constructor(
4252
public readonly name: string,
@@ -57,6 +67,9 @@ class AuthImpl implements Auth {
5767
if (storedUser) {
5868
await this.directlySetCurrentUser(storedUser);
5969
}
70+
71+
this._isInitialized = true;
72+
this._notifyStateListeners();
6073
});
6174
}
6275

@@ -74,6 +87,68 @@ class AuthImpl implements Auth {
7487
});
7588
}
7689

90+
onAuthStateChanged(
91+
nextOrObserver: NextOrObserver<User>,
92+
error?: ErrorFn,
93+
completed?: CompleteFn
94+
): Unsubscribe {
95+
return this.registerStateListener(
96+
this.authStateSubscription,
97+
nextOrObserver,
98+
error,
99+
completed
100+
);
101+
}
102+
103+
onIdTokenChange(
104+
nextOrObserver: NextOrObserver<User>,
105+
error?: ErrorFn,
106+
completed?: CompleteFn
107+
): Unsubscribe {
108+
return this.registerStateListener(
109+
this.idTokenSubscription,
110+
nextOrObserver,
111+
error,
112+
completed
113+
);
114+
}
115+
116+
_notifyStateListeners(): void {
117+
if (!this._isInitialized) {
118+
return;
119+
}
120+
121+
this.idTokenSubscription.next(this.currentUser);
122+
123+
if (this.lastNotifiedUid !== this.currentUser?.uid) {
124+
this.lastNotifiedUid = this.currentUser?.uid;
125+
this.authStateSubscription.next(this.currentUser);
126+
}
127+
}
128+
129+
private registerStateListener(
130+
subscription: Subscription<User>,
131+
nextOrObserver: NextOrObserver<User>,
132+
error?: ErrorFn,
133+
completed?: CompleteFn
134+
): Unsubscribe {
135+
if (this._isInitialized) {
136+
const cb =
137+
typeof nextOrObserver === 'function'
138+
? nextOrObserver
139+
: nextOrObserver.next;
140+
// The callback needs to be called asynchronously per the spec.
141+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
142+
Promise.resolve().then(() => cb(this.currentUser));
143+
}
144+
145+
if (typeof nextOrObserver === 'function') {
146+
return subscription.addObserver(nextOrObserver, error, completed);
147+
} else {
148+
return subscription.addObserver(nextOrObserver);
149+
}
150+
}
151+
77152
/**
78153
* Unprotected (from race conditions) method to set the current user. This
79154
* should only be called from within a queued callback. This is necessary
@@ -87,6 +162,8 @@ class AuthImpl implements Auth {
87162
} else {
88163
await this.assertedPersistence.removeCurrentUser();
89164
}
165+
166+
this._notifyStateListeners();
90167
}
91168

92169
private queue(action: AsyncAction): Promise<void> {
@@ -122,3 +199,18 @@ export function initializeAuth(
122199

123200
return new AuthImpl(app.name, config, hierarchy);
124201
}
202+
203+
/** Helper class to wrap subscriber logic */
204+
class Subscription<T> {
205+
private observer: Observer<T | null> | null = null;
206+
readonly addObserver: Subscribe<T | null> = createSubscribe(
207+
observer => (this.observer = observer)
208+
);
209+
210+
constructor(readonly auth: Auth) {}
211+
212+
get next(): NextFn<T | null> {
213+
assert(this.observer, this.auth.name);
214+
return this.observer.next.bind(this.observer);
215+
}
216+
}

0 commit comments

Comments
 (0)