Skip to content

fix: validate JWT expiration with clock skew tolerance - #3467

Open
sinchubhat wants to merge 1 commit into
mainfrom
issue993-console
Open

fix: validate JWT expiration with clock skew tolerance#3467
sinchubhat wants to merge 1 commit into
mainfrom
issue993-console

Conversation

@sinchubhat

@sinchubhat sinchubhat commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes first-load 401 errors on /api/v1/devices/stats when stale JWT exists in localStorage.

Addresses device-management-toolkit/console#993

Root cause:
Original code didn't validate JWT expiration before trusting tokens from localStorage. In distributed systems, client/edge/backend clocks may differ due to timezone and NTP drift.

Solution:
Add JWT validation with 5-minute clock skew tolerance:

  • Tokens expired >5 minutes: rejected immediately, redirect to login
  • Tokens expired <5 minutes: accepted (handles clock differences)
  • Valid tokens: accepted, dashboard loads normally

Changes:

  • Add restoreSessionFromStorage() to validate JWT expiration
  • Add 5-minute clock skew tolerance (industry standard)
  • Add authStateInitialized$ to prevent race conditions
  • Fix authorize interceptor regex to only exclude login endpoint
  • Add comprehensive test coverage

Clock skew tolerance (5 minutes) handles:

  • Timezone differences between client and server
  • NTP clock drift in edge nodes
  • Distributed system clock variations
  • Rejects tokens expired >5 minutes ago

Testing:

Prerequisites:
Browser and host/server time must match!

Check times match:

In terminal

date

In sample-web-ui browser F12 console

console.log(new Date().toString());

If times differ by >5 minutes, fix before testing:

  • Close all browser windows and restart
  • Or sync system time: sudo timedatectl set-ntp true

Unit tests

npm test -- --include='**/auth.service.spec.ts' --browsers=ChromeHeadless --watch=false
npm test -- --include='**/authorize.interceptor.spec.ts' --browsers=ChromeHeadless --watch=false

Testing with Console

Backend API Tests (curl)

Test 1: Fresh Valid Token

BASE="https://localhost:8181"
USER="username"
PASS="password"

Login and get token

TOKEN=$(curl -sk -X POST "$BASE/api/v1/authorize" \
  -H "Content-Type: application/json" \
  -d "{\"username\":\"$USER\",\"password\":\"$PASS\"}" | \
  grep -o '"token":"[^"]*"' | cut -d'"' -f4)
echo "Token: $TOKEN"

Test with backend

curl -sk -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/devices/stats"

Expected: HTTP 200 {"totalCount":0,"connectedCount":0,"disconnectedCount":0}

Test 2: Expired Token (10 minutes ago)

Create expired token

EXPIRED=$(node -e "
  const b64url = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
  const exp = Math.floor(Date.now() / 1000) - (10 * 60);
  console.log(b64url({alg:'HS256'}) + '.' + b64url({exp}) + '.fake');
")

Test with backend

curl -sk -H "Authorization: Bearer $EXPIRED" "$BASE/api/v1/devices/stats"

Expected: HTTP 401 {"error":"invalid access token"}

Test 3: Invalid/No Token

Malformed token

curl -sk -H "Authorization: Bearer bad.token" "$BASE/api/v1/devices/stats"

Expected: HTTP 401 {"error":"invalid access token"}

No token

curl -sk "$BASE/api/v1/devices/stats"

Expected: HTTP 401 {"error":"request does not contain an access token"}

Testing with Cloud

Kong does the JWT validation and not MPS
Endpoints through Kong: /mps/login/api/v1/authorize and /mps/api/v1/devices/stats
cloud base url should be https://ipaddress:443/ and not http://localhost:3000

git clone -b v2 --recursive https://github.com/device-management-toolkit/deployment.git deployment-v2
cd deployment-v2
cp .env.template .env

Edit .env and kong.yaml as mentioned in documentation https://device-management-toolkit.github.io/docs/2.36/GetStarted/Cloud/setup/

cd /home/hspe/sinchana/deployment-v2/sample-web-ui
git fetch origin
git checkout issue993-console

docker compose up -d --build (gives proxy error)
or
docker compose build \
  --build-arg http_proxy=<value-here> \
  --build-arg https_proxy=<value-here> \
  --build-arg no_proxy="no proxy values"
docker compose up -d
docker ps --format "table {{.Image}}\t{{.Status}}\t{{.Names}}"

Testing via curl cmds

Test 1: Login and get valid token

BASE="https://localhost:443"
USER="hspeoob"
PASS="Openamt-2026"
TOKEN=$(curl -sk -X POST "$BASE/mps/login/api/v1/authorize" \
  -H "Content-Type: application/json" \
  -d "{\"username\":\"$USER\",\"password\":\"$PASS\"}" | \
  grep -o '"token":"[^"]*"' | cut -d'"' -f4)
echo "Token: $TOKEN"

Test 2: Valid token - should work

curl -sk -H "Authorization: Bearer $TOKEN" "$BASE/mps/api/v1/devices/stats"

Expected: {"totalCount":0,"connectedCount":0,"disconnectedCount":0}

Test 3: Expired token (10 minutes ago) - should fail

EXPIRED=$(node -e "
  const b64url = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
  const exp = Math.floor(Date.now() / 1000) - (10 * 60);
  console.log(b64url({alg:'HS256'}) + '.' + b64url({exp}) + '.fake');
")
curl -sk -H "Authorization: Bearer $EXPIRED" "$BASE/mps/api/v1/devices/stats"

Expected: HTTP 401 {"message":"No mandatory 'iss' in claims"}

Test 4: Malformed token - should fail

curl -sk -H "Authorization: Bearer bad.token" "$BASE/mps/api/v1/devices/stats"

Expected: HTTP 401 {"message":"Bad token; invalid JSON"}

Test 5: No token - should fail

curl -sk "$BASE/mps/api/v1/devices/stats"

Expected: HTTP 401 {"message": "Unauthorized"}

Manual Browser Tests

Test 1: Valid Token (Should Stay Logged In)

  • Login at http://localhost:4200
  • Wait for dashboard
  • Press F5 to refresh

Expected: Stays logged in, dashboard loads

Test 2: Expired Token - Beyond Tolerance (Should Redirect to Login)

  • Login and wait for dashboard
  • Press F12 → Go to Console tab
  • Copy and paste this (one command at a time):
const b64 = (o) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
const expired = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ exp: Math.floor(Date.now() / 1000) - 600 }) + '.x';
localStorage.setItem('loggedInUser', JSON.stringify({ token: expired })); location.href = '/';

What this does: Creates token expired 10 minutes ago (beyond 5-min tolerance)
Expected:

  • Redirects to /login immediately
  • No 401 error in Network tab
  • No error dialog

Test 3: Expired Token - Within Tolerance (Should Stay Logged In)

  • Login and wait for dashboard
  • Press F12 → Go to Console tab
  • Copy and paste this (one command at a time):
const b64 = (o) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
const expired = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ exp: Math.floor(Date.now() / 1000) - 180 }) + '.x';
localStorage.setItem('loggedInUser', JSON.stringify({ token: expired })); location.href = '/';

What this does: Creates token expired 3 minutes ago (within 5-min tolerance)

Expected behavior:

  1. Client validation: Token expired 3 min → Within 5-min tolerance → VALID
  2. Client allows dashboard to load (proves client validation worked!)
  3. Dashboard makes API call → Server rejects fake signature (.x) → 401
  4. Error interceptor catches 401 → Logs out → Redirects to login
    Note: The 401 error is from the fake signature, not expiration. The key proof that clock tolerance works is that dashboard loaded (unlike Test 2 where client rejected immediately before loading).

Helper:

Check Current Token Status

To see your token expiration, press F12 → Console → Run:

const user = JSON.parse(localStorage.getItem('loggedInUser'));
const payload = JSON.parse(atob(user.token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
const exp = new Date(payload.exp * 1000);
console.log('Expires:', exp.toString());
console.log('Expired:', exp < new Date());

Debugging

Check Token Details
// In browser console

const user = JSON.parse(localStorage.getItem('loggedInUser'));
if (user && user.token) {
  const payload = JSON.parse(atob(user.token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
  const exp = new Date(payload.exp * 1000);
  const now = new Date();
  const tolerance = 5 * 60 * 1000;
  
  console.log('Expires:', exp);
  console.log('Now:', now);
  console.log('Expired:', exp < now);
  console.log('Valid (with tolerance):', (exp.getTime() + tolerance) > now.getTime());
}

Monitor Network

  1. DevTools → Network tab
  2. Filter: "stats"
  3. Look for /api/v1/devices/stats
  4. Check status: 200 = valid, 401 = invalid

Understanding 5-Minute Tolerance

Why needed?

  • Client browser can be any timezone
  • Edge nodes deployed globally
  • NTP drift between servers

How it works:

Token expires: 10:00
Current: 10:03 (3 min past) → VALID (within 5-min tolerance)
Current: 10:07 (7 min past) → INVALID (beyond tolerance)

Industry standard: OAuth 2.0, JWT RFC, Auth0, AWS, Google all use 5-10 minutes

PR Checklist

  • Unit Tests have been added for new changes
  • API tests have been updated if applicable
  • All commented code has been removed
  • If you've added a dependency, you've ensured license is compatible with Apache 2.0 and clearly outlined the added dependency.

What are you changing?

Anything the reviewer should know when reviewing this PR?

If the there are associated PRs in other repositories, please link them here (i.e. device-management-toolkit/repo#365 )

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens session restore on app startup by validating locally stored JWTs (with a 5‑minute clock-skew tolerance) to prevent first-load 401s, and tightens the authorization interceptor’s login-endpoint detection.

Changes:

  • Added client-side JWT exp validation with a 5-minute tolerance and an initialization gate (authStateInitialized$) to reduce startup race conditions.
  • Updated the authorize interceptor to only skip attaching a bearer token for the actual login endpoint (not other /authorize/* routes).
  • Expanded unit tests for both the interceptor behavior and AuthService constructor token handling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/app/authorize.interceptor.ts Refines login-endpoint matching so only /api/v1/authorize is excluded from auth header injection.
src/app/authorize.interceptor.spec.ts Updates/extends tests to cover the new login exclusion and /authorize/validate inclusion behavior.
src/app/auth.service.ts Adds localStorage session restore with JWT expiration validation + clock-skew tolerance and an initialization observable to coordinate route guarding.
src/app/auth.service.spec.ts Adds constructor-time token validation tests for valid/expired/malformed stored tokens.

Comment thread src/app/auth.service.ts Outdated
Comment thread src/app/auth.service.ts
Comment thread src/app/auth.service.spec.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/app/auth.service.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/app/auth.service.ts:210

  • Use localStorage.setItem() instead of property assignment (localStorage.loggedInUser = ...) for consistency with the rest of the codebase and to avoid relying on Storage property-mapping behavior. Examples using setItem/getItem: src/app/core/about/about.component.ts:38-44, src/app/core/toolbar/toolbar.component.ts:113-114.
        if (!environment.useOAuth) {
          this.isLoggedIn = true
          localStorage.loggedInUser = JSON.stringify(data)
          this.loggedInSubject$.next(this.isLoggedIn)
          this.authStateInitialized$.next(true)

@sinchubhat
sinchubhat marked this pull request as ready for review July 27, 2026 14:31
@madhavilosetty-intel

Copy link
Copy Markdown
Contributor

@sinchubhat Have you tested these changes with cloud deployment?

Fixes first-load 401 errors on /api/v1/devices/stats when stale JWT exists in localStorage.

Addresses device-management-toolkit/console#993
@sinchubhat

Copy link
Copy Markdown
Contributor Author

@sinchubhat Have you tested these changes with cloud deployment?

Hi, tested with cloud as well, updated the PR description with the curl commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First-load 401 on api/v1/devices/stats when stale JWT exists in UI local storage

3 participants