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 |
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 |
|
Components |
|
Store |
|
Interceptor |
|
Guards |
|
User model |
|
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:
-
Reads
googleAuthResult.credential. If absent it dispatchesLOGIN_FAILEDwith the message'Something change in google auth response'— a deliberate guard against Google changing its response shape. -
Calls
LookupRepository.getAuthGoogleLogin(credential). -
Builds
{ jwtToken: result.token, username: result.decoded.email }and hands off tohandleFullLogin.
|
The Google client id is the same string in the dev and production configuration
templates. Only specific |
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 |
|---|---|
|
|
|
|
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 |
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 |
|---|---|
|
Decodes the JWT with |
|
A |
|
The expiry claim is named
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):
-
Calls
clearStorages()— which islocalStorage.clear(), i.e. it removes everything, not just the session: the persisted supplier query (suppliers_last_query) and thelscachereference-data cache go too. -
Navigates to
/login, passingpayload.messageas amessagequery parameter when present. -
Wrapped in
try/catchthat 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.
|
|
Guards
| Guard | Used on |
|---|---|
|
Every feature area. Full validity check plus redirect capture. Despite the name it is
a |
|
|
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 |
|---|---|
|
Aliases for |
|
|
|
|
|
|
|
True if the user holds |
|
|
|
|
Also note UserModel has getters, and — like SupplierDetailsModel — they are lost
when the object is spread. See
data modelling.
Related
-
Authorisation and permissions — what the loaded permissions gate.
-
Caching and persistence — every storage key, including
currentUserandloginRedirectUrl. -
Iframe messaging and EVA — how the token reaches EVA.
-
Observability — the Segment
identifycall made with the logged-in user.