Skip to content

Commit

Permalink
feat(auth-guard): AngularFire auth guards (#2016)
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesdaniels committed May 23, 2019
1 parent be0a1fb commit e32164d
Show file tree
Hide file tree
Showing 19 changed files with 323 additions and 0 deletions.
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
102 changes: 102 additions & 0 deletions docs/auth/router-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Route users with AngularFire guards

`AngularFireAuthGuard` provides a prebuilt [`canActivate` Router Guard](https://angular.io/api/router/CanActivate) using `AngularFireAuth`. By default unauthenticated users are not permitted to navigate to protected routes:

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

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

## Customizing the behavior of `AngularFireAuthGuard`

To customize the behavior of `AngularFireAuthGuard`, you can pass an RXJS pipe through the route data's `authGuardPipe` key.

The `auth-guard` module provides the following pre-built pipes:

| Exported pipe | Functionality |
|-|-|
| `loggedIn` | The default pipe, rejects if the user is not authenticated. |
| `isNotAnonymous` | Rejects if the user is anonymous |
| `emailVerified` | Rejects if the user's email is not verified |
| `hasCustomClaim(claim)` | Rejects if the user does not have the specified claim |
| `redirectUnauthorizedTo(redirect)` | Redirect unauthenticated users to a different route |
| `redirectLoggedInTo(redirect)` | Redirect authenticated users to a different route |

Example use:

```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 }}
];
```
Use the provided `canActivate` helper and spread syntax to make your routes more readable:
```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
`AngularFireAuthGuard` pipes are RXJS operators which transform an optional User to a boolean or Array (for redirects). You can build easily build your own to customize behavior further:
```ts
import { map } from 'rxjs/operators';

// This pipe redirects a user to their "profile edit" page or the "login page" if they're unauthenticated
// { path: 'profile', ...canActivate(redirectToProfileEditOrLogin) }
const redirectToProfileEditOrLogin = map(user => user ? ['profiles', user.uid, 'edit'] : ['login']);
```
The `auth-guard` modules provides a `customClaims` operator to reduce boiler plate when checking a user's claims:
```ts
import { pipe } from 'rxjs';
import { map } from 'rxjs/operators';
import { customClaims } from '@angular/fire/auth-guard';

// This pipe will only allow users with the editor role to access the route
// { path: 'articles/:id/edit', component: ArticleEditComponent, ...canActivate(editorOnly) }
const editorOnly = pipe(customClaims, map(claims => claims.role === "editor"));
```
### Using router state
`AngularFireAuthGuard` will also accept `AuthPipeGenerator`s which generate `AuthPipe`s given the router state:
```ts
import { pipe } from 'rxjs';
import { map } from 'rxjs/operators';
import { customClaims } from '@angular/fire/auth-guard';

// Only allow navigation to the route if :userId matches the authenticated user's uid
// { path: 'user/:userId/edit', component: ProfileEditComponent, ...canActivate(onlyAllowSelf) }
const onlyAllowSelf = (next) => map(user => !!user && next.params.userId === user.uid);

// Only allow navigation to the route if the user has a custom claim matching :accountId
// { path: 'accounts/:accountId/billing', component: BillingDetailsComponent, ...canActivate(accountAdmin) }
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 @@ -41,6 +41,7 @@
"@angular/core": ">=6.0.0 <9 || 9.0.0-0",
"@angular/platform-browser": ">=6.0.0 <9 || 9.0.0-0",
"@angular/platform-browser-dynamic": ">=6.0.0 <9 || 9.0.0-0",
"@angular/router": ">=6.0.0 <9 || 9.0.0-0",
"firebase": ">= 5.5.7 <7",
"firebase-tools": "^6.10.0",
"fuzzy": "^0.1.3",
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();
});
});
38 changes: 38 additions & 0 deletions src/auth-guard/auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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 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));
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

0 comments on commit e32164d

Please sign in to comment.