Authentication and session

Two ways in

Method Flow

Google SSO (primary)

Google Identity Services renders the sign-in button, returns a credential, and the app exchanges it with the internal API for a Payability JWT.

Username and password (fallback)

An HTTP Basic header against internalApiUrl + auth/login, which returns the same kind of JWT.

Both converge on the same post-login work: load the user record, load their permissions, persist the session, and redirect.

Where it lives

Concern Location

Routes

auth/auth-routing.module.ts — /login, /logout, declared with RouterModule.forChild and therefore rendered outside MainLayoutComponent (no sidebar)

Components

auth/login/, auth/logout/, auth/google-auth-login/ (with a vendored ng-gapi/)

Store

auth/store/auth.actions.ts, auth.reducers.ts, auth.effects.ts, auth-mock.reducers.ts

Interceptor

auth/auth-interceptor.ts

Guards

auth/guards/auth.guard.ts, shared/utils/route-access-resolver.ts

User model

shared/users/user.model.ts, user-perm.model.ts

Google sign-in

GoogleAuthLoginComponent uses Google Identity Services (the newer google.accounts.id API), not the legacy gapi.auth2 flow, despite the @types/gapi dependency:

window.onGoogleLibraryLoad = () => {
  google.accounts.id.initialize({
    client_id: this.clientId,             // env.googleAuthClientId
    callback: this.googleResponse.bind(this),
    auto_select: true,
    cancel_on_tap_outside: true,
    itp_support: true,
    context: 'signin',
    ux_mod: 'popup',
  });
  google.accounts.id.renderButton(document.getElementById('loginBtn'), { … });
  google.accounts.id.prompt();           // One Tap
};

auto_select: true plus prompt() means a returning user can be signed in without clicking anything.

The component emits GoogleSignInSuccess / GoogleSignInFailure; LoginComponent dispatches TryLoginGoogle({ googleAuthUser }).

AuthEffects.authLoginGoogle then:

  1. Reads googleAuthResult.credential. If absent it dispatches LOGIN_FAILED with the message 'Something change in google auth response' — a deliberate guard against Google changing its response shape.

  2. Calls LookupRepository.getAuthGoogleLogin(credential).

  3. Builds { jwtToken: result.token, username: result.decoded.email } and hands off to handleFullLogin.

The Google client id is the same string in the dev and production configuration templates. Only specific localhost ports are registered with Google — 4200, 4300, 4400, 4500, 4600, 5000, 8082. Serving on any other port breaks Google sign-in. See local development.

Username and password

AuthEffects.authLogin constructs the Basic header inline:

const authEndpoint = `${this.appConfig.env.internalApiUrl}auth/login`;
const headers = { Authorization: 'Basic ' + btoa(`${username}:${password}`) };
return this.httpClient.get(authEndpoint, { headers: new HttpHeaders(headers) }) …

This is the one request that carries an Authorization: Basic header; AuthInterceptor leaves it alone because there is no token in state yet.

LoginComponent maps the failure status to a message: 401 → "Invalid login or password", 403 → "Access denied", anything else → "Cannot connect with server", then dispatches ClearAuthState.

Completing the login

AuthEffects.handleFullLogin(currentUser) is shared by both paths:

this.usersService.getUser(currentUser.username, currentUser.jwtToken).pipe(
  mergeMap((user) => {
    const perms = user.perms.map((p) => p.permCode);
    this.permissionsService.loadPermissions(perms);      // ngx-permissions
    return [{ type: LOGIN, payload: { user: { ...user, ...currentUser } } }];
  }),
)

So the stored user object is the API user record merged with the token — { …user, jwtToken, username }. perms is an array of UserPermModel, and the permission codes are what ngx-permissions receives.

AppComponent also calls loadPermissions on every auth state change, which is what restores permissions after a page reload from persisted state. See authorisation.

The auth slice

export interface AuthState {
  isAuthenticated: boolean;
  user?: any;                     // deliberately untyped
  authError?: HttpErrorResponse;
}

user is any because it is the merge of the API record and the token. UserModel is materialised on demand by the selectLoggedUserModel selector:

export const selectLoggedUserModel = createSelector(selectAuth, (state) => {
  if (hasCorrectToken(state) && hasCorrectData(state)) {
    return Deserialize(state.user, UserModel);
  }
});

Two validity predicates are exported and reused across the app:

Predicate Checks

hasCorrectToken(authState)

user is defined, user.jwtToken is defined, is a string, and is truthy.

hasCorrectData(authState)

user is defined and user.loginId is truthy.

Selectors: selectAuthToken, selectLoggedUserModel, selectLoggedUserLoginKey, isLoggedIn.

Persistence

The reducer is where session persistence happens, at module-evaluation time:

export function authReducers(state = getPersistedState() || initialState, action) {
  switch (action.type) {
    case LOGIN:
      setPersistedState(action.payload.user);      // localStorage['currentUser']
      return { ...state, user: action.payload.user, isAuthenticated: true };
    case LOGIN_FAILED:
      return { ...state, authError: action.payload, isAuthenticated: false };
    case CLEAR_AUTH_STATE:
    case LOGOUT:
      return { ...initialState };
    default: return state;
  }
}

getPersistedState() parses localStorage['currentUser'] and, if present, returns { user, isAuthenticated: true }. So the presence of the stored object is what makes the app consider you logged in — isAuthenticated is derived, not stored.

localStorage['currentUser'] is also read directly, outside the store, by EvaComponent when handing the token to EVA.

The JWT sits in localStorage in clear text, and the request ring buffer in the errors slice records recent requests. Both are visible to anything running in the page. This is accepted for an internal tool on a controlled network; do not extend the pattern to more sensitive data.

Carrying the token

AuthInterceptor (AUTH_HEADER_NAME = 'X-Session-Token') selects the auth slice with take(1) on every request and, when hasCorrectToken holds, clones the request with the header. Requests made before login pass through unmodified.

Every Payability backend accepts this header, so the same token goes to a dozen different hosts. CrmService additionally sets the raw token as Authorization, and SseService passes it explicitly because EventSource cannot set headers.

Expiry and 401

There is no token refresh. Expiry is detected in two places:

Detector Behaviour

RouteAccessResolver (on every guarded navigation)

Decodes the JWT with jwt-decode and checks the token claim against Date.now() / 1000. Also rejects a user with no loginId. On rejection it stores the current URL and logs out.

AuthInterceptor (on every response)

A 401 with a token present dispatches Logout({ message: 'Auth token expired, please try to login again.' }) and rethrows.

The expiry claim is named token, not the standard exp:

const encodedToken: any = JWT(jwtToken);
if (!isUndefined(encodedToken.token) && encodedToken.token < Date.now() / 1000) return true;

If the claim is absent the token is treated as not expired. Only a 401 will then end the session.

Because expiry is only checked on navigation, a user sitting on one screen keeps a dead token until their next request fails.

Logout

/logout renders LogoutComponent, whose only job is to dispatch Logout(). AuthEffects.authLogout (a dispatch: false effect):

  1. Calls clearStorages() — which is localStorage.clear(), i.e. it removes everything, not just the session: the persisted supplier query (suppliers_last_query) and the lscache reference-data cache go too.

  2. Navigates to /login, passing payload.message as a message query parameter when present.

  3. Wrapped in try/catch that repeats both steps on failure.

LOGIN_FAILED triggers the same clearStorages() through the loginFailedCleanup effect.

LoginComponent.ngOnInit additionally clears feature-flag session state (clearAvailableFlagsExecStatus() and FeatureFlagsEvaService.reset()), so flag overrides do not survive a logout. EvaComponent clears the EVA auth cookie on window:unload.

?message= on /login is toasted as an info notification for 10 seconds — the channel by which "your session expired" reaches the user.

Redirect after login

RouteAccessResolver.handleLogout()
  backUrl = location.href − origin, with /#/ stripped   →  sessionStorage['loginRedirectUrl']
  (unless backUrl is already '/login')
                                ⋮
AuthEffects.login (on LOGIN)
  redirect = sessionStorage['loginRedirectUrl'] || '/'
  ngZone.run(() => router.navigate([redirect]))

ngZone.run is required because the Google callback resolves outside Angular’s zone.

loginRedirectUrl is never cleared after use. A later logout overwrites it, but if a user logs out from /login itself the previous value survives, so the next login may navigate somewhere unexpected.

Guards

Guard Used on

RouteAccessResolver

Every feature area. Full validity check plus redirect capture. Despite the name it is a CanActivate guard.

AuthGuard

/welcome only. A simpler isLoggedIn check that redirects to /login.

Both are provided in AppModule. See routing for the guard order.

The user model

UserModel (shared/users/user.model.ts) carries loginKey, loginId, firstname, lastname, email, username, phoneNumber, countryCode, authyId, authyRegVerified, and perms. The authy* fields indicate a two-factor enrolment for internal users (distinct from supplier 2FA).

Derived members:

Member Behaviour

id, key

Aliases for loginId and loginKey.

name

"first last", falling back to loginId when both names are empty.

fullname

"first last" with no fallback.

isOwner(reqModel)

reqModel.ownerUuid === this.loginKey.

isSuperUser(reqModel, actionName)

True if the user holds CRM_ADMIN — ignores reqModel entirely and only checks that actionName is truthy.

hasAccessTo(item, action)

isOwner(item) || isSuperUser(item, action).

isSuperUser is misleadingly named and misleadingly parameterised: it is a CRM_ADMIN check, not a general super-user check, and neither argument affects the result beyond a truthiness test. hasAccessTo is used for CRM item ownership; do not reuse it as a general authorisation helper.

Also note UserModel has getters, and — like SupplierDetailsModel — they are lost when the object is spread. See data modelling.