Skip to content

Commit

Permalink
[SDK-1778] Add AuthGuard to protect unauthenticated users from access…
Browse files Browse the repository at this point in the history
…ing certain routes (#16)

* Add auth guard and spec

* Add guard to auth module

* Add guard to playground

* Add review suggestions

* Wrap navigateByUrl in a setTimeout

Otherwise, the call to `navigateByUrl` appears to never complete and the
redirect never happens.

Co-authored-by: Steve Hobbs <[email protected]>
Co-authored-by: Steve Hobbs <[email protected]>
  • Loading branch information
3 people authored Jul 30, 2020
1 parent 17790bf commit 0c1c47f
Show file tree
Hide file tree
Showing 17 changed files with 180 additions and 17 deletions.
9 changes: 5 additions & 4 deletions projects/auth0-angular/src/lib/abstract-navigator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Location } from '@angular/common';
import { AbstractNavigator } from './abstract-navigator';
Expand Down Expand Up @@ -70,11 +70,12 @@ describe('RouteNavigator', () => {
navigator = TestBed.inject(AbstractNavigator);
});

it('should use the router if available', async () => {
it('should use the router if available', fakeAsync(() => {
const location = TestBed.inject(Location);
await navigator.navigateByUrl('/test-route');
navigator.navigateByUrl('/test-route');
tick();
expect(location.path()).toBe('/test-route');
});
}));

it('should not use the window object to navigate', async () => {
expect(windowStub.history.replaceState).not.toHaveBeenCalled();
Expand Down
9 changes: 6 additions & 3 deletions projects/auth0-angular/src/lib/abstract-navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ export class AbstractNavigator {
* to `window.history.replaceState`.
* @param url The url to navigate to
*/
navigateByUrl(url: string): Promise<boolean> {
navigateByUrl(url: string): void {
if (this.router) {
return this.router.navigateByUrl(url);
setTimeout(() => {
this.router.navigateByUrl(url);
}, 0);

return;
}

this.window.history.replaceState({}, null, url);
return Promise.resolve(true);
}
}
33 changes: 33 additions & 0 deletions projects/auth0-angular/src/lib/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { of } from 'rxjs';
import { AuthGuard } from './auth.guard';

describe('AuthGuard', () => {
let authServiceMock: any;
let guard: AuthGuard;
const routeMock: any = { snapshot: {} };
const routeStateMock: any = { snapshot: {}, url: '/' };

it('should return true for a logged in user', () => {
authServiceMock = {
isAuthenticated$: of(true),
loginWithRedirect: jasmine.createSpy('loginWithRedirect'),
};
guard = new AuthGuard(authServiceMock);
const listener = jasmine.createSpy();
guard.canActivate(routeMock, routeStateMock).subscribe(listener);
expect(authServiceMock.loginWithRedirect).not.toHaveBeenCalled();
expect(listener).toHaveBeenCalledWith(true);
});

it('should redirect a logged out user', () => {
authServiceMock = {
isAuthenticated$: of(false),
loginWithRedirect: jasmine.createSpy('loginWithRedirect'),
};
guard = new AuthGuard(authServiceMock);
guard.canActivate(routeMock, routeStateMock).subscribe();
expect(authServiceMock.loginWithRedirect).toHaveBeenCalledWith({
appState: { target: '/' },
});
});
});
31 changes: 31 additions & 0 deletions projects/auth0-angular/src/lib/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
CanActivate,
} from '@angular/router';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuthService } from './auth.service';

@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService) {}

canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
return this.auth.isAuthenticated$.pipe(
tap((loggedIn) => {
if (!loggedIn) {
this.auth.loginWithRedirect({ appState: { target: state.url } });
} else {
return of(true);
}
})
);
}
}
3 changes: 2 additions & 1 deletion projects/auth0-angular/src/lib/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { NgModule, ModuleWithProviders, InjectionToken } from '@angular/core';
import { AuthService } from './auth.service';
import { AuthConfig, AuthConfigService } from './auth.config';
import { Auth0ClientService, Auth0ClientFactory } from './auth.client';
import { Auth0Client } from '@auth0/auth0-spa-js';
import { WindowService, windowProvider } from './window';
import { AuthGuard } from './auth.guard';

@NgModule()
export class AuthModule {
Expand All @@ -12,6 +12,7 @@ export class AuthModule {
ngModule: AuthModule,
providers: [
AuthService,
AuthGuard,
{ provide: AuthConfigService, useValue: config },
{
provide: Auth0ClientService,
Expand Down
8 changes: 4 additions & 4 deletions projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LogoutOptions,
GetTokenSilentlyOptions,
GetTokenWithPopupOptions,
RedirectLoginResult,
} from '@auth0/auth0-spa-js';

import {
Expand All @@ -18,7 +19,6 @@ import {
Observable,
iif,
defer,
concat,
} from 'rxjs';

import {
Expand Down Expand Up @@ -220,11 +220,11 @@ export class AuthService implements OnDestroy {
);
}

private handleRedirectCallback(): Observable<boolean> {
private handleRedirectCallback(): Observable<RedirectLoginResult> {
return defer(() => this.auth0Client.handleRedirectCallback()).pipe(
concatMap((result) => {
tap((result) => {
const target = result?.appState?.target ?? '/';
return this.navigator.navigateByUrl(target);
this.navigator.navigateByUrl(target);
})
);
}
Expand Down
21 changes: 17 additions & 4 deletions projects/playground/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProtectedComponent } from './protected/protected.component';
import { AuthGuard } from 'projects/auth0-angular/src/lib/auth.guard';
import { UnprotectedComponent } from './unprotected/unprotected.component';


const routes: Routes = [];
const routes: Routes = [
{
path: 'protected',
component: ProtectedComponent,
canActivate: [AuthGuard],
},
{
path: '',
component: UnprotectedComponent,
pathMatch: 'full',
},
];

@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
exports: [RouterModule],
})
export class AppRoutingModule { }
export class AppRoutingModule {}
5 changes: 5 additions & 0 deletions projects/playground/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ <h2>Artifacts</h2>
</li>
</ul>
</div>

<h2>Test Auth Guard</h2>
<a routerLink="/protected">Protected Route</a> |
<a routerLink="/">Unprotected Route</a>
<router-outlet></router-outlet>
4 changes: 3 additions & 1 deletion projects/playground/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { AuthModule } from 'projects/auth0-angular/src/lib/auth.module';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { ProtectedComponent } from './protected/protected.component';
import { UnprotectedComponent } from './unprotected/unprotected.component';

const AUTH0_CONFIG = {
clientId: 'wLSIP47wM39wKdDmOj6Zb5eSEw3JVhVp',
Expand All @@ -12,7 +14,7 @@ const AUTH0_CONFIG = {
};

@NgModule({
declarations: [AppComponent],
declarations: [AppComponent, ProtectedComponent, UnprotectedComponent],
imports: [BrowserModule, AppRoutingModule, AuthModule.forRoot(AUTH0_CONFIG)],
providers: [],
bootstrap: [AppComponent],
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>This route is protected!</p>
24 changes: 24 additions & 0 deletions projects/playground/src/app/protected/protected.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { ProtectedComponent } from './protected.component';

describe('ProtectedComponent', () => {
let component: ProtectedComponent;
let fixture: ComponentFixture<ProtectedComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProtectedComponent],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ProtectedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
12 changes: 12 additions & 0 deletions projects/playground/src/app/protected/protected.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-protected',
templateUrl: './protected.component.html',
styleUrls: ['./protected.component.css'],
})
export class ProtectedComponent implements OnInit {
constructor() {}

ngOnInit(): void {}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>This route is unprotected!</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { UnprotectedComponent } from './unprotected.component';

describe('UnprotectedComponent', () => {
let component: UnprotectedComponent;
let fixture: ComponentFixture<UnprotectedComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [UnprotectedComponent],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(UnprotectedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
12 changes: 12 additions & 0 deletions projects/playground/src/app/unprotected/unprotected.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-unprotected',
templateUrl: './unprotected.component.html',
styleUrls: ['./unprotected.component.css'],
})
export class UnprotectedComponent implements OnInit {
constructor() {}

ngOnInit(): void {}
}

0 comments on commit 0c1c47f

Please sign in to comment.