Skip to content

Commit

Permalink
AAE-26163 Logout user after 3 login attempts failed, avoiding infinit…
Browse files Browse the repository at this point in the history
…e loop when an authentication error occured, like when a user machine clock is significantly out of sync
  • Loading branch information
alep85 committed Oct 4, 2024
1 parent 0e085bc commit 3ed1b30
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 51 deletions.
21 changes: 0 additions & 21 deletions lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ describe('RedirectAuthService', () => {

TestBed.inject(OAuthService);
service = TestBed.inject(RedirectAuthService);
spyOn(service, 'reloadPage').and.callFake(() => {});
spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true);
mockOauthService.getAccessToken = () => 'access-token';
});
Expand Down Expand Up @@ -96,24 +95,4 @@ describe('RedirectAuthService', () => {
expect(silentRefreshCalled).toBe(true);
});

it('should remove all auth items from the storage if access token is set and is not authenticated', () => {
mockOauthService.getAccessToken = () => 'access-token';
spyOnProperty(service, 'authenticated', 'get').and.returnValue(false);
(mockOauthService.events as Subject<OAuthEvent>).next({ type: 'discovery_document_loaded' } as OAuthEvent);

expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token_stored_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('expires_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('granted_scopes');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_claims_obj');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_expires_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_stored_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('nonce');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('PKCE_verifier');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('refresh_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
expect(service.reloadPage).toHaveBeenCalledOnceWith();
});

});
40 changes: 10 additions & 30 deletions lib/core/src/lib/auth/oidc/redirect-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,18 @@ import { Inject, Injectable, inject } from '@angular/core';
import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { from, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, take } from 'rxjs/operators';
import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
import { RetryLoginService } from './retry-login.service';

const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';

@Injectable()
export class RedirectAuthService extends AuthService {

readonly authModuleConfig: AuthModuleConfig = inject(AUTH_MODULE_CONFIG);
private readonly _retryLoginService: RetryLoginService = inject(RetryLoginService);

onLogin: Observable<any>;

Expand All @@ -53,21 +55,6 @@ export class RedirectAuthService extends AuthService {

private authConfig!: AuthConfig | Promise<AuthConfig>;

private readonly AUTH_STORAGE_ITEMS: string[] = [
'access_token',
'access_token_stored_at',
'expires_at',
'granted_scopes',
'id_token',
'id_token_claims_obj',
'id_token_expires_at',
'id_token_stored_at',
'nonce',
'PKCE_verifier',
'refresh_token',
'session_state'
];

constructor(
private oauthService: OAuthService,
private _oauthStorage: OAuthStorage,
Expand All @@ -84,13 +71,6 @@ export class RedirectAuthService extends AuthService {
shareReplay(1)
);

this.oauthService.events.pipe(take(1)).subscribe(() => {
if(this.oauthService.getAccessToken() && !this.authenticated){
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
this.reloadPage();
}
});

this.onLogin = this.authenticated$.pipe(
filter((authenticated) => authenticated),
map(() => undefined)
Expand Down Expand Up @@ -160,9 +140,13 @@ export class RedirectAuthService extends AuthService {
}

async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
return this.ensureDiscoveryDocument()
.then(() => this.oauthService.tryLogin({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
.then(() => this._getRedirectUrl());
return this.ensureDiscoveryDocument()
.then(() => this._retryLoginService.tryToLoginTimes({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
.then(() => this._getRedirectUrl())
.catch(async (error) => {
console.error(error);
this.oauthService.revokeTokenAndLogout();
});
}

private _getRedirectUrl() {
Expand Down Expand Up @@ -246,8 +230,4 @@ export class RedirectAuthService extends AuthService {
this.oauthService.configure(config);
}

reloadPage() {
window.location.reload();
}

}
85 changes: 85 additions & 0 deletions lib/core/src/lib/auth/oidc/retry-login.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { TestBed } from '@angular/core/testing';
import { OAuthService } from 'angular-oauth2-oidc';
import { RetryLoginService } from './retry-login.service';

describe('RetryLoginService', () => {
let service: RetryLoginService;
let oauthService: jasmine.SpyObj<OAuthService>;

beforeEach(() => {
const oauthServiceSpy = jasmine.createSpyObj('OAuthService', ['tryLogin']);

TestBed.configureTestingModule({
providers: [
RetryLoginService,
{ provide: OAuthService, useValue: oauthServiceSpy }
]
});

service = TestBed.inject(RetryLoginService);
oauthService = TestBed.inject(OAuthService) as jasmine.SpyObj<OAuthService>;
});

it('should login successfully on the first attempt', async () => {
oauthService.tryLogin.and.returnValue(Promise.resolve(true));

const result = await service.tryToLoginTimes({});

expect(result).toBeTrue();
expect(oauthService.tryLogin).toHaveBeenCalledTimes(1);
});

it('should retry login up to maxRetries times', async () => {
oauthService.tryLogin.and.returnValues(
Promise.reject(new Error('error')),
Promise.reject(new Error('error')),
Promise.resolve(true)
);

const result = await service.tryToLoginTimes({}, 2);

expect(result).toBeTrue();
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
});

it('should fail after maxRetries attempts providing maxRetries', async () => {
oauthService.tryLogin.and.rejectWith('error');

try {
await service.tryToLoginTimes({}, 2);
fail('Expected to throw an error');
} catch (error) {
expect(error).toBe('error');
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
}
});

it('should fail after maxRetries attempts using default maxRetries value', async () => {
oauthService.tryLogin.and.rejectWith('error');

try {
await service.tryToLoginTimes({});
fail('Expected to throw an error');
} catch (error) {
expect(error).toBe('error');
expect(oauthService.tryLogin).toHaveBeenCalledTimes(3);
}
});
});
53 changes: 53 additions & 0 deletions lib/core/src/lib/auth/oidc/retry-login.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { inject, Injectable } from '@angular/core';
import { LoginOptions, OAuthService } from 'angular-oauth2-oidc';

@Injectable({
providedIn: 'root'
})
export class RetryLoginService {

private oauthService = inject(OAuthService);

/**
* Attempts to log in a specified number of times if the initial login attempt fails.
*
* @param loginOptions - The options to be used for the login attempt.
* @param maxLoginAttempts - The maximum number of login attempts. Defaults to 3.
* @returns A promise that resolves to `true` if the login is successful, or rejects with an error if all attempts fail.
*/
tryToLoginTimes(loginOptions: LoginOptions, maxLoginAttempts = 3): Promise<boolean> {
let retryCount = 0;
const maxRetries = maxLoginAttempts - 1;

const attemptLogin = (): Promise<boolean> => this.oauthService.tryLogin({ ...loginOptions })
.catch((error) => {
if (retryCount < maxRetries) {
console.error(`Login attempt ${retryCount + 1} of ${maxLoginAttempts} failed. ${retryCount < maxLoginAttempts - 1 ? 'Retrying...' : ''}`);
retryCount++;
return attemptLogin();
} else {
throw new Error(`Login failed after ${maxLoginAttempts} attempts. ${error.message}`);
}
});

return attemptLogin();
}

}

0 comments on commit 3ed1b30

Please sign in to comment.