Skip to content

Commit

Permalink
feat(auth-guard): Adding in modular auth guards (#3001)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jamesdaniels authored Oct 8, 2021
1 parent 5d6d8bf commit 3ae6ce5
Show file tree
Hide file tree
Showing 38 changed files with 836 additions and 117 deletions.
6 changes: 6 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
"rules": "test/storage.rules"
},
"emulators": {
"auth": {
"port": 9099
},
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
Expand Down
2 changes: 1 addition & 1 deletion samples/advanced/src/app/app.module.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 9 additions & 25 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,23 @@
import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, APP_INITIALIZER, Injector } from '@angular/core';
import { Analytics as FirebaseAnalytics, isSupported } from 'firebase/analytics';
import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angular/fire';
import { Analytics as FirebaseAnalytics } from 'firebase/analytics';
import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION, ɵisAnalyticsSupportedFactory } from '@angular/fire';
import { Analytics, ANALYTICS_PROVIDER_NAME, AnalyticsInstances } from './analytics';
import { FirebaseApps, FirebaseApp } from '@angular/fire/app';
import { registerVersion } from 'firebase/app';
import { ScreenTrackingService } from './screen-tracking.service';
import { UserTrackingService } from './user-tracking.service';

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

const isSupportedValueSymbol = Symbol('angularfire2.analytics.isSupported.value');
export const isSupportedPromiseSymbol = Symbol('angularfire2.analytics.isSupported');

globalThis[isSupportedPromiseSymbol] ||= isSupported().then(it => globalThis[isSupportedValueSymbol] = it);

export function defaultAnalyticsInstanceFactory(isSupported: boolean, provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) {
if (!isSupported) { return null; }
export function defaultAnalyticsInstanceFactory(provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) {
if (!ɵisAnalyticsSupportedFactory.sync()) { return null; }
const defaultAnalytics = ɵgetDefaultInstanceOf<FirebaseAnalytics>(ANALYTICS_PROVIDER_NAME, provided, defaultApp);
return defaultAnalytics && new Analytics(defaultAnalytics);
}

export function analyticsInstanceFactory(fn: (injector: Injector) => FirebaseAnalytics) {
return (zone: NgZone, isSupported: boolean, injector: Injector) => {
if (!isSupported) { return null; }
return (zone: NgZone, injector: Injector) => {
if (!ɵisAnalyticsSupportedFactory.sync()) { return null; }
const analytics = zone.runOutsideAngular(() => fn(injector));
return new Analytics(analytics);
};
Expand All @@ -41,7 +34,6 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = {
provide: Analytics,
useFactory: defaultAnalyticsInstanceFactory,
deps: [
IS_SUPPORTED,
[new Optional(), PROVIDED_ANALYTICS_INSTANCES ],
FirebaseApp,
]
Expand All @@ -53,15 +45,15 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = {
ANALYTICS_INSTANCES_PROVIDER,
{
provide: APP_INITIALIZER,
useValue: () => globalThis[isSupportedPromiseSymbol],
useValue: ɵisAnalyticsSupportedFactory.async,
multi: true,
}
]
})
export class AnalyticsModule {
constructor(
@Optional() _screenTracking: ScreenTrackingService,
@Optional() _userTracking: UserTrackingService,
@Optional() _screenTrackingService: ScreenTrackingService,
@Optional() _userTrackingService: UserTrackingService,
) {
registerVersion('angularfire', VERSION.full, 'analytics');
}
Expand All @@ -71,19 +63,11 @@ export function provideAnalytics(fn: (injector: Injector) => FirebaseAnalytics,
return {
ngModule: AnalyticsModule,
providers: [{
provide: IS_SUPPORTED,
useFactory: () => globalThis[isSupportedValueSymbol],
}, {
provide: PROVIDED_ANALYTICS_INSTANCE_FACTORIES,
useValue: fn,
multi: true,
}, {
provide: PROVIDED_ANALYTICS_INSTANCES,
useFactory: analyticsInstanceFactory(fn),
multi: true,
deps: [
NgZone,
IS_SUPPORTED,
Injector,
ɵAngularFireSchedulers,
FirebaseApps,
Expand Down
50 changes: 50 additions & 0 deletions src/analytics/analytics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app';
import { Analytics, provideAnalytics, getAnalytics, isSupported } from '@angular/fire/analytics';
import { COMMON_CONFIG } from '../test-config';
import { rando } from '../utils';

describe('Analytics', () => {
let app: FirebaseApp;
let analytics: Analytics;
let providedAnalytics: Analytics;
let appName: string;

beforeAll(done => {
// The APP_INITIALIZER that is making isSupported() sync for DI may not
// be done evaulating by the time we inject from the TestBed. We can
// ensure correct behavior by waiting for the (global) isSuppported() promise
// to resolve.
isSupported().then(() => done());
});

describe('single injection', () => {

beforeEach(() => {
appName = rando();
TestBed.configureTestingModule({
imports: [
provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)),
provideAnalytics(() => {
providedAnalytics = getAnalytics(getApp(appName));
return providedAnalytics;
}),
],
});
app = TestBed.inject(FirebaseApp);
analytics = TestBed.inject(Analytics);
});

afterEach(() => {
deleteApp(app).catch(() => undefined);
});

it('should be injectable', () => {
expect(providedAnalytics).toBeTruthy();
expect(analytics).toEqual(providedAnalytics);
expect(analytics.app).toEqual(app);
});

});

});
6 changes: 4 additions & 2 deletions src/analytics/firebase.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/analytics/overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ɵisAnalyticsSupportedFactory } from '@angular/fire';

export const isSupported = ɵisAnalyticsSupportedFactory.async;
39 changes: 16 additions & 23 deletions src/analytics/screen-tracking.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { Inject, ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core';
import { ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core';
import { of, Subscription, Observable } from 'rxjs';
import { distinctUntilChanged, filter, groupBy, map, mergeMap, pairwise, startWith, switchMap } from 'rxjs/operators';
import { ActivationEnd, Router, ɵEmptyOutletComponent } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { VERSION } from '@angular/fire';
import { FirebaseApp } from '@angular/fire/app';
import { registerVersion } from 'firebase/app';

import { Analytics } from './analytics';
import { logEvent } from './firebase';
import { logEvent, isSupported } from './firebase';
import { UserTrackingService } from './user-tracking.service';
import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module';

const FIREBASE_EVENT_ORIGIN_KEY = 'firebase_event_origin';
const FIREBASE_PREVIOUS_SCREEN_CLASS_KEY = 'firebase_previous_class';
Expand Down Expand Up @@ -153,28 +151,23 @@ export class ScreenTrackingService implements OnDestroy {
componentFactoryResolver: ComponentFactoryResolver,
zone: NgZone,
@Optional() userTrackingService: UserTrackingService,
firebaseApp: FirebaseApp,
@Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>,
injector: Injector,
) {
registerVersion('angularfire', VERSION.full, 'screen-tracking');
if (!router) { return this; }
// Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way
const analyticsInstance: Promise<Analytics|null> = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => {
const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector));
return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp);
});
zone.runOutsideAngular(() => {
this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe(
switchMap(async params => {
if (userTrackingService) {
await userTrackingService.initialized;
}
const analytics = await analyticsInstance;
if (!analytics) { return; }
return logEvent(analytics, SCREEN_VIEW_EVENT, params);
})
).subscribe();
// The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI
// may not be done when services are initialized. Guard the functionality by first ensuring
// that the (global) promise has resolved, then get Analytics from the injector.
isSupported().then(() => {
const analytics = injector.get(Analytics);
if (!router || !analytics) { return; }
zone.runOutsideAngular(() => {
this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe(
switchMap(async params => {
if (userTrackingService) { await userTrackingService.initialized; }
return logEvent(analytics, SCREEN_VIEW_EVENT, params);
})
).subscribe();
});
});
}

Expand Down
40 changes: 21 additions & 19 deletions src/analytics/user-tracking.service.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,43 @@
import { Inject, Injectable, Injector, NgZone, OnDestroy } from '@angular/core';
import { Analytics } from './analytics';
import { Injectable, Injector, NgZone, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { VERSION } from '@angular/fire';
import { Auth, authState } from '@angular/fire/auth';
import { registerVersion } from 'firebase/app';
import { setUserId } from './firebase';
import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module';
import { FirebaseApp } from '@angular/fire/app';

import { Analytics } from './analytics';
import { setUserId, isSupported } from './firebase';

@Injectable()
export class UserTrackingService implements OnDestroy {

public readonly initialized: Promise<void>;
private readonly disposables: Array<Subscription> = [];
private disposables: Array<Subscription> = [];

constructor(
auth: Auth,
zone: NgZone,
@Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>,
injector: Injector,
firebaseApp: FirebaseApp,
) {
registerVersion('angularfire', VERSION.full, 'user-tracking');
// Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way
const analyticsInstance: Promise<Analytics|null> = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => {
const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector));
return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp);
});
let resolveInitialized: () => void;
this.initialized = zone.runOutsideAngular(() => new Promise(resolve => { resolveInitialized = resolve; }));
this.disposables = [
// TODO add credential tracking back in
authState(auth).subscribe(user => {
analyticsInstance.then(analytics => analytics && setUserId(analytics, user?.uid));
// The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI
// may not be done when services are initialized. Guard the functionality by first ensuring
// that the (global) promise has resolved, then get Analytics from the injector.
isSupported().then(() => {
const analytics = injector.get(Analytics);
if (analytics) {
this.disposables = [
// TODO add credential tracking back in
authState(auth).subscribe(user => {
setUserId(analytics, user?.uid);
resolveInitialized();
}),
];
} else {
resolveInitialized();
}),
];
}
});
}

ngOnDestroy() {
Expand Down
43 changes: 43 additions & 0 deletions src/app-check/app-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app';
import { Auth, provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth';
import { COMMON_CONFIG } from '../test-config';
import { rando } from '../utils';

describe('Auth', () => {
let app: FirebaseApp;
let auth: Auth;
let providedAuth: Auth;
let appName: string;

describe('single injection', () => {

beforeEach(() => {
appName = rando();
TestBed.configureTestingModule({
imports: [
provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)),
provideAuth(() => {
providedAuth = getAuth(getApp(appName));
connectAuthEmulator(providedAuth, 'http://localhost:9099');
return providedAuth;
}),
],
});
app = TestBed.inject(FirebaseApp);
auth = TestBed.inject(Auth);
});

afterEach(() => {
deleteApp(app).catch(() => undefined);
});

it('should be injectable', () => {
expect(auth).toBeTruthy();
expect(auth).toEqual(providedAuth);
expect(auth.app).toEqual(app);
});

});

});
37 changes: 37 additions & 0 deletions src/app/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { FirebaseApp, provideFirebaseApp, initializeApp, deleteApp } from '@angular/fire/app';
import { COMMON_CONFIG } from '../test-config';
import { rando } from '../utils';

describe('FirebaseApp', () => {
let app: FirebaseApp;
let providedApp: FirebaseApp;
let appName: string;

describe('single injection', () => {

beforeEach(() => {
appName = rando();
TestBed.configureTestingModule({
imports: [
provideFirebaseApp(() => {
providedApp = initializeApp(COMMON_CONFIG, appName);
return providedApp;
})
],
});
app = TestBed.inject(FirebaseApp);
});

afterEach(() => {
deleteApp(app).catch(() => undefined);
});

it('should be injectable', () => {
expect(app).toBeTruthy();
expect(app).toEqual(providedApp);
});

});

});
Loading

0 comments on commit 3ae6ce5

Please sign in to comment.