Skip to content

Commit 37a89ca

Browse files
sam-gcavolkovi
authored andcommitted
Add auth listener implementation, add user.reload() (#2961)
1 parent 3866675 commit 37a89ca

File tree

10 files changed

+581
-22
lines changed

10 files changed

+581
-22
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: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,17 @@
1717

1818
import { getApp } from '@firebase/app-exp';
1919
import { FirebaseApp } from '@firebase/app-types-exp';
20-
21-
import { Auth, Config, Dependencies } from '../../model/auth';
20+
import {
21+
CompleteFn,
22+
createSubscribe,
23+
ErrorFn,
24+
NextFn,
25+
Observer,
26+
Subscribe,
27+
Unsubscribe
28+
} from '@firebase/util';
29+
30+
import { Auth, Config, Dependencies, NextOrObserver } from '../../model/auth';
2231
import { User } from '../../model/user';
2332
import { AuthErrorCode } from '../errors';
2433
import { Persistence } from '../persistence';
@@ -37,6 +46,13 @@ class AuthImpl implements Auth {
3746
currentUser: User | null = null;
3847
private operations = Promise.resolve();
3948
private persistenceManager?: PersistenceUserManager;
49+
private authStateSubscription = new Subscription<User>(this);
50+
private idTokenSubscription = new Subscription<User>(this);
51+
_isInitialized = false;
52+
53+
// Tracks the last notified UID for state change listeners to prevent
54+
// repeated calls to the callbacks
55+
private lastNotifiedUid: string | undefined = undefined;
4056

4157
constructor(
4258
public readonly name: string,
@@ -57,6 +73,9 @@ class AuthImpl implements Auth {
5773
if (storedUser) {
5874
await this.directlySetCurrentUser(storedUser);
5975
}
76+
77+
this._isInitialized = true;
78+
this._notifyStateListeners();
6079
});
6180
}
6281

@@ -74,6 +93,68 @@ class AuthImpl implements Auth {
7493
});
7594
}
7695

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

92175
private queue(action: AsyncAction): Promise<void> {
@@ -122,3 +205,18 @@ export function initializeAuth(
122205

123206
return new AuthImpl(app.name, config, hierarchy);
124207
}
208+
209+
/** Helper class to wrap subscriber logic */
210+
class Subscription<T> {
211+
private observer: Observer<T | null> | null = null;
212+
readonly addObserver: Subscribe<T | null> = createSubscribe(
213+
observer => (this.observer = observer)
214+
);
215+
216+
constructor(readonly auth: Auth) {}
217+
218+
get next(): NextFn<T | null> {
219+
assert(this.observer, this.auth.name);
220+
return this.observer.next.bind(this.observer);
221+
}
222+
}

0 commit comments

Comments
 (0)