Skip to content

Commit e32164d

Browse files
authored
feat(auth-guard): AngularFire auth guards (#2016)
1 parent be0a1fb commit e32164d

19 files changed

+323
-0
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,18 @@ Firebase offers two cloud-based, client-accessible database solutions that suppo
8888
### Authenticate users
8989

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

9293
### Upload files
94+
9395
- [Getting started with Cloud Storage](docs/storage/storage.md)
9496

9597
### Send push notifications
98+
9699
- [Getting started with Firebase Messaging](docs/messaging/messaging.md)
97100

98101
### Directly call Cloud Functions
102+
99103
- [Getting started with Callable Functions](docs/functions/functions.md)
100104

101105
### Deploying your application

docs/auth/router-guards.md

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Route users with AngularFire guards
2+
3+
`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:
4+
5+
```ts
6+
import { AngularFireAuthGuard } from '@angular/fire/auth-guard';
7+
8+
export const routes: Routes = [
9+
{ path: '', component: AppComponent },
10+
{ path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard] },
11+
]
12+
```
13+
14+
## Customizing the behavior of `AngularFireAuthGuard`
15+
16+
To customize the behavior of `AngularFireAuthGuard`, you can pass an RXJS pipe through the route data's `authGuardPipe` key.
17+
18+
The `auth-guard` module provides the following pre-built pipes:
19+
20+
| Exported pipe | Functionality |
21+
|-|-|
22+
| `loggedIn` | The default pipe, rejects if the user is not authenticated. |
23+
| `isNotAnonymous` | Rejects if the user is anonymous |
24+
| `emailVerified` | Rejects if the user's email is not verified |
25+
| `hasCustomClaim(claim)` | Rejects if the user does not have the specified claim |
26+
| `redirectUnauthorizedTo(redirect)` | Redirect unauthenticated users to a different route |
27+
| `redirectLoggedInTo(redirect)` | Redirect authenticated users to a different route |
28+
29+
Example use:
30+
31+
```ts
32+
import { AngularFireAuthGuard, hasCustomClaim, redirectUnauthorizedTo, redirectLoggedInTo } from '@angular/fire/auth-guard';
33+
34+
const adminOnly = hasCustomClaim('admin');
35+
const redirectUnauthorizedToLogin = redirectUnauthorizedTo(['login']);
36+
const redirectLoggedInToItems = redirectLoggedInTo(['items']);
37+
const belongsToAccount = (next) => hasCustomClaim(`account-${next.params.id}`);
38+
39+
export const routes: Routes = [
40+
{ path: '', component: AppComponent },
41+
{ path: 'login', component: LoginComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectLoggedInToItems }},
42+
{ path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectUnauthorizedToLogin },
43+
{ path: 'admin', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: adminOnly }},
44+
{ path: 'accounts/:id', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: belongsToAccount }}
45+
];
46+
```
47+
48+
Use the provided `canActivate` helper and spread syntax to make your routes more readable:
49+
50+
```ts
51+
import { canActivate } from '@angular/fire/auth-guard';
52+
53+
export const routes: Routes = [
54+
{ path: '', component: AppComponent },
55+
{ path: 'login', component: LoginComponent, ...canActivate(redirectLoggedInToItems) },
56+
{ path: 'items', component: ItemListComponent, ...canActivate(redirectUnauthorizedToLogin) },
57+
{ path: 'admin', component: AdminComponent, ...canActivate(adminOnly) },
58+
{ path: 'accounts/:id', component: AdminComponent, ...canActivate(belongsToAccount) }
59+
];
60+
```
61+
62+
### Compose your own pipes
63+
64+
`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:
65+
66+
```ts
67+
import { map } from 'rxjs/operators';
68+
69+
// This pipe redirects a user to their "profile edit" page or the "login page" if they're unauthenticated
70+
// { path: 'profile', ...canActivate(redirectToProfileEditOrLogin) }
71+
const redirectToProfileEditOrLogin = map(user => user ? ['profiles', user.uid, 'edit'] : ['login']);
72+
```
73+
74+
The `auth-guard` modules provides a `customClaims` operator to reduce boiler plate when checking a user's claims:
75+
76+
```ts
77+
import { pipe } from 'rxjs';
78+
import { map } from 'rxjs/operators';
79+
import { customClaims } from '@angular/fire/auth-guard';
80+
81+
// This pipe will only allow users with the editor role to access the route
82+
// { path: 'articles/:id/edit', component: ArticleEditComponent, ...canActivate(editorOnly) }
83+
const editorOnly = pipe(customClaims, map(claims => claims.role === "editor"));
84+
```
85+
86+
### Using router state
87+
88+
`AngularFireAuthGuard` will also accept `AuthPipeGenerator`s which generate `AuthPipe`s given the router state:
89+
90+
```ts
91+
import { pipe } from 'rxjs';
92+
import { map } from 'rxjs/operators';
93+
import { customClaims } from '@angular/fire/auth-guard';
94+
95+
// Only allow navigation to the route if :userId matches the authenticated user's uid
96+
// { path: 'user/:userId/edit', component: ProfileEditComponent, ...canActivate(onlyAllowSelf) }
97+
const onlyAllowSelf = (next) => map(user => !!user && next.params.userId === user.uid);
98+
99+
// Only allow navigation to the route if the user has a custom claim matching :accountId
100+
// { path: 'accounts/:accountId/billing', component: BillingDetailsComponent, ...canActivate(accountAdmin) }
101+
const accountAdmin = (next) => pipe(customClaims, map(claims => claims[`account-${next.params.accountId}-role`] === "admin"));
102+
```

karma.conf.js

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = function(config) {
3333
'node_modules/firebase/firebase-storage.js',
3434
'dist/packages-dist/bundles/core.umd.{js,map}',
3535
'dist/packages-dist/bundles/auth.umd.{js,map}',
36+
'dist/packages-dist/bundles/auth-guard.umd.{js,map}',
3637
'dist/packages-dist/bundles/database.umd.{js,map}',
3738
'dist/packages-dist/bundles/firestore.umd.{js,map}',
3839
'dist/packages-dist/bundles/functions.umd.{js,map}',

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@angular/core": ">=6.0.0 <9 || 9.0.0-0",
4242
"@angular/platform-browser": ">=6.0.0 <9 || 9.0.0-0",
4343
"@angular/platform-browser-dynamic": ">=6.0.0 <9 || 9.0.0-0",
44+
"@angular/router": ">=6.0.0 <9 || 9.0.0-0",
4445
"firebase": ">= 5.5.7 <7",
4546
"firebase-tools": "^6.10.0",
4647
"fuzzy": "^0.1.3",

src/auth-guard/auth-guard.module.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NgModule } from '@angular/core';
2+
import { AngularFireAuthGuard } from './auth-guard';
3+
4+
@NgModule({
5+
providers: [ AngularFireAuthGuard ]
6+
})
7+
export class AngularFireAuthGuardModule { }

src/auth-guard/auth-guard.spec.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { TestBed, inject } from '@angular/core/testing';
2+
import { FirebaseApp, AngularFireModule } from '@angular/fire';
3+
import { COMMON_CONFIG } from './test-config';
4+
import { AngularFireAuthModule } from '@angular/fire/auth';
5+
import { AngularFireAuthGuardModule, AngularFireAuthGuard } from '@angular/fire/auth-guard';
6+
import { RouterModule, Router } from '@angular/router';
7+
import { APP_BASE_HREF } from '@angular/common';
8+
9+
describe('AngularFireAuthGuard', () => {
10+
let app: FirebaseApp;
11+
let router: Router;
12+
13+
beforeEach(() => {
14+
TestBed.configureTestingModule({
15+
imports: [
16+
AngularFireModule.initializeApp(COMMON_CONFIG),
17+
AngularFireAuthModule,
18+
AngularFireAuthGuardModule,
19+
RouterModule.forRoot([
20+
{ path: 'a', redirectTo: '/', canActivate: [AngularFireAuthGuard] }
21+
])
22+
],
23+
providers: [
24+
{ provide: APP_BASE_HREF, useValue: 'http://localhost:4200/' }
25+
]
26+
});
27+
inject([FirebaseApp, Router], (app_: FirebaseApp, router_: Router) => {
28+
app = app_;
29+
router = router_;
30+
})();
31+
});
32+
33+
afterEach(done => {
34+
app.delete().then(done, done.fail);
35+
});
36+
37+
it('should be injectable', () => {
38+
expect(router).toBeTruthy();
39+
});
40+
});

src/auth-guard/auth-guard.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Injectable, InjectionToken } from '@angular/core';
2+
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
3+
import { Observable, of, pipe, UnaryFunction } from 'rxjs';
4+
import { map, switchMap, take } from 'rxjs/operators'
5+
import { User, auth } from 'firebase/app';
6+
import { AngularFireAuth } from '@angular/fire/auth';
7+
8+
export type AuthPipeGenerator = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => AuthPipe;
9+
export type AuthPipe = UnaryFunction<Observable<User|null>, Observable<boolean|any[]>>;
10+
11+
@Injectable()
12+
export class AngularFireAuthGuard implements CanActivate {
13+
14+
constructor(private afAuth: AngularFireAuth, private router: Router) {}
15+
16+
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
17+
const authPipeFactory: AuthPipeGenerator = next.data.authGuardPipe || (() => loggedIn);
18+
return this.afAuth.user.pipe(
19+
take(1),
20+
authPipeFactory(next, state),
21+
map(canActivate => typeof canActivate == "boolean" ? canActivate : this.router.createUrlTree(canActivate))
22+
);
23+
}
24+
25+
}
26+
27+
export const canActivate = (pipe: AuthPipe|AuthPipeGenerator) => ({
28+
canActivate: [ AngularFireAuthGuard ], data: { authGuardPipe: pipe.name === "" ? pipe : () => pipe}
29+
});
30+
31+
export const loggedIn: AuthPipe = map(user => !!user);
32+
export const isNotAnonymous: AuthPipe = map(user => !!user && !user.isAnonymous);
33+
export const idTokenResult = switchMap((user: User|null) => user ? user.getIdTokenResult() : of(null));
34+
export const emailVerified: AuthPipe = map(user => !!user && user.emailVerified);
35+
export const customClaims = pipe(idTokenResult, map(idTokenResult => idTokenResult ? idTokenResult.claims : []));
36+
export const hasCustomClaim = (claim:string) => pipe(customClaims, map(claims => claims.hasOwnProperty(claim)));
37+
export const redirectUnauthorizedTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn || redirect));
38+
export const redirectLoggedInTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn && redirect || true));

src/auth-guard/index.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './auth-guard.spec';

src/auth-guard/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './public_api';

src/auth-guard/package.json

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@angular/fire/auth-guard",
3+
"version": "ANGULARFIRE2_VERSION",
4+
"description": "The auth guard module",
5+
"main": "../bundles/auth-guard.umd.js",
6+
"module": "index.js",
7+
"es2015": "./es2015/index.js",
8+
"keywords": [
9+
"angular",
10+
"firebase",
11+
"rxjs"
12+
],
13+
"repository": {
14+
"type": "git",
15+
"url": "git+https://github.com/angular/angularfire2.git"
16+
},
17+
"author": "angular,firebase",
18+
"license": "MIT",
19+
"peerDependencies": {
20+
"@angular/fire": "ANGULARFIRE2_VERSION",
21+
"@angular/common": "ANGULAR_VERSION",
22+
"@angular/core": "ANGULAR_VERSION",
23+
"@angular/platform-browser": "ANGULAR_VERSION",
24+
"@angular/platform-browser-dynamic": "ANGULAR_VERSION",
25+
"@angular/router": "ANGULAR_VERSION",
26+
"firebase": "FIREBASE_VERSION",
27+
"rxjs": "RXJS_VERSION",
28+
"zone.js": "ZONEJS_VERSION"
29+
},
30+
"typings": "index.d.ts"
31+
}
32+

src/auth-guard/public_api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './auth-guard';
2+
export * from './auth-guard.module';

src/auth-guard/test-config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
2+
export const COMMON_CONFIG = {
3+
apiKey: "AIzaSyBVSy3YpkVGiKXbbxeK0qBnu3-MNZ9UIjA",
4+
authDomain: "angularfire2-test.firebaseapp.com",
5+
databaseURL: "https://angularfire2-test.firebaseio.com",
6+
storageBucket: "angularfire2-test.appspot.com",
7+
};

src/auth-guard/tsconfig-build.json

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".",
4+
"experimentalDecorators": true,
5+
"emitDecoratorMetadata": true,
6+
"module": "es2015",
7+
"target": "es2015",
8+
"noImplicitAny": false,
9+
"outDir": "../../dist/packages-dist/auth-guard/es2015",
10+
"rootDir": ".",
11+
"sourceMap": true,
12+
"inlineSources": true,
13+
"declaration": false,
14+
"removeComments": true,
15+
"strictNullChecks": true,
16+
"lib": ["es2015", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"],
17+
"skipLibCheck": true,
18+
"moduleResolution": "node",
19+
"paths": {
20+
"@angular/fire": ["../../dist/packages-dist"],
21+
"@angular/fire/auth": ["../../dist/packages-dist/auth"]
22+
}
23+
},
24+
"files": [
25+
"index.ts",
26+
"../../node_modules/zone.js/dist/zone.js.d.ts"
27+
],
28+
"angularCompilerOptions": {
29+
"skipTemplateCodegen": true,
30+
"strictMetadataEmit": true,
31+
"enableSummariesForJit": false
32+
}
33+
}
34+

src/auth-guard/tsconfig-esm.json

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"extends": "./tsconfig-build.json",
3+
"compilerOptions": {
4+
"target": "es5",
5+
"outDir": "../../dist/packages-dist/auth-guard",
6+
"declaration": true
7+
},
8+
"files": [
9+
"public_api.ts",
10+
"../../node_modules/zone.js/dist/zone.js.d.ts"
11+
],
12+
"angularCompilerOptions": {
13+
"skipTemplateCodegen": true,
14+
"strictMetadataEmit": true,
15+
"enableSummariesForJit": false,
16+
"flatModuleOutFile": "index.js",
17+
"flatModuleId": "@angular/fire/auth-guard"
18+
}
19+
}

src/auth-guard/tsconfig-test.json

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"extends": "./tsconfig-esm.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"paths": {
6+
"@angular/fire": ["../../dist/packages-dist"],
7+
"@angular/fire/auth": ["../../dist/packages-dist/auth"],
8+
"@angular/fire/auth-guard": ["../../dist/packages-dist/auth-guard"]
9+
}
10+
},
11+
"files": [
12+
"index.spec.ts",
13+
"../../node_modules/zone.js/dist/zone.js.d.ts"
14+
]
15+
}

src/root.spec.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// These paths are written to use the dist build
22
export * from './packages-dist/angularfire2.spec';
33
export * from './packages-dist/auth/auth.spec';
4+
export * from './packages-dist/auth-guard/auth-guard.spec';
45
export * from './packages-dist/firestore/firestore.spec';
56
export * from './packages-dist/firestore/document/document.spec';
67
export * from './packages-dist/firestore/collection/collection.spec';

src/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"paths": {
1313
"@angular/fire": ["./core"],
1414
"@angular/fire/auth": ["./auth"],
15+
"@angular/fire/auth-guard": ["./auth-guard"],
1516
"@angular/fire/database": ["./database"],
1617
"@angular/fire/firestore": ["./firestore"],
1718
"@angular/fire/functions": ["./functions"],

0 commit comments

Comments
 (0)