{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.
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
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 multiHTTP_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
| Request URL contains | Bearer token attached | Why |
|---|---|---|
/auth/refresh | the refresh token | Exchanging 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 else | the access token, or literal '1234' | Normal authenticated calls |
401 refresh-and-retry (inbound)
On a401, 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
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
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
"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.
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
| Key | Written by | Read by | Notes |
|---|---|---|---|
accessToken | login.component.ts:55, storeAccessToken() | interceptor, isAuthenticated(), Socket.IO & redispad query params | The string "undefined" is treated as missing everywhere |
refreshToken | login.component.ts:56, storeRefreshToken() | interceptor & timer refresh | Subject of the pitfall above |
email | login.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 prefixenvironment.url (default http://localhost:5000/api/v1; see Environments, Build & CI).
| Method | Endpoint | Auth token used | Purpose |
|---|---|---|---|
isAuthenticated() | GET /auth | access | Guard check; also short-circuits on missing local token |
login() | POST /login | (none / 1234) | {username, password} → {access_token, refresh_token} |
refreshToken() | GET /auth/refresh | refresh | Returns a new token pair |
logout() | DELETE /logout | access | Then 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-password | explicit Bearer <tempToken> | Set new password; interceptor leaves it alone |
sendPasswordReset() | POST /reset-password | (1234) | Request a reset email |
The 8-minute background refresh timer
TheAuthService 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
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.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:
Free memory & sockets
ModelCacheService.clearCache() drops downloaded 3D models; VehicleCommandService.disconnect() closes the redispad WebSocket.Things a future editor must preserve
- The
'1234'fallback header is load-bearing for how public routes behave — verify the backend before removing it. refreshTokenfield name inhandle401ErroriscamelCaseand wrong; the timer path issnake_caseand right. Fix them together or not at all.- Skip-lists are substring matches:
ignoreRefreshUrlsand the outbound/update-password//verify-emailchecks all useurl.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()useslocalStorage.clear(), so anything else you stash inlocalStoragedisappears on sign-out.

