Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(): Angularfire auth guards #2016

Merged
merged 13 commits into from
May 23, 2019
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ Firebase offers two cloud-based, client-accessible database solutions that suppo
### Authenticate users

- [Getting started with Firebase Authentication](docs/auth/getting-started.md)
- [Route users with AngularFire guards](docs/auth/router-guards.md)

### Upload files

- [Getting started with Cloud Storage](docs/storage/storage.md)

### Send push notifications

- [Getting started with Firebase Messaging](docs/messaging/messaging.md)

### Directly call Cloud Functions

- [Getting started with Callable Functions](docs/functions/functions.md)

### Deploying your application
Expand Down
58 changes: 58 additions & 0 deletions docs/auth/router-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Route users with AngularFire guards

jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved
## Basic example

```ts
import { AngularFireAuthGuard } from '@angular/fire/auth-guard';

export const routes: Routes = [
{ path: '', component: AppComponent },
{ path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard] },
]
```

## Use our pre-built pipes for common tests
jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved

```ts
import { AngularFireAuthGuard, hasCustomClaim, redirectUnauthorizedTo, redirectLoggedInTo } from '@angular/fire/auth-guard';

const adminOnly = hasCustomClaim('admin');
const redirectUnauthorizedToLogin = redirectUnauthorizedTo(['login']);
const redirectLoggedInToItems = redirectLoggedInTo(['items']);
const belongsToAccount = (next) => hasCustomClaim(`account-${next.params.id}`);

export const routes: Routes = [
{ path: '', component: AppComponent },
{ path: 'login', component: LoginComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectLoggedInToItems }},
{ path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectUnauthorizedToLogin },
{ path: 'admin', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: adminOnly }},
{ path: 'accounts/:id', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: belongsToAccount }}
];
```

## Increase readability with our `canActivate` helper

```ts
import { canActivate } from '@angular/fire/auth-guard';

export const routes: Routes = [
{ path: '', component: AppComponent },
{ path: 'login', component: LoginComponent, ...canActivate(redirectLoggedInToItems) },
{ path: 'items', component: ItemListComponent, ...canActivate(redirectUnauthorizedToLogin) },
{ path: 'admin', component: AdminComponent, ...canActivate(adminOnly) },
{ path: 'accounts/:id', component: AdminComponent, ...canActivate(belongsToAccount) }
];
```

## Compose your own pipes

```ts
import { pipe, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { customClaims } from '@angular/fire/auth-guard';

const editorOnly = pipe(customClaims, map(claims => claims.role === "editor"));
const redirectToProfileEditOrLogin = map(user => user ? ['profiles', user.uid, 'edit'] : ['login']);
const onlyAllowSelf = (next) => map(user => !!user && next.params.userId === user.uid);
const accountAdmin = (next) => pipe(customClaims, map(claims => claims[`account-${next.params.accountId}-role`] === "admin"));
```
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module.exports = function(config) {
'node_modules/firebase/firebase-storage.js',
'dist/packages-dist/bundles/core.umd.{js,map}',
'dist/packages-dist/bundles/auth.umd.{js,map}',
'dist/packages-dist/bundles/auth-guard.umd.{js,map}',
'dist/packages-dist/bundles/database.umd.{js,map}',
'dist/packages-dist/bundles/firestore.umd.{js,map}',
'dist/packages-dist/bundles/functions.umd.{js,map}',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@angular/core": ">=6.0.0 <8",
"@angular/platform-browser": ">=6.0.0 <8",
"@angular/platform-browser-dynamic": ">=6.0.0 <8",
"@angular/router": ">=6.0.0 <8",
"firebase": ">= 5.5.0 <7",
"rxjs": "^6.0.0",
"ws": "^3.3.2",
Expand Down
7 changes: 7 additions & 0 deletions src/auth-guard/auth-guard.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NgModule } from '@angular/core';
import { AngularFireAuthGuard } from './auth-guard';

@NgModule({
providers: [ AngularFireAuthGuard ]
})
export class AngularFireAuthGuardModule { }
40 changes: 40 additions & 0 deletions src/auth-guard/auth-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { TestBed, inject } from '@angular/core/testing';
import { FirebaseApp, AngularFireModule } from '@angular/fire';
import { COMMON_CONFIG } from './test-config';
import { AngularFireAuthModule } from '@angular/fire/auth';
import { AngularFireAuthGuardModule, AngularFireAuthGuard } from '@angular/fire/auth-guard';
import { RouterModule, Router } from '@angular/router';
import { APP_BASE_HREF } from '@angular/common';

describe('AngularFireAuthGuard', () => {
let app: FirebaseApp;
let router: Router;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
AngularFireModule.initializeApp(COMMON_CONFIG),
AngularFireAuthModule,
AngularFireAuthGuardModule,
RouterModule.forRoot([
{ path: 'a', redirectTo: '/', canActivate: [AngularFireAuthGuard] }
])
],
providers: [
{ provide: APP_BASE_HREF, useValue: 'http://localhost:4200/' }
]
});
inject([FirebaseApp, Router], (app_: FirebaseApp, router_: Router) => {
app = app_;
router = router_;
})();
});

afterEach(done => {
app.delete().then(done, done.fail);
});

it('should be injectable', () => {
expect(router).toBeTruthy();
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here's a StackOverflow answer with suggestions on ways to test auth guards. But maybe that's overkill and we just have a dummy user object and quick happy path tests for the different built-in pipes for now?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe also spy on the AngularFireAuthGuard pipe to make sure it calls the pipe provided to it with the data provided to it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll have to put together a proper E2E for this, something I'm working on on another branch. Think I'll punt on building more robust tests for now & take it as an action item.

});
40 changes: 40 additions & 0 deletions src/auth-guard/auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Injectable, InjectionToken } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable, of, pipe, UnaryFunction } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'
import { User, auth } from 'firebase/app';
import { AngularFireAuth } from '@angular/fire/auth';

export const EnableRouterGuardListeners = new InjectionToken<boolean>('angularfire2.enableRouterGuardListeners');

export type AuthPipeGenerator = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => AuthPipe;
export type AuthPipe = UnaryFunction<Observable<User|null>, Observable<boolean|any[]>>;

@Injectable()
export class AngularFireAuthGuard implements CanActivate {

constructor(private afAuth: AngularFireAuth, private router: Router) {}

canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const authPipeFactory: AuthPipeGenerator = next.data.authGuardPipe || (() => loggedIn);
return this.afAuth.user.pipe(
take(1),
authPipeFactory(next, state),
map(canActivate => typeof canActivate == "boolean" ? canActivate : this.router.createUrlTree(canActivate))
);
}

}

export const canActivate = (pipe: AuthPipe|AuthPipeGenerator) => ({
canActivate: [ AngularFireAuthGuard ], data: { authGuardPipe: pipe.name === "" ? pipe : () => pipe}
});

export const loggedIn: AuthPipe = map(user => !!user);
export const isNotAnonymous: AuthPipe = map(user => !!user && !user.isAnonymous);
export const idTokenResult = switchMap((user: User|null) => user ? user.getIdTokenResult() : of(null));
jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved
export const emailVerified: AuthPipe = map(user => !!user && user.emailVerified);
export const customClaims = pipe(idTokenResult, map(idTokenResult => idTokenResult ? idTokenResult.claims : []));
export const hasCustomClaim = (claim:string) => pipe(customClaims, map(claims => claims.hasOwnProperty(claim)));
export const redirectUnauthorizedTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn || redirect));
export const redirectLoggedInTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn && redirect || true));
1 change: 1 addition & 0 deletions src/auth-guard/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './auth-guard.spec';
1 change: 1 addition & 0 deletions src/auth-guard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public_api';
32 changes: 32 additions & 0 deletions src/auth-guard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@angular/fire/auth-guard",
"version": "ANGULARFIRE2_VERSION",
"description": "The auth guard module",
"main": "../bundles/auth-guard.umd.js",
"module": "index.js",
"es2015": "./es2015/index.js",
"keywords": [
"angular",
"firebase",
"rxjs"
],
"repository": {
"type": "git",
"url": "git+https://github.com/angular/angularfire2.git"
},
"author": "angular,firebase",
"license": "MIT",
"peerDependencies": {
"@angular/fire": "ANGULARFIRE2_VERSION",
"@angular/common": "ANGULAR_VERSION",
"@angular/core": "ANGULAR_VERSION",
"@angular/platform-browser": "ANGULAR_VERSION",
"@angular/platform-browser-dynamic": "ANGULAR_VERSION",
"@angular/router": "ANGULAR_VERSION",
"firebase": "FIREBASE_VERSION",
"rxjs": "RXJS_VERSION",
"zone.js": "ZONEJS_VERSION"
},
"typings": "index.d.ts"
}

2 changes: 2 additions & 0 deletions src/auth-guard/public_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './auth-guard';
export * from './auth-guard.module';
7 changes: 7 additions & 0 deletions src/auth-guard/test-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

export const COMMON_CONFIG = {
apiKey: "AIzaSyBVSy3YpkVGiKXbbxeK0qBnu3-MNZ9UIjA",
authDomain: "angularfire2-test.firebaseapp.com",
databaseURL: "https://angularfire2-test.firebaseio.com",
storageBucket: "angularfire2-test.appspot.com",
};
34 changes: 34 additions & 0 deletions src/auth-guard/tsconfig-build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"baseUrl": ".",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "es2015",
"target": "es2015",
"noImplicitAny": false,
"outDir": "../../dist/packages-dist/auth-guard/es2015",
"rootDir": ".",
"sourceMap": true,
"inlineSources": true,
"declaration": false,
"removeComments": true,
"strictNullChecks": true,
"lib": ["es2015", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"],
"skipLibCheck": true,
"moduleResolution": "node",
"paths": {
"@angular/fire": ["../../dist/packages-dist"],
"@angular/fire/auth": ["../../dist/packages-dist/auth"]
}
},
"files": [
"index.ts",
"../../node_modules/zone.js/dist/zone.js.d.ts"
],
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableSummariesForJit": false
}
}

19 changes: 19 additions & 0 deletions src/auth-guard/tsconfig-esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig-build.json",
"compilerOptions": {
"target": "es5",
"outDir": "../../dist/packages-dist/auth-guard",
"declaration": true
},
"files": [
"public_api.ts",
"../../node_modules/zone.js/dist/zone.js.d.ts"
],
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableSummariesForJit": false,
"flatModuleOutFile": "index.js",
"flatModuleId": "@angular/fire/auth-guard"
}
}
15 changes: 15 additions & 0 deletions src/auth-guard/tsconfig-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig-esm.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@angular/fire": ["../../dist/packages-dist"],
"@angular/fire/auth": ["../../dist/packages-dist/auth"],
"@angular/fire/auth-guard": ["../../dist/packages-dist/auth-guard"]
}
},
"files": [
"index.spec.ts",
"../../node_modules/zone.js/dist/zone.js.d.ts"
]
}
1 change: 1 addition & 0 deletions src/root.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// These paths are written to use the dist build
export * from './packages-dist/angularfire2.spec';
export * from './packages-dist/auth/auth.spec';
export * from './packages-dist/auth-guard/auth-guard.spec';
export * from './packages-dist/firestore/firestore.spec';
export * from './packages-dist/firestore/document/document.spec';
export * from './packages-dist/firestore/collection/collection.spec';
Expand Down
1 change: 1 addition & 0 deletions src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"paths": {
"@angular/fire": ["./core"],
"@angular/fire/auth": ["./auth"],
"@angular/fire/auth-guard": ["./auth-guard"],
"@angular/fire/database": ["./database"],
"@angular/fire/firestore": ["./firestore"],
"@angular/fire/functions": ["./functions"],
Expand Down
Loading