Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ describe('AuthService', () => {
})
})

describe('verifyStoredSession', () => {
it('should call a protected endpoint so the server can reject a dead token', () => {
service.verifyStoredSession().subscribe()

const req = httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/devices/stats`)
expect(req.request.method).toBe('GET')
req.flush({})
})
})

describe('getMPSVersion', () => {
it('should fetch the MPS version', () => {
const mockResponse: MPSVersion = { serviceVersion: '1.0.0' }
Expand Down
5 changes: 5 additions & 0 deletions src/app/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ export class AuthService {
this.router.navigate(['/login'])
}

/** Vets a session restored from storage; a 401 logs the user out. */
verifyStoredSession(): Observable<any> {
return this.http.get(`${environment.mpsServer}/api/v1/devices/stats`)
}

getMPSVersion(): Observable<any> {
return this.http.get<MPSVersion>(`${environment.mpsServer}/api/v1/version`).pipe(
catchError((err) => {
Expand Down
62 changes: 61 additions & 1 deletion src/app/error-handling.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ describe('ErrorHandlingInterceptor', () => {

beforeEach(() => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['logout'])
const dialogSpy = jasmine.createSpyObj('MatDialog', ['open'])
const dialogSpy = jasmine.createSpyObj('MatDialog', [
'open',
'getDialogById'
])
const snackbarSpy = jasmine.createSpyObj('MatSnackBar', ['open'])

TestBed.configureTestingModule({
Expand Down Expand Up @@ -47,6 +50,7 @@ describe('ErrorHandlingInterceptor', () => {
error: () => {
expect(authService.logout).toHaveBeenCalled()
expect(dialog.open).toHaveBeenCalledWith(DialogContentComponent, {
id: 'session-timed-out',
data: { name: 'error.sessionTimedOut.value' }
})
}
Expand All @@ -56,6 +60,62 @@ describe('ErrorHandlingInterceptor', () => {
req.flush({ exp: 'token expired' }, { status: 401, statusText: 'Unauthorized' })
})

it('should report a session timeout for a 401 that does not describe the token', () => {
// Console's wording for an expired token
httpClient.get('/test').subscribe({
error: () => {
expect(authService.logout).toHaveBeenCalled()
expect(dialog.open).toHaveBeenCalledWith(DialogContentComponent, {
id: 'session-timed-out',
data: { name: 'error.sessionTimedOut.value' }
})
}
})

const req = httpMock.expectOne('/test')
req.flush({ error: 'invalid access token' }, { status: 401, statusText: 'Unauthorized' })
})

it('should report a session timeout for a 401 with an empty body', () => {
httpClient.get('/test').subscribe({
error: () => {
expect(authService.logout).toHaveBeenCalled()
expect(dialog.open).toHaveBeenCalledTimes(1)
}
})

const req = httpMock.expectOne('/test')
req.flush(null, { status: 401, statusText: 'Unauthorized' })
})

it('should leave a failed login to the login page', () => {
httpClient.post('http://localhost:3000/api/v1/authorize', {}).subscribe({
error: (error) => {
expect(error.status).toBe(401)
expect(dialog.open).not.toHaveBeenCalled()
expect(authService.logout).not.toHaveBeenCalled()
}
})

const req = httpMock.expectOne('http://localhost:3000/api/v1/authorize')
req.flush({ message: 'Incorrect Username and/or Password!' }, { status: 401, statusText: 'Unauthorized' })
})

it('should open a single session timeout dialog for concurrent 401s', () => {
dialog.open.and.callFake(() => {
dialog.getDialogById.and.returnValue({} as any)
return {} as any
})

httpClient.get('/one').subscribe({ error: () => undefined })
httpClient.get('/two').subscribe({ error: () => undefined })

httpMock.expectOne('/one').flush(null, { status: 401, statusText: 'Unauthorized' })
httpMock.expectOne('/two').flush(null, { status: 401, statusText: 'Unauthorized' })

expect(dialog.open).toHaveBeenCalledTimes(1)
})

it('should handle 412 error and show dialog', () => {
httpClient.get('/test').subscribe({
error: () => {
Expand Down
17 changes: 14 additions & 3 deletions src/app/error-handling.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { AuthService } from './auth.service'
import { inject } from '@angular/core'
import { TranslateService } from '@ngx-translate/core'

/** Keeps concurrent 401s from stacking a dialog each. */
const SESSION_TIMEOUT_DIALOG_ID = 'session-timed-out'

/** Login answers 401 on bad credentials; LoginComponent reports that itself. */
const isLoginRequest = (url: string): boolean => url.includes('/authorize') && !url.includes('/authorize/redirection')

export const errorHandlingInterceptor: HttpInterceptorFn = (request, next) => {
const authService = inject(AuthService)
const dialog = inject(MatDialog)
Expand All @@ -16,9 +22,14 @@ export const errorHandlingInterceptor: HttpInterceptorFn = (request, next) => {
return next(request).pipe(
catchError((error: any) => {
if (error instanceof HttpErrorResponse) {
if (error.status === 401) {
if (error.error.exp === 'token expired') {
dialog.open(DialogContentComponent, { data: { name: translate.instant('error.sessionTimedOut.value') } })
if (error.status === 401 && !isLoginRequest(request.url)) {
// Backends word a dead token differently and the body is sometimes empty,
// so the status drives the message.
if (dialog.getDialogById(SESSION_TIMEOUT_DIALOG_ID) == null) {
dialog.open(DialogContentComponent, {
id: SESSION_TIMEOUT_DIALOG_ID,
data: { name: translate.instant('error.sessionTimedOut.value') }
})
}
authService.logout()
} else if (error.status === 412 || error.status === 409) {
Expand Down
16 changes: 15 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient, withInterceptors, withInterceptorsFromDi } from '@angular/common/http'
import { OAuthService, provideOAuthClient } from 'angular-oauth2-oidc'
import { AuthGuard } from './app/shared/auth-guard.service'
import { AuthService } from './app/auth.service'
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'
import { errorHandlingInterceptor } from './app/error-handling.interceptor'
import { authorizationInterceptor } from './app/authorize.interceptor'
Expand Down Expand Up @@ -67,7 +68,20 @@ if (environment.useOAuth) {
})
)
} else {
providers.push(provideHttpClient(withInterceptors([authorizationInterceptor, errorHandlingInterceptor])))
providers.push(
provideHttpClient(withInterceptors([authorizationInterceptor, errorHandlingInterceptor])),
provideAppInitializer(() => {
const authService = inject(AuthService)

// A restored session is unchecked; let the server reject a dead token before any route renders.
if (!authService.isLoggedIn) {
return Promise.resolve()
}

// The interceptor handles a 401; other failures must not block startup.
return firstValueFrom(authService.verifyStoredSession()).catch(() => undefined)
})
)
}
bootstrapApplication(AppComponent, {
providers
Expand Down
Loading