The Dashboard authenticates to the Gateway with the platform’s JWT Bearer model: a {access_token, refresh_token} pair minted by POST /api/v1/login, stored in localStorage, and attached as Authorization: Bearer <jwt> on every HTTP call. Three small classes own this end-to-end, all under src/app/auth/:

AuthGuard

auth.guard.ts — a CanActivate guard that blocks /home and /billing until AuthService.isAuthenticated() resolves true.

AuthInterceptor

auth.interceptor.ts — injects the Bearer header, picks the right token per URL, and transparently refreshes-and-retries on 401.

AuthService

auth.service.ts — the auth API client and token store, plus a background 8-minute refresh timer.
Login page
This page covers the browser-side JWT machinery only. For the Gateway’s view of the same tokens — HS256 signing, the 10-minute access / 12-hour refresh TTLs, the login/refresh/logout routes, and the in-memory blocklist — see Authentication & JWT Lifecycle and the platform-wide Authentication & Security Model. For how the same token is passed to the other transports (?token= on Socket.IO, ?access_token= on redispad), see Real-time Telemetry Client and Vehicle Commands & Gamepad.

How a protected navigation authenticates

Both eager (/login, /register, /reset-password) and lazy protected (/home, /billing) routes are declared in the app shell; the guard is attached only to the protected pair. See Routing & Lazy Loading for the route tree.

AuthGuard

auth.guard.ts is a providedIn: 'root' guard whose canActivate is async — it awaits a live GET /auth round-trip on every protected navigation, not just a local token check.
src/app/auth/auth.guard.ts
async canActivate(_route, _state): Promise<boolean> {
  try {
    const isAuthenticated = await this.authService.isAuthenticated();
    if (isAuthenticated) return true;
    this.router.navigate(['/login']);   // redirect on failure
    return false;
  } catch (error) {
    console.error('Error checking authentication:', error);
    return false;                       // block, but do NOT navigate
  }
}
Double-redirect to /login. AuthService.isAuthenticated() also calls this.router.navigate(['/login']) internally on every failure path (auth.service.ts:74,82,89), and then the guard navigates again (auth.guard.ts:20). An unauthenticated hit on /home can fire two /login navigations. This is harmless today but a refactor that adds route-state preservation (e.g. returnUrl) must collapse the two redirects into one.
Note the catch branch (auth.guard.ts:23-26) returns false without navigating. isAuthenticated() swallows its own errors internally, so this branch is effectively unreachable — but if you ever make isAuthenticated() throw, the user is silently stranded on a blank outlet (there is no ** wildcard/404 route).

AuthInterceptor

Registered as a multi HTTP_INTERCEPTORS provider in AppModule, auth.interceptor.ts runs on every outbound HttpClient request. It does two jobs: attach the correct token on the way out, and refresh-and-retry on a 401 coming back.

Per-URL token selection (outbound)

src/app/auth/auth.interceptor.ts
if (request.url.includes('/auth/refresh')) {
  const refreshToken = this.authService.getRefreshToken();
  if (refreshToken == null) throw new Error('Refresh token not found');
  modifiedRequest = this.addToken(request, refreshToken);
} else if (request.url.includes('/update-password') || request.url.includes('/verify-email')) {
  modifiedRequest = request;                       // passed through untouched
} else {
  modifiedRequest = this.addToken(request, this.authService.getAccessToken() ?? '1234');
}
Request URL containsBearer token attachedWhy
/auth/refreshthe refresh tokenExchanging the refresh token for a new pair; throws if absent
/update-password(none — request untouched)AuthService.updatePassword() sets its own explicit Bearer <tempToken> header (auth.service.ts:154); the interceptor must not clobber it
/verify-email(none — request untouched)Unauthenticated flow
anything elsethe access token, or literal '1234'Normal authenticated calls
The '1234' fallback is a real header, not a no-op. When no access token exists, the interceptor sends Authorization: Bearer 1234 rather than omitting the header. Unauthenticated endpoints that the interceptor does not skip — /login, /register/{token}, /reset-password — all receive Bearer 1234. The Gateway ignores it for these routes (they don’t require JWT, and /register / /update-password carry their real credential in the URL path or an explicit header), but any new public endpoint must tolerate this placeholder. Do not “clean this up” by removing the fallback without checking the backend.

401 refresh-and-retry (inbound)

On a 401, the interceptor refreshes the token and replays the original request — unless the failing URL contains /auth, /login, or /logout (those failures propagate as-is, preventing an infinite refresh loop on the refresh call itself).
src/app/auth/auth.interceptor.ts
const ignoreRefreshUrls = ['/auth', '/login', '/logout'];
if (error?.status === 401 && !ignoreRefreshUrls.some((str) => request.url.includes(str))) {
  return this.handle401Error(request, next);
}
return throwError(() => error);
handle401Error first guards against a missing/poisoned refresh token, then calls refreshToken(), stores the new pair, re-attaches the fresh access token, and retries. A refresh failure logs out and routes to /login.

The snake_case / camelCase refresh-token pitfall

This is the single most important gotcha on this page. The Gateway returns refresh responses in snake_case ({access_token, refresh_token}), and login stores them correctly (login.component.ts:55-56). But the interceptor’s refresh handler reads the wrong key:
src/app/auth/auth.interceptor.ts handle401Error, 53-56
const newAccessToken = tokens.access_token;    // ✅ snake_case — correct
const newRefreshToken = tokens.refreshToken;   // ❌ camelCase — always undefined
this.authService.storeAccessToken(newAccessToken);
this.authService.storeRefreshToken(newRefreshToken);   // stores the string "undefined"
Because tokens.refreshToken is undefined, storeRefreshToken(undefined) writes the literal string "undefined" into localStorage['refreshToken']. The access token is fine, so the retried request succeeds — but the refresh token is now poisoned. The background timer in AuthService reads the correct key, so the two paths disagree:
src/app/auth/auth.service.ts setInterval body, 36-37
const newAccessToken = tokens.access_token;    // ✅
const newRefreshToken = tokens.refresh_token;  // ✅ snake_case — correct
Downstream effect: every consumer that reads the refresh token guards against the string "undefined", so the poisoned value is treated as “no refresh token.” The next interceptor-driven 401 short-circuits to /login (auth.interceptor.ts:45), the 8-minute timer skips its refresh (auth.service.ts:30), and isAuthenticated() treats a "undefined" access token as missing (auth.service.ts:73). In practice: a single interceptor-driven refresh silently ends the session at the next 401, while the timer path keeps working.
This is a latent bug preserved intentionally in the docs, not endorsed. The one-character fix is tokens.refresh_token in handle401Error. If you touch auth.interceptor.ts, fix it deliberately and test the “access token expires between timer ticks” case — do not leave it half-corrected, and do not “fix” the token store to accept undefined (that would let "undefined" masquerade as a valid token).

AuthService — token lifecycle

auth.service.ts is the providedIn: 'root' client for every auth route and the sole owner of token storage.

localStorage token contract

KeyWritten byRead byNotes
accessTokenlogin.component.ts:55, storeAccessToken()interceptor, isAuthenticated(), Socket.IO & redispad query paramsThe string "undefined" is treated as missing everywhere
refreshTokenlogin.component.ts:56, storeRefreshToken()interceptor & timer refreshSubject of the pitfall above
emaillogin.component.ts:54 (via StorageService)UI
logout() calls localStorage.clear() (not a targeted removal), so selectedDroneId, showMenu, skyhub_subscription and every other key are wiped on sign-out.

The auth API surface

All calls prefix environment.url (default http://localhost:5000/api/v1; see Environments, Build & CI).
MethodEndpointAuth token usedPurpose
isAuthenticated()GET /authaccessGuard check; also short-circuits on missing local token
login()POST /login(none / 1234){username, password}{access_token, refresh_token}
refreshToken()GET /auth/refreshrefreshReturns a new token pair
logout()DELETE /logoutaccessThen clears storage/cookies, model cache, disconnects redispad
register()POST /register/{token}(1234 header; real token in path)Complete signup with a password
verifyEmail()POST /verify-email(untouched)Trigger a verification email
updatePassword()POST /update-passwordexplicit Bearer <tempToken>Set new password; interceptor leaves it alone
sendPasswordReset()POST /reset-password(1234)Request a reset email

The 8-minute background refresh timer

The AuthService constructor starts a setInterval that fires every environment.httpSessionExpiryTime minutes (default 8) and silently refreshes both the token pair and the billing subscription cache:
src/app/auth/auth.service.ts constructor
setInterval(() => {
  const refreshToken = this.getRefreshToken();
  if (!refreshToken || refreshToken === 'undefined') return;   // skip if poisoned/absent
  this.refreshToken().subscribe({
    next: (tokens) => {
      this.storeAccessToken(tokens.access_token);
      this.storeRefreshToken(tokens.refresh_token);            // correct keys here
      this.refreshSubscriptionStatus();
    },
    error: (error) => console.debug('Token refresh failed:', error.status),
  });
}, this.sessionExpiryTime * 60 * 1000);
Why 8 minutes? The Gateway’s access token expires at 10 minutes (JWT lifecycle), so an 8-minute proactive refresh keeps the session alive with ~2 minutes of headroom, ideally before any request hits a 401. The two mechanisms are complementary: the timer is the happy path; the interceptor’s refresh-and-retry is the fallback when a request races the expiry.
The interval is never cleared — it lives for the whole lifetime of the root singleton (i.e. the browser tab). This is fine because AuthService is a root-provided singleton, but do not naively make AuthService non-root or provide it per-module, or you will spawn overlapping refresh timers.

Logout side effects

logout() does more than call the API — via Injector (lazily grabbed to break a circular dependency with VehicleCommandService) it tears down client state:
1

DELETE /logout

Server-side session/token invalidation.
2

Clear local state

localStorage.clear() + clearCookies() (expires every cookie on path=/).
3

Free memory & sockets

ModelCacheService.clearCache() drops downloaded 3D models; VehicleCommandService.disconnect() closes the redispad WebSocket.
4

Redirect

navigate('login') and a PrimeNG toast.
Keep the Injector.get(VehicleCommandService) lazy-resolution pattern (auth.service.ts:115) when refactoring DI. Injecting VehicleCommandService into AuthService’s constructor closes the loop VehicleCommandService → AuthService → VehicleCommandService (VehicleCommandService already injects AuthService at vehicle-command.service.ts:36), which Angular will flag at bootstrap.

Things a future editor must preserve

  • The '1234' fallback header is load-bearing for how public routes behave — verify the backend before removing it.
  • refreshToken field name in handle401Error is camelCase and wrong; the timer path is snake_case and right. Fix them together or not at all.
  • Skip-lists are substring matches: ignoreRefreshUrls and the outbound /update-password//verify-email checks all use url.includes(...). A new route whose path merely contains /auth (e.g. /authors) would silently be excluded from refresh-and-retry.
  • Both the guard and isAuthenticated() navigate to /login — don’t add a third redirect.
  • logout() uses localStorage.clear(), so anything else you stash in localStorage disappears on sign-out.
For the broader picture of how these tokens travel to the REST, Socket.IO, redispad, and Janus transports, see Frontend ↔ Gateway Integration and the full Angular Services & REST Reference.