Commit 01661a94 authored by François Agneray's avatar François Agneray

Update authentication => angular-auth-oidc-client

parent 33bd557b
Pipeline #3463 passed with stages
in 8 minutes and 37 seconds
......@@ -20,6 +20,7 @@
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/silent-renew.html",
"src/favicon.ico",
"src/assets"
],
......
import * as authActions from './auth.action';
import { UserProfile } from './user-profile.model';
describe('[Auth] Action', () => {
it('should create LoginAction', () => {
......@@ -11,21 +10,4 @@ describe('[Auth] Action', () => {
const action = new authActions.LogoutAction();
expect(action.type).toEqual(authActions.LOGOUT);
});
it('should create AuthSuccessAction', () => {
const action = new authActions.AuthSuccessAction();
expect(action.type).toEqual(authActions.AUTH_SUCCESS);
});
it('should create LoadUserProfileSuccessAction', () => {
const userProfile: UserProfile = { id: 'toto@mail.com', username: 'toto' };
const action = new authActions.LoadUserProfileSuccessAction(userProfile);
expect(action.type).toEqual(authActions.LOAD_USER_PROFILE_SUCCESS);
expect(action.payload).toEqual(userProfile);
});
it('should create OpenEditProfileAction', () => {
const action = new authActions.OpenEditProfileAction();
expect(action.type).toEqual(authActions.OPEN_EDIT_PROFILE);
});
});
......@@ -9,15 +9,26 @@
import { Action } from '@ngrx/store';
import { UserProfile } from './user-profile.model';
export const CHECK_AUTH = '[Auth] check Auth';
export const CHECK_AUTH_COMPLETE = '[Auth] Check Auth Complete';
export const LOGIN = '[Auth] Login';
export const LOGIN_COMPLETE = '[Auth] Login Complete';
export const LOGOUT = '[Auth] Logout';
export const AUTH_SUCCESS = '[Auth] Auth Success';
export const LOAD_USER_PROFILE_SUCCESS = '[Auth] Load User Profile Success';
export const LOGOUT_COMPLETE = '[Auth] Logout Complete';
export const OPEN_EDIT_PROFILE = '[Auth] Edit Profile';
export class CheckAuthAction implements Action {
readonly type = CHECK_AUTH;
constructor(public payload: {} = null) { }
}
export class CheckAuthCompleteAction implements Action {
readonly type = CHECK_AUTH_COMPLETE;
constructor(public payload: boolean) { }
}
/**
* @class
* @classdesc LoginAction action.
......@@ -29,6 +40,13 @@ export class LoginAction implements Action {
constructor(public payload: {} = null) { }
}
export class LoginCompleteAction implements Action {
readonly type = LOGIN_COMPLETE;
constructor(public payload: any) { }
}
/**
* @class
* @classdesc LogoutAction action.
......@@ -40,33 +58,12 @@ export class LogoutAction implements Action {
constructor(public payload: {} = null) { }
}
/**
* @class
* @classdesc AuthSuccessAction action.
* @readonly
*/
export class AuthSuccessAction implements Action {
readonly type = AUTH_SUCCESS;
export class LogoutCompleteAction implements Action {
readonly type = LOGOUT_COMPLETE;
constructor(public payload: {} = null) { }
}
/**
* @class
* @classdesc LoadUserProfileSuccessAction action.
* @readonly
*/
export class LoadUserProfileSuccessAction implements Action {
readonly type = LOAD_USER_PROFILE_SUCCESS;
constructor(public payload: UserProfile) { }
}
/**
* @class
* @classdesc OpenEditProfileAction action.
* @readonly
*/
export class OpenEditProfileAction implements Action {
readonly type = OPEN_EDIT_PROFILE;
......@@ -74,8 +71,10 @@ export class OpenEditProfileAction implements Action {
}
export type Actions
= LoginAction
= CheckAuthAction
| CheckAuthCompleteAction
| LoginAction
| LoginCompleteAction
| LogoutAction
| AuthSuccessAction
| LoadUserProfileSuccessAction
| LogoutCompleteAction
| OpenEditProfileAction;
......@@ -8,14 +8,15 @@
*/
import { Injectable } from '@angular/core';
import { from } from 'rxjs';
import { switchMap, map, tap } from 'rxjs/operators';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { switchMap, map, tap } from 'rxjs/operators';
import { Effect, Actions, ofType } from '@ngrx/effects';
import { KeycloakService } from 'keycloak-angular';
import * as authActions from './auth.action';
import { environment } from '../../../environments/environment';
import { AuthService } from './auth.service';
import { environment } from '../../environments/environment';
@Injectable()
/**
......@@ -25,38 +26,60 @@ import { environment } from '../../../environments/environment';
export class AuthEffects {
constructor(
private actions$: Actions,
private keycloak: KeycloakService
private authService: AuthService,
private router: Router
) { }
/**
* Logs in.
* Execute Login (OIDC Login)
*/
@Effect({ dispatch: false })
loginAction$ = this.actions$.pipe(
ofType(authActions.LOGIN),
tap(_ => this.keycloak.login())
tap(_ => this.authService.doLogin())
);
/**
* Logs out.
*/
@Effect({ dispatch: false })
@Effect()
checkAuthAction$ = this.actions$.pipe(
ofType(authActions.CHECK_AUTH),
switchMap(() =>
this.authService
.checkAuth()
.pipe(
map((isAuthenticated) => new authActions.CheckAuthCompleteAction(isAuthenticated))
)
)
);
@Effect()
checkAuthCompleteAction$ = this.actions$.pipe(
ofType(authActions.CHECK_AUTH_COMPLETE),
switchMap((action: authActions.CheckAuthCompleteAction) => {
const isAuthenticated = action.payload;
if (isAuthenticated) {
return this.authService.userData.pipe(
map((profile) =>
new authActions.LoginCompleteAction(profile)
)
);
} else {
return of(new authActions.LogoutCompleteAction())
}
})
);
@Effect()
logoutAction$ = this.actions$.pipe(
ofType(authActions.LOGOUT),
tap(_ => this.keycloak.logout())
tap(_ => this.authService.signOut()),
map(() => new authActions.LogoutCompleteAction())
);
/**
* Loads user profile when log in success.
*/
@Effect()
authSuccessAction$ = this.actions$.pipe(
ofType(authActions.AUTH_SUCCESS),
switchMap(_ =>
from(this.keycloak.loadUserProfile()).pipe(
map(userProfile => new authActions.LoadUserProfileSuccessAction(userProfile))
)
)
@Effect({ dispatch: false })
logoutCompleteAction$ = this.actions$.pipe(
ofType(authActions.LOGOUT_COMPLETE),
tap(_ => this.router.navigate(['/']))
);
/**
......@@ -65,6 +88,6 @@ export class AuthEffects {
@Effect({ dispatch: false })
OpenEditProfileAction$ = this.actions$.pipe(
ofType(authActions.OPEN_EDIT_PROFILE),
tap(_ => window.open(environment.ssoAuthUrl + '/realms/' + environment.ssoRealm + '/account', '_blank'))
tap(_ => window.open(environment.ssoAuthUrl + '/account', '_blank'))
);
}
......@@ -7,24 +7,39 @@
* file that was distributed with this source code.
*/
import { NgModule } from '@angular/core';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { KeycloakAngularModule } from 'keycloak-angular';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { AuthModule as AuthOIDCModule, OidcConfigService } from 'angular-auth-oidc-client';
import { initializeKeycloakAnis } from './init.keycloak';
import { reducer } from './store/auth.reducer';
import { AuthEffects } from './store/auth.effects';
import { reducer } from './auth.reducer';
import { AuthEffects } from './auth.effects';
import { AuthService } from './auth.service';
import { configureAuth } from './config-auth';
import { TokenInterceptor } from './token-interceptor';
@NgModule({
imports: [
KeycloakAngularModule,
AuthOIDCModule.forRoot(),
StoreModule.forFeature('auth', reducer),
EffectsModule.forFeature([ AuthEffects ])
],
providers: [
initializeKeycloakAnis
OidcConfigService,
{
provide: APP_INITIALIZER,
useFactory: configureAuth,
deps: [OidcConfigService],
multi: true,
},
AuthService,
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
}
]
})
/**
......
import * as fromAuth from './auth.reducer';
import * as authActions from './auth.action';
import { UserProfile } from './user-profile.model';
describe('[Auth] Reducer', () => {
it('should return init state', () => {
......@@ -11,27 +10,6 @@ describe('[Auth] Reducer', () => {
expect(state).toBe(initialState);
});
it('should set isAuthenticated to true', () => {
const { initialState } = fromAuth;
const action = new authActions.AuthSuccessAction();
const state = fromAuth.reducer(initialState, action);
expect(state.isAuthenticated).toBeTruthy();
expect(state.userProfile).toBeNull();
expect(state).not.toEqual(initialState);
});
it('should set userProfile', () => {
const userProfile: UserProfile = { id: 'toto@mail.com', username: 'toto' };
const { initialState } = fromAuth;
const action = new authActions.LoadUserProfileSuccessAction(userProfile);
const state = fromAuth.reducer(initialState, action);
expect(state.isAuthenticated).toBeFalsy();
expect(state.userProfile).toEqual(userProfile);
expect(state).not.toEqual(initialState);
});
it('should get isAuthenticated', () => {
const action = {} as authActions.Actions;
const state = fromAuth.reducer(undefined, action);
......
......@@ -8,7 +8,6 @@
*/
import * as actions from './auth.action';
import { UserProfile } from './user-profile.model';
/**
* Interface for authentication state.
......@@ -17,7 +16,7 @@ import { UserProfile } from './user-profile.model';
*/
export interface State {
isAuthenticated: boolean;
userProfile: UserProfile;
userProfile: any;
}
export const initialState: State = {
......@@ -35,16 +34,20 @@ export const initialState: State = {
*/
export function reducer(state: State = initialState, action: actions.Actions): State {
switch (action.type) {
case actions.AUTH_SUCCESS:
case actions.LOGIN_COMPLETE:
const userProfile = action.payload as any;
return {
...state,
isAuthenticated: true
isAuthenticated: true,
userProfile
};
case actions.LOAD_USER_PROFILE_SUCCESS:
case actions.LOGOUT:
return {
...state,
userProfile: action.payload
isAuthenticated: false,
userProfile: null
}
default:
......
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { OidcSecurityService } from 'angular-auth-oidc-client';
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private oidcSecurityService: OidcSecurityService) { }
get isLoggedIn() {
return this.oidcSecurityService.isAuthenticated$;
}
get token() {
return this.oidcSecurityService.getIdToken();
}
get userData() {
return this.oidcSecurityService.userData$;
}
checkAuth() {
return this.oidcSecurityService.checkAuth();
}
doLogin() {
return of(this.oidcSecurityService.authorize());
}
signOut() {
this.oidcSecurityService.logoffAndRevokeTokens();
}
}
import { OidcConfigService, LogLevel } from 'angular-auth-oidc-client';
import { environment } from '../../environments/environment';
export function configureAuth(oidcConfigService: OidcConfigService) {
return () =>
oidcConfigService.withConfig({
stsServer: environment.ssoAuthUrl,
redirectUrl: window.location.origin + environment.ssoRedirectUrl,
postLogoutRedirectUri: window.location.origin + environment.ssoLogoutRedirectUri,
clientId: environment.ssoClientId,
scope: 'openid profile email offline_access',
responseType: 'code',
silentRenew: true,
silentRenewUrl: `${window.location.origin}/silent-renew.html`,
renewTimeBeforeTokenExpiresInSeconds: 10,
logLevel: LogLevel.Debug,
});
}
/**
* This file is part of Anis Client.
*
* @copyright Laboratoire d'Astrophysique de Marseille / CNRS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { APP_INITIALIZER } from '@angular/core';
import { Store } from '@ngrx/store';
import { from } from 'rxjs';
import { KeycloakService, KeycloakEventType } from 'keycloak-angular';
import * as keycloakActions from './store/auth.action';
import * as fromKeycloak from './store/auth.reducer';
import { environment } from '../../environments/environment';
function initializeKeycloak(keycloak: KeycloakService, store: Store<{ keycloak: fromKeycloak.State }>) {
return async () => {
from(keycloak.keycloakEvents$).subscribe(event => {
if (event.type === KeycloakEventType.OnAuthSuccess) {
store.dispatch(new keycloakActions.AuthSuccessAction());
}
})
return keycloak.init({
config: {
url: environment.ssoAuthUrl,
realm: environment.ssoRealm,
clientId: environment.ssoClientId,
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/assets/silent-check-sso.html'
},
loadUserProfileAtStartUp: true
});
}
}
export const initializeKeycloakAnis = {
provide: APP_INITIALIZER,
useFactory: initializeKeycloak,
multi: true,
deps: [ KeycloakService, Store ],
};
\ No newline at end of file
/**
* This file is part of Anis Client.
*
* @copyright Laboratoire d'Astrophysique de Marseille / CNRS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* Interface for user profile.
*
* @interface UserProfile
*/
export interface UserProfile {
id?: string;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
emailVerified?: boolean;
totp?: boolean;
createdTimestamp?: number;
}
\ No newline at end of file
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
import { environment } from '../../environments/environment';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) { }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
let requestToForward = request;
const token = this.authService.token;
const isApiUrl = request.url.startsWith(environment.apiUrl);
if (token !== '' && isApiUrl) {
requestToForward = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(requestToForward);
}
}
......@@ -10,7 +10,6 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { Instance } from '../../metamodel/model';
import { UserProfile } from '../../auth/store/user-profile.model';
import { environment } from '../../../environments/environment'
@Component({
......@@ -25,7 +24,7 @@ import { environment } from '../../../environments/environment'
*/
export class NavComponent {
@Input() isAuthenticated: boolean;
@Input() userProfile: UserProfile;
@Input() userProfile: any;
@Input() instance: Instance;
@Output() login: EventEmitter<any> = new EventEmitter();
@Output() logout: EventEmitter<any> = new EventEmitter();
......
......@@ -3,9 +3,8 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromAuth from '../../auth/store/auth.reducer';
import * as authActions from '../../auth/store/auth.action';
import { UserProfile } from '../../auth/store/user-profile.model';
import * as fromAuth from '../../auth/auth.reducer';
import * as authActions from '../../auth/auth.action';
import { AppComponent } from './app.component';
import * as fromMetamodel from '../../metamodel/reducers';
import * as instanceActions from '../../metamodel/action/instance.action';
......@@ -14,7 +13,7 @@ describe('[Core] Container: AppComponent', () => {
@Component({ selector: 'app-nav', template: '' })
class NavStubComponent {
@Input() isAuthenticated: boolean;
@Input() userProfile: UserProfile;
@Input() userProfile: any;
}
let component: AppComponent;
......@@ -47,10 +46,12 @@ describe('[Core] Container: AppComponent', () => {
it('should execute ngOnInit lifecycle', () => {
const loadInstanceMetaAction = new instanceActions.LoadInstanceMetaAction();
const checkAuthAction = new authActions.CheckAuthAction();
const spy = spyOn(store, 'dispatch');
component.ngOnInit();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith(loadInstanceMetaAction);
expect(spy).toHaveBeenCalledWith(checkAuthAction);
});
it('#login() should dispatch LoginAction', () => {
......
......@@ -15,10 +15,9 @@ import { Observable } from 'rxjs';
import * as fromMetamodel from '../../metamodel/reducers';
import * as instanceActions from '../../metamodel/action/instance.action';
import * as metamodelSelector from '../../metamodel/selectors';
import * as fromAuth from '../../auth/store/auth.reducer';