Skip to content

Commit 02150f8

Browse files
committed
fix(auth): harden session restoration guards
1 parent b93bbfa commit 02150f8

5 files changed

Lines changed: 269 additions & 67 deletions

File tree

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,144 @@
1+
import {
2+
ActivatedRouteSnapshot,
3+
RouterStateSnapshot
4+
} from '@angular/router';
15
import { UserAuthGuard } from './user-auth-guard.service';
26
import {
37
BehaviorSubject,
4-
EMPTY
8+
EMPTY,
9+
ReplaySubject
510
} from 'rxjs';
11+
import { UserManagementService } from './user-management.service';
12+
13+
14+
type UserStub = { id: string } | null;
15+
16+
const route = {} as ActivatedRouteSnapshot;
617

18+
function state(url: string): RouterStateSnapshot {
19+
return {url} as RouterStateSnapshot;
20+
}
21+
22+
function createSnackRef() {
23+
return {
24+
onAction: jasmine.createSpy('onAction').and.returnValue(EMPTY),
25+
afterDismissed: jasmine.createSpy('afterDismissed').and.returnValue(EMPTY),
26+
dismiss: jasmine.createSpy('dismiss')
27+
};
28+
}
729

8-
function buildGuard(loggedUser: any = null) {
9-
const loggedUser$ = new BehaviorSubject<any>(loggedUser);
10-
const snackRef = {onAction: jasmine.createSpy('onAction').and.returnValue(EMPTY), _open: jasmine.createSpy('_open')};
30+
function buildGuard(loggedUser: UserStub = null) {
31+
const loggedUser$ = new BehaviorSubject<UserStub>(loggedUser);
32+
const authRestored$ = new BehaviorSubject<boolean>(true);
33+
const snackRef = createSnackRef();
1134
const snackBar = jasmine.createSpyObj('MatSnackBar', {open: snackRef});
1235
const router = jasmine.createSpyObj('Router', ['navigate']);
1336
const guard = new UserAuthGuard(
1437
snackBar, router,
15-
{loggedUser$} as any
38+
{loggedUser$, authRestored$} as unknown as UserManagementService
1639
);
17-
return {guard, snackBar, router, snackRef};
40+
return {guard, snackBar, router, snackRef, authRestored$, loggedUser$};
1841
}
1942

2043

2144
describe('UserAuthGuard', () => {
2245
it('returns true when user is logged in', (done) => {
2346
const {guard} = buildGuard({id: 'user-1'});
24-
guard.canActivate({} as any, {url: '/protected'} as any).subscribe(can => {
47+
guard.canActivate(route, state('/protected')).subscribe(can => {
2548
expect(can).toBeTrue();
2649
done();
2750
});
2851
});
2952

3053
it('returns false when user is not logged in', (done) => {
3154
const {guard} = buildGuard(null);
32-
guard.canActivate({} as any, {url: '/protected'} as any).subscribe(can => {
55+
guard.canActivate(route, state('/protected')).subscribe(can => {
3356
expect(can).toBeFalse();
3457
done();
3558
});
3659
});
3760

3861
it('opens snackbar when user is not logged in', (done) => {
3962
const {guard, snackBar} = buildGuard(null);
40-
guard.canActivate({} as any, {url: '/protected'} as any).subscribe(() => {
63+
guard.canActivate(route, state('/protected')).subscribe(() => {
4164
expect(snackBar.open).toHaveBeenCalledWith('Sign in to use this feature.', 'Sign in', jasmine.anything());
4265
done();
4366
});
4467
});
4568

4669
it('navigates to login with returnUrl when user is not logged in', (done) => {
4770
const {guard, router} = buildGuard(null);
48-
guard.canActivate({} as any, {url: '/my-page'} as any).subscribe(() => {
71+
guard.canActivate(route, state('/my-page')).subscribe(() => {
4972
expect(router.navigate).toHaveBeenCalledWith(['/auth/login'], jasmine.objectContaining({queryParams: {returnUrl: '/my-page'}}));
5073
done();
5174
});
5275
});
5376

5477
it('does not navigate or open snackbar when user is logged in', (done) => {
5578
const {guard, snackBar, router} = buildGuard({id: 'user-1'});
56-
guard.canActivate({} as any, {url: '/protected'} as any).subscribe(() => {
79+
guard.canActivate(route, state('/protected')).subscribe(() => {
80+
expect(snackBar.open).not.toHaveBeenCalled();
81+
expect(router.navigate).not.toHaveBeenCalled();
82+
done();
83+
});
84+
});
85+
86+
it('waits for auth restoration before allowing a restored user', (done) => {
87+
const loggedUser$ = new ReplaySubject<UserStub>(1);
88+
const authRestored$ = new BehaviorSubject<boolean>(false);
89+
const snackRef = createSnackRef();
90+
const snackBar = jasmine.createSpyObj('MatSnackBar', {open: snackRef});
91+
const router = jasmine.createSpyObj('Router', ['navigate']);
92+
const guard = new UserAuthGuard(snackBar, router, {loggedUser$, authRestored$} as unknown as UserManagementService);
93+
94+
guard.canActivate(route, state('/protected')).subscribe(can => {
95+
expect(can).toBeTrue();
5796
expect(snackBar.open).not.toHaveBeenCalled();
5897
expect(router.navigate).not.toHaveBeenCalled();
5998
done();
6099
});
100+
101+
loggedUser$.next({id: 'user-1'});
102+
expect(snackBar.open).not.toHaveBeenCalled();
103+
expect(router.navigate).not.toHaveBeenCalled();
104+
105+
authRestored$.next(true);
106+
});
107+
108+
it('waits for auth restoration before rejecting a restored signed-out session', (done) => {
109+
const loggedUser$ = new ReplaySubject<UserStub>(1);
110+
const authRestored$ = new BehaviorSubject<boolean>(false);
111+
const snackRef = createSnackRef();
112+
const snackBar = jasmine.createSpyObj('MatSnackBar', {open: snackRef});
113+
const router = jasmine.createSpyObj('Router', ['navigate']);
114+
const guard = new UserAuthGuard(snackBar, router, {loggedUser$, authRestored$} as unknown as UserManagementService);
115+
116+
guard.canActivate(route, state('/protected')).subscribe(can => {
117+
expect(can).toBeFalse();
118+
expect(snackBar.open).toHaveBeenCalledTimes(1);
119+
expect(router.navigate).toHaveBeenCalledWith(['/auth/login'], jasmine.objectContaining({queryParams: {returnUrl: '/protected'}}));
120+
done();
121+
});
122+
123+
loggedUser$.next(null);
124+
expect(snackBar.open).not.toHaveBeenCalled();
125+
expect(router.navigate).not.toHaveBeenCalled();
126+
127+
authRestored$.next(true);
128+
});
129+
130+
it('reuses an active sign-in snackbar across repeated guard denials', (done) => {
131+
const {guard, snackBar} = buildGuard(null);
132+
let emissions = 0;
133+
134+
guard.canActivate(route, state('/first')).subscribe(() => {
135+
emissions++;
136+
});
137+
guard.canActivate(route, state('/second')).subscribe(() => {
138+
emissions++;
139+
expect(emissions).toBe(2);
140+
expect(snackBar.open).toHaveBeenCalledTimes(1);
141+
done();
142+
});
61143
});
62144
});

src/app/features/backbone/login/user-auth-guard.service.ts

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,86 @@ import {
22
inject,
33
Injectable
44
} from '@angular/core';
5-
import { MatSnackBar } from "@angular/material/snack-bar";
5+
import {
6+
MatSnackBar,
7+
MatSnackBarRef,
8+
TextOnlySnackBar
9+
} from "@angular/material/snack-bar";
610
import {
711
ActivatedRouteSnapshot,
812
CanActivateFn,
913
Router,
1014
RouterStateSnapshot
1115
} from '@angular/router';
1216
import {
17+
filter,
1318
map,
14-
of,
1519
tap
1620
} from 'rxjs';
1721
import { UserManagementService } from './user-management.service';
1822
import {
1923
switchMap,
20-
take,
21-
timeout
24+
take
2225
} from "rxjs/operators";
2326

2427

2528
@Injectable()
2629
export class UserAuthGuard {
30+
private signInSnack?: MatSnackBarRef<TextOnlySnackBar>;
31+
2732
constructor(
2833
private snackBar: MatSnackBar,
2934
private router: Router,
3035
private authenticationService: UserManagementService
3136
) { }
37+
38+
private showSignInPrompt(returnUrl: string): void {
39+
if (!this.signInSnack) {
40+
this.signInSnack = this.snackBar.open('Sign in to use this feature.', 'Sign in', {
41+
duration: 10000,
42+
panelClass: 'snack-info'
43+
});
44+
45+
this.signInSnack.onAction()
46+
.pipe(take(1))
47+
.subscribe(() => this.router.navigate(['/auth/login'], {queryParams: {returnUrl}}));
48+
49+
this.signInSnack.afterDismissed()
50+
.pipe(take(1))
51+
.subscribe(() => {
52+
this.signInSnack = undefined;
53+
});
54+
}
55+
56+
this.router.navigate(['/auth/login'], {queryParams: {returnUrl}});
57+
}
58+
59+
private dismissSignInPrompt(): void {
60+
this.signInSnack?.dismiss();
61+
this.signInSnack = undefined;
62+
}
3263

3364
canActivate(
3465
route: ActivatedRouteSnapshot, state: RouterStateSnapshot
3566
) {
36-
return of(undefined).pipe(
67+
return this.authenticationService.authRestored$.pipe(
68+
filter(Boolean),
69+
take(1),
3770
switchMap(() => this.authenticationService.loggedUser$.pipe(
38-
take(1),
39-
timeout({ first: 8000, with: () => of(undefined) })
71+
take(1)
4072
)),
4173
tap((user) => {
4274
if (!user) {
43-
const snack = this.snackBar.open('Sign in to use this feature.', 'Sign in', {
44-
duration: 10000,
45-
panelClass: 'snack-info'
46-
});
47-
48-
snack.onAction()
49-
.subscribe(x => this.router.navigate(['/auth/login'], {queryParams: {returnUrl: state.url}}));
50-
51-
snack._open();
52-
53-
// route to the login page if the user is not logged in
54-
this.router.navigate(['/auth/login'], {queryParams: {returnUrl: state.url}});
55-
75+
this.showSignInPrompt(state.url);
76+
} else {
77+
this.dismissSignInPrompt();
5678
}
57-
79+
5880
}),
5981
map((user) => !!user)
6082
);
61-
62-
83+
84+
6385
}
6486
}
6587

0 commit comments

Comments
 (0)