Commit b5677a18 authored by François Agneray's avatar François Agneray
Browse files

Use of the official keycloak lib

parent 07a2edf5
Pipeline #3725 passed with stages
in 7 minutes and 22 seconds
......@@ -20,7 +20,6 @@
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/silent-renew.html",
"src/favicon.ico",
"src/assets"
],
......
import * as authActions from './auth.action';
describe('[Auth] Action', () => {
it('should create LoginAction', () => {
const action = new authActions.LoginAction();
expect(action.type).toEqual(authActions.LOGIN);
});
it('should create LogoutAction', () => {
const action = new authActions.LogoutAction();
expect(action.type).toEqual(authActions.LOGOUT);
});
});
/**
* 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 { Action } from '@ngrx/store';
export const CHECK_AUTH = '[Auth] check Auth';
export const CHECK_AUTH_COMPLETE = '[Auth] Check Auth Complete';
import { UserProfile } from './user-profile.model';
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 LOAD_USER_ROLES_SUCCESS = '[Auth] Load User Roles Success';
export const OPEN_EDIT_PROFILE = '[Auth] Edit Profile';
export const GET_USER_ROLES = '[Auth] Get User Roles';
export const PARSE_JWT = '[Auth] Parse JWT';
export const LOAD_USER_ROLES = '[Auth] Load User Roles';
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.
* @readonly
*/
export class LoginAction implements Action {
readonly type = LOGIN;
constructor(public payload: string = null) { }
}
export class LoginCompleteAction implements Action {
readonly type = LOGIN_COMPLETE;
constructor(public payload: any) { }
constructor(public payload: {} = null) { }
}
/**
* @class
* @classdesc LogoutAction action.
* @readonly
*/
export class LogoutAction implements Action {
readonly type = LOGOUT;
constructor(public payload: {} = null) { }
}
export class OpenEditProfileAction implements Action {
readonly type = OPEN_EDIT_PROFILE;
export class AuthSuccessAction implements Action {
readonly type = AUTH_SUCCESS;
constructor(public payload: {} = null) { }
}
export class ParseJwtAction implements Action {
readonly type = PARSE_JWT;
export class LoadUserProfileSuccessAction implements Action {
readonly type = LOAD_USER_PROFILE_SUCCESS;
constructor(public payload: string) { }
constructor(public payload: UserProfile) { }
}
export class LoadUserRolesAction implements Action {
readonly type = LOAD_USER_ROLES;
export class LoadUserRolesSuccessAction implements Action {
readonly type = LOAD_USER_ROLES_SUCCESS;
constructor(public payload: string[]) { }
}
export class OpenEditProfileAction implements Action {
readonly type = OPEN_EDIT_PROFILE;
constructor(public payload: {} = null) { }
}
export type Actions
= CheckAuthAction
| CheckAuthCompleteAction
| LoginAction
| LoginCompleteAction
= LoginAction
| LogoutAction
| OpenEditProfileAction
| ParseJwtAction
| LoadUserRolesAction;
| AuthSuccessAction
| LoadUserProfileSuccessAction
| LoadUserRolesSuccessAction
| OpenEditProfileAction;
/**
* 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 { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { from } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { of } from 'rxjs';
import { switchMap, map, tap } from 'rxjs/operators';
import { Effect, Actions, ofType } from '@ngrx/effects';
import jwt_decode from 'jwt-decode';
import { KeycloakService } from 'keycloak-angular';
import * as authActions from './auth.action';
import { AuthService } from './auth.service';
import { environment } from '../../environments/environment';
@Injectable()
/**
* @class
* @classdesc Authentication effects.
*/
export class AuthEffects {
constructor(
private actions$: Actions,
private authService: AuthService,
private router: Router
private keycloak: KeycloakService
) { }
/**
* Execute Login (OIDC Login)
*/
@Effect({ dispatch: false })
loginAction$ = this.actions$.pipe(
ofType(authActions.LOGIN),
tap((action: authActions.LoginAction) => {
const redirectUrl = action.payload;
if (redirectUrl !== null) {
sessionStorage.setItem(environment.ssoClientId + '_redirectUrl', redirectUrl);
}
return this.authService.doLogin()
})
);
@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(
switchMap((profile) => [
new authActions.LoginCompleteAction(profile),
new authActions.ParseJwtAction(this.authService.token)
])
);
} else {
return of({ type: '[No Action] ' + authActions.CHECK_AUTH_COMPLETE });
}
})
tap(_ => this.keycloak.login({
redirectUri: window.location.origin + environment.ssoLoginRedirectUri
}))
);
@Effect({ dispatch: false })
loginCompleteAction$ = this.actions$.pipe(
ofType(authActions.LOGIN_COMPLETE),
tap(_ => {
const redirectUrl = sessionStorage.getItem(environment.ssoClientId + '_redirectUrl');
if (redirectUrl !== null) {
sessionStorage.removeItem(environment.ssoClientId + '_redirectUrl');
this.router.navigateByUrl(redirectUrl);
}
})
logoutAction$ = this.actions$.pipe(
ofType(authActions.LOGOUT),
tap(_ => this.keycloak.logout(window.location.origin + environment.ssoLogoutRedirectUri))
);
@Effect()
parseJWTAction$ = this.actions$.pipe(
ofType(authActions.PARSE_JWT),
switchMap((action: authActions.ParseJwtAction) => {
const jwt = jwt_decode(action.payload) as any;
if (environment.ssoName === 'auth0') {
return of({ type: '[No Action] ' + authActions.PARSE_JWT });
} else {
return of(new authActions.LoadUserRolesAction(jwt.realm_access.roles));
}
})
);
@Effect({ dispatch: false })
logoutAction$ = this.actions$.pipe(
ofType(authActions.LOGOUT),
tap(_ => this.authService.signOut())
authSuccessAction$ = this.actions$.pipe(
ofType(authActions.AUTH_SUCCESS),
switchMap(_ =>
from(this.keycloak.loadUserProfile()).pipe(
switchMap(userProfile => [
new authActions.LoadUserProfileSuccessAction(userProfile),
new authActions.LoadUserRolesSuccessAction(this.keycloak.getUserRoles())
])
)
)
);
/**
* Opens edit profile page.
*/
@Effect({ dispatch: false })
OpenEditProfileAction$ = this.actions$.pipe(
ofType(authActions.OPEN_EDIT_PROFILE),
tap(_ => window.open(environment.ssoAuthUrl + '/account', '_blank'))
tap(_ => window.open(environment.ssoAuthUrl + '/realms/' + environment.ssoRealm + '/account', '_blank'))
);
}
/**
* 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 { NgModule, APP_INITIALIZER } from '@angular/core';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgModule } from '@angular/core';
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 './auth.reducer';
import { AuthEffects } from './auth.effects';
import { AuthService } from './auth.service';
import { configureAuth } from './config-auth';
import { TokenInterceptor } from './token-interceptor';
@NgModule({
imports: [
AuthOIDCModule.forRoot(),
KeycloakAngularModule,
StoreModule.forFeature('auth', reducer),
EffectsModule.forFeature([ AuthEffects ])
],
providers: [
OidcConfigService,
{
provide: APP_INITIALIZER,
useFactory: configureAuth,
deps: [OidcConfigService],
multi: true,
},
AuthService,
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
}
initializeKeycloakAnis
]
})
/**
* @class
* @classdesc Authentication module.
*/
export class AuthModule { }
import * as fromAuth from './auth.reducer';
import * as authActions from './auth.action';
describe('[Auth] Reducer', () => {
it('should return init state', () => {
const { initialState } = fromAuth;
const action = {} as authActions.Actions;
const state = fromAuth.reducer(undefined, action);
expect(state).toBe(initialState);
});
it('should get isAuthenticated', () => {
const action = {} as authActions.Actions;
const state = fromAuth.reducer(undefined, action);
expect(fromAuth.isAuthenticated(state)).toBeFalsy();
});
it('should get userProfile', () => {
const action = {} as authActions.Actions;
const state = fromAuth.reducer(undefined, action);
expect(fromAuth.getUserProfile(state)).toBeNull();
});
});
/**
* 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 * as actions from './auth.action';
/**
* Interface for authentication state.
*
* @interface State
*/
import { UserProfile } from './user-profile.model';
export interface State {
isAuthenticated: boolean;
userProfile: any;
userProfile: UserProfile;
userRoles: string[];
}
......@@ -26,39 +14,29 @@ export const initialState: State = {
userRoles: []
};
/**
* Reduces state.
*
* @param {State} state - The state.
* @param {actions} action - The action.
*
* @return State
*/
export function reducer(state: State = initialState, action: actions.Actions): State {
switch (action.type) {
case actions.LOGIN_COMPLETE:
const userProfile = action.payload as any;
case actions.AUTH_SUCCESS:
return {
...state,
isAuthenticated: true
};
case actions.LOAD_USER_PROFILE_SUCCESS:
const userProfile = action.payload;
return {
...state,
isAuthenticated: true,
userProfile
};
case actions.LOAD_USER_ROLES:
case actions.LOAD_USER_ROLES_SUCCESS:
const userRoles = action.payload;
return {
...state,
userRoles
};
case actions.LOGOUT:
return {
...state,
isAuthenticated: false,
userProfile: null
}
};
default:
return state;
......
import * as authSelector from './auth.selector';
import * as fromAuth from './auth.reducer';
describe('[Auth] Selector', () => {
it('should get isAuthenticated', () => {
const state = { auth: { ...fromAuth.initialState }};
expect(authSelector.isAuthenticated(state)).toBeFalsy();
});
it('should get userProfile', () => {
const state = { auth: { ...fromAuth.initialState }};
expect(authSelector.getUserProfile(state)).toBeNull();
});
});
\ 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.
*/
import { createSelector, createFeatureSelector } from '@ngrx/store';
import * as auth from './auth.reducer';
......
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { environment } from 'src/environments/environment';
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private oidcSecurityService: OidcSecurityService) { }
get isLoggedIn() {
return this.oidcSecurityService.isAuthenticated$;
}
get token() {
if (environment.ssoName === 'auth0') {
return this.oidcSecurityService.getIdToken();
} else {
return this.oidcSecurityService.getToken();
}
}
get userData() {
return this.oidcSecurityService.userData$;
}
checkAuth() {
return this.oidcSecurityService.checkAuthIncludingServer();
}
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,
postLogoutRedirectUri: window.location.origin,
clientId: environment.ssoClientId,
scope: 'openid profile email offline_access',
responseType: 'code',
silentRenew: true,
silentRenewUrl: `${window.location.origin}/silent-renew.html`,
renewTimeBeforeTokenExpiresInSeconds: 10,
logLevel: LogLevel.None,
});
}
import { APP_INITIALIZER } from '@angular/core';
import { from } from 'rxjs';
import { KeycloakService, KeycloakEventType } from 'keycloak-angular';
import { Store } from '@ngrx/store';
import * as keycloakActions from './auth.action';
import * as fromKeycloak from './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 ],
};
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);