Skip to content

Commit 3ae6ce5

Browse files
authored
feat(auth-guard): Adding in modular auth guards (#3001)
* Adding modular auth guards * Flushing out some basic tests * Refactored how `isSupported` is handled * Firestore wasn't passing the injector, need tests for this too * Fixed the version check on firebase-tools * Fixed firebase-tools project creation * Cleaned up some of the error messaging
1 parent 5d6d8bf commit 3ae6ce5

38 files changed

+836
-117
lines changed

firebase.json

+6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
"rules": "test/storage.rules"
1111
},
1212
"emulators": {
13+
"auth": {
14+
"port": 9099
15+
},
16+
"functions": {
17+
"port": 5001
18+
},
1319
"firestore": {
1420
"port": 8080
1521
},

samples/advanced/src/app/app.module.ts

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/analytics/analytics.module.ts

+9-25
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,23 @@
11
import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, APP_INITIALIZER, Injector } from '@angular/core';
2-
import { Analytics as FirebaseAnalytics, isSupported } from 'firebase/analytics';
3-
import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angular/fire';
2+
import { Analytics as FirebaseAnalytics } from 'firebase/analytics';
3+
import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION, ɵisAnalyticsSupportedFactory } from '@angular/fire';
44
import { Analytics, ANALYTICS_PROVIDER_NAME, AnalyticsInstances } from './analytics';
55
import { FirebaseApps, FirebaseApp } from '@angular/fire/app';
66
import { registerVersion } from 'firebase/app';
77
import { ScreenTrackingService } from './screen-tracking.service';
88
import { UserTrackingService } from './user-tracking.service';
99

10-
export const PROVIDED_ANALYTICS_INSTANCE_FACTORIES = new InjectionToken<Array<(injector: Injector) => Analytics>>('angularfire2.analytics-instances.factory');
1110
export const PROVIDED_ANALYTICS_INSTANCES = new InjectionToken<Analytics[]>('angularfire2.analytics-instances');
12-
const IS_SUPPORTED = new InjectionToken<boolean>('angularfire2.analytics.isSupported');
1311

14-
const isSupportedValueSymbol = Symbol('angularfire2.analytics.isSupported.value');
15-
export const isSupportedPromiseSymbol = Symbol('angularfire2.analytics.isSupported');
16-
17-
globalThis[isSupportedPromiseSymbol] ||= isSupported().then(it => globalThis[isSupportedValueSymbol] = it);
18-
19-
export function defaultAnalyticsInstanceFactory(isSupported: boolean, provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) {
20-
if (!isSupported) { return null; }
12+
export function defaultAnalyticsInstanceFactory(provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) {
13+
if (!ɵisAnalyticsSupportedFactory.sync()) { return null; }
2114
const defaultAnalytics = ɵgetDefaultInstanceOf<FirebaseAnalytics>(ANALYTICS_PROVIDER_NAME, provided, defaultApp);
2215
return defaultAnalytics && new Analytics(defaultAnalytics);
2316
}
2417

2518
export function analyticsInstanceFactory(fn: (injector: Injector) => FirebaseAnalytics) {
26-
return (zone: NgZone, isSupported: boolean, injector: Injector) => {
27-
if (!isSupported) { return null; }
19+
return (zone: NgZone, injector: Injector) => {
20+
if (!ɵisAnalyticsSupportedFactory.sync()) { return null; }
2821
const analytics = zone.runOutsideAngular(() => fn(injector));
2922
return new Analytics(analytics);
3023
};
@@ -41,7 +34,6 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = {
4134
provide: Analytics,
4235
useFactory: defaultAnalyticsInstanceFactory,
4336
deps: [
44-
IS_SUPPORTED,
4537
[new Optional(), PROVIDED_ANALYTICS_INSTANCES ],
4638
FirebaseApp,
4739
]
@@ -53,15 +45,15 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = {
5345
ANALYTICS_INSTANCES_PROVIDER,
5446
{
5547
provide: APP_INITIALIZER,
56-
useValue: () => globalThis[isSupportedPromiseSymbol],
48+
useValue: ɵisAnalyticsSupportedFactory.async,
5749
multi: true,
5850
}
5951
]
6052
})
6153
export class AnalyticsModule {
6254
constructor(
63-
@Optional() _screenTracking: ScreenTrackingService,
64-
@Optional() _userTracking: UserTrackingService,
55+
@Optional() _screenTrackingService: ScreenTrackingService,
56+
@Optional() _userTrackingService: UserTrackingService,
6557
) {
6658
registerVersion('angularfire', VERSION.full, 'analytics');
6759
}
@@ -71,19 +63,11 @@ export function provideAnalytics(fn: (injector: Injector) => FirebaseAnalytics,
7163
return {
7264
ngModule: AnalyticsModule,
7365
providers: [{
74-
provide: IS_SUPPORTED,
75-
useFactory: () => globalThis[isSupportedValueSymbol],
76-
}, {
77-
provide: PROVIDED_ANALYTICS_INSTANCE_FACTORIES,
78-
useValue: fn,
79-
multi: true,
80-
}, {
8166
provide: PROVIDED_ANALYTICS_INSTANCES,
8267
useFactory: analyticsInstanceFactory(fn),
8368
multi: true,
8469
deps: [
8570
NgZone,
86-
IS_SUPPORTED,
8771
Injector,
8872
ɵAngularFireSchedulers,
8973
FirebaseApps,

src/analytics/analytics.spec.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app';
3+
import { Analytics, provideAnalytics, getAnalytics, isSupported } from '@angular/fire/analytics';
4+
import { COMMON_CONFIG } from '../test-config';
5+
import { rando } from '../utils';
6+
7+
describe('Analytics', () => {
8+
let app: FirebaseApp;
9+
let analytics: Analytics;
10+
let providedAnalytics: Analytics;
11+
let appName: string;
12+
13+
beforeAll(done => {
14+
// The APP_INITIALIZER that is making isSupported() sync for DI may not
15+
// be done evaulating by the time we inject from the TestBed. We can
16+
// ensure correct behavior by waiting for the (global) isSuppported() promise
17+
// to resolve.
18+
isSupported().then(() => done());
19+
});
20+
21+
describe('single injection', () => {
22+
23+
beforeEach(() => {
24+
appName = rando();
25+
TestBed.configureTestingModule({
26+
imports: [
27+
provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)),
28+
provideAnalytics(() => {
29+
providedAnalytics = getAnalytics(getApp(appName));
30+
return providedAnalytics;
31+
}),
32+
],
33+
});
34+
app = TestBed.inject(FirebaseApp);
35+
analytics = TestBed.inject(Analytics);
36+
});
37+
38+
afterEach(() => {
39+
deleteApp(app).catch(() => undefined);
40+
});
41+
42+
it('should be injectable', () => {
43+
expect(providedAnalytics).toBeTruthy();
44+
expect(analytics).toEqual(providedAnalytics);
45+
expect(analytics.app).toEqual(app);
46+
});
47+
48+
});
49+
50+
});

src/analytics/firebase.ts

+4-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/analytics/overrides.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { ɵisAnalyticsSupportedFactory } from '@angular/fire';
2+
3+
export const isSupported = ɵisAnalyticsSupportedFactory.async;

src/analytics/screen-tracking.service.ts

+16-23
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
import { Inject, ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core';
1+
import { ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core';
22
import { of, Subscription, Observable } from 'rxjs';
33
import { distinctUntilChanged, filter, groupBy, map, mergeMap, pairwise, startWith, switchMap } from 'rxjs/operators';
44
import { ActivationEnd, Router, ɵEmptyOutletComponent } from '@angular/router';
55
import { Title } from '@angular/platform-browser';
66
import { VERSION } from '@angular/fire';
7-
import { FirebaseApp } from '@angular/fire/app';
87
import { registerVersion } from 'firebase/app';
98

109
import { Analytics } from './analytics';
11-
import { logEvent } from './firebase';
10+
import { logEvent, isSupported } from './firebase';
1211
import { UserTrackingService } from './user-tracking.service';
13-
import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module';
1412

1513
const FIREBASE_EVENT_ORIGIN_KEY = 'firebase_event_origin';
1614
const FIREBASE_PREVIOUS_SCREEN_CLASS_KEY = 'firebase_previous_class';
@@ -153,28 +151,23 @@ export class ScreenTrackingService implements OnDestroy {
153151
componentFactoryResolver: ComponentFactoryResolver,
154152
zone: NgZone,
155153
@Optional() userTrackingService: UserTrackingService,
156-
firebaseApp: FirebaseApp,
157-
@Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>,
158154
injector: Injector,
159155
) {
160156
registerVersion('angularfire', VERSION.full, 'screen-tracking');
161-
if (!router) { return this; }
162-
// Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way
163-
const analyticsInstance: Promise<Analytics|null> = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => {
164-
const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector));
165-
return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp);
166-
});
167-
zone.runOutsideAngular(() => {
168-
this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe(
169-
switchMap(async params => {
170-
if (userTrackingService) {
171-
await userTrackingService.initialized;
172-
}
173-
const analytics = await analyticsInstance;
174-
if (!analytics) { return; }
175-
return logEvent(analytics, SCREEN_VIEW_EVENT, params);
176-
})
177-
).subscribe();
157+
// The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI
158+
// may not be done when services are initialized. Guard the functionality by first ensuring
159+
// that the (global) promise has resolved, then get Analytics from the injector.
160+
isSupported().then(() => {
161+
const analytics = injector.get(Analytics);
162+
if (!router || !analytics) { return; }
163+
zone.runOutsideAngular(() => {
164+
this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe(
165+
switchMap(async params => {
166+
if (userTrackingService) { await userTrackingService.initialized; }
167+
return logEvent(analytics, SCREEN_VIEW_EVENT, params);
168+
})
169+
).subscribe();
170+
});
178171
});
179172
}
180173

src/analytics/user-tracking.service.ts

+21-19
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,43 @@
1-
import { Inject, Injectable, Injector, NgZone, OnDestroy } from '@angular/core';
2-
import { Analytics } from './analytics';
1+
import { Injectable, Injector, NgZone, OnDestroy } from '@angular/core';
32
import { Subscription } from 'rxjs';
43
import { VERSION } from '@angular/fire';
54
import { Auth, authState } from '@angular/fire/auth';
65
import { registerVersion } from 'firebase/app';
7-
import { setUserId } from './firebase';
8-
import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module';
9-
import { FirebaseApp } from '@angular/fire/app';
6+
7+
import { Analytics } from './analytics';
8+
import { setUserId, isSupported } from './firebase';
109

1110
@Injectable()
1211
export class UserTrackingService implements OnDestroy {
1312

1413
public readonly initialized: Promise<void>;
15-
private readonly disposables: Array<Subscription> = [];
14+
private disposables: Array<Subscription> = [];
1615

1716
constructor(
1817
auth: Auth,
1918
zone: NgZone,
20-
@Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>,
2119
injector: Injector,
22-
firebaseApp: FirebaseApp,
2320
) {
2421
registerVersion('angularfire', VERSION.full, 'user-tracking');
25-
// Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way
26-
const analyticsInstance: Promise<Analytics|null> = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => {
27-
const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector));
28-
return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp);
29-
});
3022
let resolveInitialized: () => void;
3123
this.initialized = zone.runOutsideAngular(() => new Promise(resolve => { resolveInitialized = resolve; }));
32-
this.disposables = [
33-
// TODO add credential tracking back in
34-
authState(auth).subscribe(user => {
35-
analyticsInstance.then(analytics => analytics && setUserId(analytics, user?.uid));
24+
// The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI
25+
// may not be done when services are initialized. Guard the functionality by first ensuring
26+
// that the (global) promise has resolved, then get Analytics from the injector.
27+
isSupported().then(() => {
28+
const analytics = injector.get(Analytics);
29+
if (analytics) {
30+
this.disposables = [
31+
// TODO add credential tracking back in
32+
authState(auth).subscribe(user => {
33+
setUserId(analytics, user?.uid);
34+
resolveInitialized();
35+
}),
36+
];
37+
} else {
3638
resolveInitialized();
37-
}),
38-
];
39+
}
40+
});
3941
}
4042

4143
ngOnDestroy() {

src/app-check/app-check.spec.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app';
3+
import { Auth, provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth';
4+
import { COMMON_CONFIG } from '../test-config';
5+
import { rando } from '../utils';
6+
7+
describe('Auth', () => {
8+
let app: FirebaseApp;
9+
let auth: Auth;
10+
let providedAuth: Auth;
11+
let appName: string;
12+
13+
describe('single injection', () => {
14+
15+
beforeEach(() => {
16+
appName = rando();
17+
TestBed.configureTestingModule({
18+
imports: [
19+
provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)),
20+
provideAuth(() => {
21+
providedAuth = getAuth(getApp(appName));
22+
connectAuthEmulator(providedAuth, 'http://localhost:9099');
23+
return providedAuth;
24+
}),
25+
],
26+
});
27+
app = TestBed.inject(FirebaseApp);
28+
auth = TestBed.inject(Auth);
29+
});
30+
31+
afterEach(() => {
32+
deleteApp(app).catch(() => undefined);
33+
});
34+
35+
it('should be injectable', () => {
36+
expect(auth).toBeTruthy();
37+
expect(auth).toEqual(providedAuth);
38+
expect(auth.app).toEqual(app);
39+
});
40+
41+
});
42+
43+
});

src/app/app.spec.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { FirebaseApp, provideFirebaseApp, initializeApp, deleteApp } from '@angular/fire/app';
3+
import { COMMON_CONFIG } from '../test-config';
4+
import { rando } from '../utils';
5+
6+
describe('FirebaseApp', () => {
7+
let app: FirebaseApp;
8+
let providedApp: FirebaseApp;
9+
let appName: string;
10+
11+
describe('single injection', () => {
12+
13+
beforeEach(() => {
14+
appName = rando();
15+
TestBed.configureTestingModule({
16+
imports: [
17+
provideFirebaseApp(() => {
18+
providedApp = initializeApp(COMMON_CONFIG, appName);
19+
return providedApp;
20+
})
21+
],
22+
});
23+
app = TestBed.inject(FirebaseApp);
24+
});
25+
26+
afterEach(() => {
27+
deleteApp(app).catch(() => undefined);
28+
});
29+
30+
it('should be injectable', () => {
31+
expect(app).toBeTruthy();
32+
expect(app).toEqual(providedApp);
33+
});
34+
35+
});
36+
37+
});

0 commit comments

Comments
 (0)