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
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

jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved
`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();
});
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.

});
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));
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