Skip to content

fix(login): reject expired JWT from local storage on startup - #3489

Open
sinchubhat wants to merge 4 commits into
mainfrom
fix/jwt-expiration
Open

fix(login): reject expired JWT from local storage on startup#3489
sinchubhat wants to merge 4 commits into
mainfrom
fix/jwt-expiration

Conversation

@sinchubhat

@sinchubhat sinchubhat commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Validate the exp claim before restoring a persisted session, so protected requests are no longer sent with a stale token on first load. An unreadable or expired token is discarded and the user is routed to login instead of receiving a 401.

Resolves: device-management-toolkit/console#993

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 fix/jwt-expiration

docker compose up -d --build
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="<username>"
PASS="<password>"
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 (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
Expected:

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

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:

Monitor Network

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

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

Adds startup-time validation for a persisted (localStorage) login session so the app won’t consider the user authenticated when the stored JWT is expired, reducing first-load 401s and redirecting the user to login as needed.

Changes:

  • Replaces the constructor’s “any stored user => logged in” logic with a dedicated session restore path that checks JWT expiration.
  • Hardens token retrieval from localStorage against malformed JSON.
  • Adds unit tests covering session restore behavior for expired/malformed storage entries.

Reviewed changes

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

File Description
src/app/auth.service.ts Introduces restoreStoredSession() + JWT exp parsing to avoid restoring expired sessions from localStorage.
src/app/auth.service.spec.ts Adds tests for stored-session restore behavior, including expired/corrupted storage scenarios.
Suppressed comments (2)

src/app/auth.service.ts:140

  • restoreStoredSession currently restores any stored session unless it can prove the token is expired. For malformed/non-JWT/empty tokens (or JWTs with an unreadable payload), this still marks the user logged in and allows protected routes to activate, so initial requests can still be sent with an invalid token and return 401—contradicting the PR description that unreadable tokens are discarded on startup.
    const expiresAt = this.getTokenExpiration(token)
    if (expiresAt != null && expiresAt <= Date.now()) {
      console.warn(`[auth] discarded stored session: expired ${new Date(expiresAt).toISOString()}`)
      localStorage.removeItem('loggedInUser')
      return

src/app/auth.service.spec.ts:281

  • These tests currently assert that non-JWT/empty tokens are treated as a valid restored session. That keeps protected routes open and allows initial API calls to be made with an invalid token (leading to 401s), which conflicts with the PR goal of discarding unreadable tokens on startup.
    it('should restore a session whose token is not a JWT', () => {
      const restored = createServiceWithStoredSession(JSON.stringify({ token: 'not-a-jwt' }))

      expect(restored.isLoggedIn).toBeTrue()
      expect(localStorage.getItem('loggedInUser')).not.toBeNull()
    })

    it('should restore a session whose token is empty', () => {
      const restored = createServiceWithStoredSession(JSON.stringify({ token: '' }))

      expect(restored.isLoggedIn).toBeTrue()
      expect(localStorage.getItem('loggedInUser')).not.toBeNull()
    })

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

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 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/app/auth.service.ts:129

  • restoreStoredSession() assumes JSON.parse(stored).token is a string. If the JSON is valid but token is not a string, getTokenExpiration(token) will throw at runtime (token.split is not a function) during app startup. Add a type check and discard the stored session when token isn’t a string.
    let token: string
    try {
      token = JSON.parse(stored).token ?? ''
    } catch {
      console.warn('[auth] discarded stored session: unreadable')

src/app/auth.service.ts:136

  • PR description says “unreadable or expired token is discarded and the user is routed to login”, but the current restore logic only discards (1) unreadable localStorage JSON or (2) tokens with a readable exp that is expired. Tokens that are malformed JWTs / missing exp / empty are explicitly kept (and covered by the new tests), which can still produce an initial 401 in those cases. Please either update the PR description/linked issue expectations to match this behavior, or change the restore logic (and tests) to also discard unreadable token strings on startup.
    // Only a provably expired token is dropped here. When the expiration cannot be read the
    // session is kept - the server answers 401 and errorHandlingInterceptor logs the user out.
    const expiresAt = this.getTokenExpiration(token)

src/app/auth.service.ts:113

  • getLoggedUserToken() assumes loggedInUser.token is a string. If localStorage is tampered with (e.g., token is an object/number), this method will return a non-string value despite the string return type, which can break callers that build headers or do string operations. Parse into an unknown and type-check before returning.

This issue also appears on line 125 of the same file.

      try {
        const token: string = JSON.parse(loggedInUser).token
        return token ?? ''
      } catch {
        return ''
      }

@sinchubhat
sinchubhat marked this pull request as ready for review August 6, 2026 04:47
@sinchubhat

Copy link
Copy Markdown
Contributor Author

I have tested using both cloud and console. Tested using both curl command and via manual browser test as mentioned in PR description.

Report a session timeout for any 401 on an authenticated route, not
only when the body matches Kong's wording, so the Console gateway no
longer logs the user out with no message. Guard the body access that
threw on an empty 401, and keep concurrent failures from stacking a
dialog each. Leave the login call alone, since it answers 401 for bad
credentials and the login page reports that itself.

Vet a session restored from local storage with one protected call at
startup, so routes that issue no request of their own cannot render
behind a token the server has already rejected.

Resolves: device-management-toolkit/console#993
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