Skip to content

fix(frontend): guard requests on auth and batch settings fetches - #3749

Open
jmthomas wants to merge 3 commits into
auth-verify-token-loggingfrom
auth-guard-frontend
Open

fix(frontend): guard requests on auth and batch settings fetches#3749
jmthomas wants to merge 3 commits into
auth-verify-token-loggingfrom
auth-guard-frontend

Conversation

@jmthomas

@jmthomas jmthomas commented Aug 20, 2026

Copy link
Copy Markdown
Member

Stacked on #3748review/merge that first; this branch's base is auth-verify-token-logging, so the diff shown here is frontend only. Login.vue calls the POST /auth/verify-token endpoint added in #3748.

Problem

A page load with a stale or missing token fired one request per component. Each was a guaranteed 401, logged server side as an error, and each rejection was a console stack trace on the way to the login page. The login page itself checked its token via auth#verify, where a session token is never a valid password — so the check failed and consumed rate limit.

services/authGuard.js

Single place that decides "we have no usable token":

  • refreshToken() — rejects with an AuthRequiredError before the request goes out. Also redirects to login at most once per page load: a second redirect can only mean the first didn't navigate, which would otherwise bounce forever.
  • isUnauthorizedError() — matches axios 401s and the synthetic errors OpenC3Api.exec builds, which carry no response, only a name copied from the Ruby exception class (OpenC3::AuthError, OpenC3::ForbiddenError). Without the name check every 401 through OpenC3Api slipped past.
  • logUnlessAuthRequired() — drop-in for .catch(console.error) on requests that fire before login is settled.
  • An unhandledrejection handler swallows AuthRequiredError only. Being sent to login is a navigation, not a bug.

Errors are matched by name, not identityAuthRequiredError is deliberately not re-exported from services/index.js, since instanceof breaks across editions (core and enterprise each ship their own tool-base and Auth implementation).

Batched settings

getCachedSetting now queues into one get_settings call per tick instead of a round trip per component (theme, astro, subtitle, time_zone, source_url, … all mount together). Details that matter:

  • A failed batch settles its callers with fallbacks but does not cache them, so a transient 401 during a token refresh doesn't pin every setting to its default for the rest of the session.
  • A generation counter means a batch queued before resetSettingsCache() won't write into the cache afterwards — but still settles its promises, so awaiting callers aren't left hanging.
  • invalidateCachedSetting() added for components that poll a setting on an interval (ContextTag) and would otherwise be served the same cached value forever.

Test plan

  • pnpm lint --max-warnings 0 clean in openc3-js-common, openc3-vue-common, openc3-cosmos-tool-dataextractor, openc3-cosmos-tool-dataviewer
  • Manual: load a tool with an expired token in localStorage → single redirect to login, no console stack traces, no 401 burst in cmd-tlm-api logs
  • Manual: settings still apply on load (theme, classification banner, subtitle, time zone, context tag)
  • Playwright

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 20, 2026 18:42
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 53.77358% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.99%. Comparing base (47010ab) to head (dcfaff5).

Files with missing lines Patch % Lines
...ackages/openc3-vue-common/src/tools/base/Login.vue 15.38% 11 Missing ⚠️
...ackages/openc3-js-common/src/services/authGuard.js 56.52% 10 Missing ⚠️
...es/openc3-vue-common/src/tools/base/ContextTag.vue 14.28% 5 Missing and 1 partial ⚠️
...ages/openc3-vue-common/src/tools/base/UserMenu.vue 16.66% 5 Missing ⚠️
...ckages/openc3-vue-common/src/tools/base/AppNav.vue 42.85% 2 Missing and 2 partials ⚠️
...ckages/openc3-vue-common/src/util/settingsCache.js 80.95% 4 Missing ⚠️
...ns/packages/openc3-js-common/src/services/axios.js 25.00% 3 Missing ⚠️
...ns/packages/openc3-js-common/src/services/cable.js 40.00% 3 Missing ⚠️
...gins/packages/openc3-js-common/src/services/api.js 71.42% 2 Missing ⚠️
...penc3-vue-common/src/tools/admin/tabs/ToolsTab.vue 66.66% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                      @@
##           auth-verify-token-logging    #3749      +/-   ##
=============================================================
- Coverage                      79.11%   78.99%   -0.12%     
=============================================================
  Files                            894      895       +1     
  Lines                          66871    66925      +54     
  Branches                        2591     2607      +16     
=============================================================
- Hits                           52906    52869      -37     
- Misses                         13302    13404     +102     
+ Partials                         663      652      -11     
Flag Coverage Δ
frontend 65.58% <53.77%> (-0.48%) ⬇️
python 79.29% <ø> (+<0.01%) ⬆️
ruby-api 81.53% <ø> (-0.36%) ⬇️
ruby-backend 84.42% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR centralizes “no usable token” handling to prevent bursts of guaranteed-401 requests on page load, and reduces redundant settings round-trips by batching settings fetches.

Changes:

  • Introduces a shared authGuard (token refresh + auth-required detection + “log unless auth”) and wires it into Api/OpenC3Api and selected UI callers.
  • Batches settings reads via get_settings in settingsCache and updates base components to use the cache.
  • Updates Login flow to validate session tokens via POST /auth/verify-token and opt out of interceptor banners for expected 401/429 responses.

Reviewed changes

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

Show a summary per file
File Description
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/ArrayplotWidget.vue Suppresses subscription attempt when token refresh rejects.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/util/settingsCache.js Implements per-tick batching for settings fetches + cache invalidation.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/util/index.js Re-exports new invalidateCachedSetting.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/UserMenu.vue Switches to cached settings + auth-aware logging for requests.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/ThemeSwitcher.vue Switches theme read to cached settings.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/Notifications.vue Uses auth-aware logging for expected auth redirects.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/Login.vue Adds verify-token path + ignores expected 401/429 in interceptor.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/ContextTag.vue Uses cached setting + invalidates cache for polling refresh.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/ClassificationBanners.vue Switches classification banner read to cached settings.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/AppNav.vue Batches base settings reads + auth-aware logging for tool list fetch, suppresses token-refresh rejection noise.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/AppFooter.vue Switches source_url read to cached settings + auth-aware logging for version call.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/admin/tabs/ToolsTab.vue Makes “add tool” failures resilient to missing error.response.
openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/Graph.vue Suppresses subscription attempt when token refresh rejects.
openc3-cosmos-init/plugins/packages/openc3-tool-base/public/js/auth.js Makes core Auth reject with AuthRequiredError when redirecting to login.
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/openc3Api.js Uses refreshToken() guard before JSON-RPC requests.
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/index.js Exports new auth-guard helpers (without exporting the error class).
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/cable.js Treats auth-required rejection as a non-tool-error for subscriptions.
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/axios.js Updates interceptor ordering; 401 triggers refresh/redirect without banner spam.
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/authGuard.js Adds shared auth-required classification, guarded refresh, and unhandledrejection suppression.
openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/api.js Uses refreshToken() guard before REST requests.
openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataviewer/src/tools/DataViewer/DataViewer.vue Suppresses subscription attempts when token refresh rejects.
openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataextractor/src/tools/DataExtractor/DataExtractor.vue Suppresses subscription attempts when token refresh rejects.
Suppressed comments (1)

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/Graph.vue:1

  • Catching and swallowing all updateToken() rejections hides non-auth failures (e.g., unexpected exceptions inside the auth implementation), which can make real issues very hard to diagnose. Consider only swallowing the redirect case (e.g., via isAuthRequiredError(error)), and logging/rethrowing anything else. This pattern appears in multiple subscription sites in this PR; tightening the catch consistently would improve debuggability.
<!--

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/AppNav.vue Outdated
@jmthomas

jmthomas commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Test on main by doing the following in the dev console:

localStorage.openc3Token = 'ses_bogus'; location.reload()

Results in a bunch of CmdTlmServer logs:

2026-08-20 13:22:49.752 | ERROR | CMD__TLM__API | AuthError : Token is required
<FULL STACK TRACE>

As well as a bunch of console errors. Another slightly different way to test:

delete localStorage.openc3Token; location.href = '/tools/cmdtlmserver'

After running this branch you should see only a single CmdTlmServer message and very few console log errors.

@jmthomas
jmthomas requested a balanced review from Copilot August 20, 2026 20:01

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

Suppressed comments (8)

openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataviewer/src/tools/DataViewer/DataViewer.vue:588

  • This also swallows non-auth token-refresh failures, silently preventing packet subscriptions from being added. Only AuthRequiredError should be consumed; rethrow or report other errors, consistent with Cable.createSubscription.
          .catch(() => {})

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/AppNav.vue:344

  • The empty handler also hides non-auth refresh failures, despite this file already importing logUnlessAuthRequired. Use that handler so expected redirects stay quiet while real refresh failures remain visible.
            .catch(() => {})

openc3-cosmos-init/plugins/packages/openc3-tool-base/public/js/auth.js:40

  • The redirect-once guard in authGuard is bypassed here: every concurrent missing-token or 401 path calls login() before rejecting, while refreshToken() rethrows AuthRequiredError without using redirectToLogin(). A page-load burst can therefore still schedule multiple redirects. Make navigation idempotent inside the shared Auth implementation, or move it entirely behind the guarded helper.
      this.login(location.href)

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/UserMenu.vue:235

  • This request now cannot reach the server: OpenC3Auth.logout() clears localStorage.openc3Token first, so the new Api preflight guard rejects before sending the authenticated logout request. The endpoint requires the current token to terminate the server session. Send it first and perform the local logout in finally.
      Api.put(`/openc3-api/users/logout/${this.username}`).catch(
        logUnlessAuthRequired,
      )

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/ArrayplotWidget.vue:246

  • This swallows every token-refresh failure, not only AuthRequiredError. A transient or edition-specific refresh failure will silently skip the subscription and leave the widget idle. Check isAuthRequiredError and rethrow/log any other rejection, as Cable.createSubscription does.
          .catch(() => {})

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/Graph.vue:1769

  • This swallows every token-refresh failure, not only AuthRequiredError. A transient or edition-specific refresh failure will silently skip the subscription and leave the graph idle. Check isAuthRequiredError and rethrow/log any other rejection, as Cable.createSubscription does.
          .catch(() => {})

openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataviewer/src/tools/DataViewer/DataViewer.vue:555

  • This swallows every token-refresh failure, not only AuthRequiredError. A transient or edition-specific refresh failure will silently skip adding the item subscriptions. Check isAuthRequiredError and rethrow/log any other rejection, as Cable.createSubscription does.

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

          .catch(() => {})

openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataextractor/src/tools/DataExtractor/DataExtractor.vue:850

  • This swallows every token-refresh failure, not only AuthRequiredError. A transient or edition-specific refresh failure will silently skip adding the subscription and leave extraction idle. Check isAuthRequiredError and rethrow/log any other rejection, as Cable.createSubscription does.
        .catch(() => {})

@jmthomas
jmthomas force-pushed the auth-guard-frontend branch from ef2c1af to 80d1d77 Compare August 20, 2026 21:27
@sonarqubecloud

Copy link
Copy Markdown

jmthomas and others added 3 commits August 21, 2026 16:21
A page load with a stale or missing token fired one request per component,
each a guaranteed 401 logged server side as an error, and each rejection a
console stack trace on the way to the login page.

Add services/authGuard.js as the single place that decides "we have no usable
token": refreshToken() rejects with an AuthRequiredError before the request
goes out, isUnauthorizedError() recognizes both axios 401s and the synthetic
errors OpenC3Api.exec builds from Ruby exception names, and
logUnlessAuthRequired() replaces .catch(console.error) on requests that fire
before login is settled. Errors are matched by name, not identity, so this
works across core and enterprise Auth implementations.

Settings reads now coalesce: getCachedSetting queues into one batched
get_settings call per tick instead of a separate round trip per component, and
a failed batch settles its callers with fallbacks without caching them, so a
transient 401 doesn't pin every setting to its default for the session.

Login.vue uses the new POST /auth/verify-token instead of auth#verify, which
rejected session tokens as bad passwords and consumed rate limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add an optionalAuth request option so AppNav's unauthorized-by-design
/openc3-api/tools/all call still goes out with no token, which the nav
needs to render on the login page itself. Replace swallow-all token
refresh catches with logUnlessAuthRequired so only the expected redirect
to login is silent, and give the add-tool notification a string fallback
for JSON error bodies.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The server terminates only the session named by the Authorization header,
but OpenC3Auth.logout() cleared the token and reloaded first, so the
logout request either never went out or arrived unauthenticated. Await it
and do the local logout in finally.

Auth.login() now navigates at most once per page load, since a burst of
tokenless requests calls updateToken (and therefore login) once each.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@jmthomas
jmthomas force-pushed the auth-guard-frontend branch from 6b6f79a to dcfaff5 Compare August 21, 2026 22:21
@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Comment on lines +18 to +40
import {
isAuthRequiredError,
isUnauthorizedError,
logUnlessAuthRequired,
refreshToken,
} from './authGuard'
import axios from './axios'
import Cable from './cable'
import { ConfigParserError, ConfigParserService } from './configParser'
import OpenC3Api from './openc3Api'

export { Api, axios, Cable, ConfigParserError, ConfigParserService, OpenC3Api }
export {
Api,
axios,
Cable,
ConfigParserError,
ConfigParserService,
isAuthRequiredError,
isUnauthorizedError,
logUnlessAuthRequired,
OpenC3Api,
refreshToken,
}

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.

Suggested change
import {
isAuthRequiredError,
isUnauthorizedError,
logUnlessAuthRequired,
refreshToken,
} from './authGuard'
import axios from './axios'
import Cable from './cable'
import { ConfigParserError, ConfigParserService } from './configParser'
import OpenC3Api from './openc3Api'
export { Api, axios, Cable, ConfigParserError, ConfigParserService, OpenC3Api }
export {
Api,
axios,
Cable,
ConfigParserError,
ConfigParserService,
isAuthRequiredError,
isUnauthorizedError,
logUnlessAuthRequired,
OpenC3Api,
refreshToken,
}
export {
isAuthRequiredError,
isUnauthorizedError,
logUnlessAuthRequired,
refreshToken,
} from './authGuard'
import axios from './axios'
import Cable from './cable'
import { ConfigParserError, ConfigParserService } from './configParser'
import OpenC3Api from './openc3Api'
export {
Api,
axios,
Cable,
ConfigParserError,
ConfigParserService,
OpenC3Api,
}

}
}

export class AuthRequiredError extends Error {

@EmilyRagan EmilyRagan Aug 24, 2026

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.

Since this is not used anywhere outside of this file and seems like that will not change, perhaps this class should not be exported, and it might be better to move the comment about intentionally not exporting it from index.js to here instead

Suggested change
export class AuthRequiredError extends Error {
// NOTE: AuthRequiredError itself is deliberately not exported. It's matched by
// name, not by identity, so callers use isAuthRequiredError instead - exporting
// the class would invite instanceof checks that break across editions.
class AuthRequiredError extends Error {

Comment on lines +15 to +17
// NOTE: AuthRequiredError itself is deliberately not exported. It's matched by
// name, not by identity, so callers use isAuthRequiredError instead - exporting
// the class would invite instanceof checks that break across editions.

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.

Moving this comment to the definition of the class where it is not exported is clearer

Suggested change
// NOTE: AuthRequiredError itself is deliberately not exported. It's matched by
// name, not by identity, so callers use isAuthRequiredError instead - exporting
// the class would invite instanceof checks that break across editions.

Comment on lines +33 to +36
// @param value [Number] unused in core, minimum token validity in seconds
// @param from_401 [Boolean] whether a request just came back unauthorized
// @return [Promise<Boolean>] whether the token was refreshed, or a rejection
// with an AuthRequiredError if we're redirecting to login instead

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.

Multi-line comment style will treat this as a JSDoc comment, associating the documentation with the referenced parameters

Image
Suggested change
// @param value [Number] unused in core, minimum token validity in seconds
// @param from_401 [Boolean] whether a request just came back unauthorized
// @return [Promise<Boolean>] whether the token was refreshed, or a rejection
// with an AuthRequiredError if we're redirecting to login instead
/**
* @param value {number} unused in core, minimum token validity in seconds
* @param from_401 {boolean} whether a request just came back unauthorized
* @return {Promise<boolean>} whether the token was refreshed, or a rejection
* with an AuthRequiredError if we're redirecting to login instead
*/

Comment on lines +179 to +188
if (!isUnauthorizedError(error)) {
return
}
// Stale token - drop it and let the user log in normally. Only if it's
// still the token we checked: the user can finish logging in while
// this request is in flight, and deleting the new token would send
// them straight back to the login form.
if (localStorage.openc3Token === token) {
delete localStorage.openc3Token
}

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.

Clearer logically to act on the positive case of "if unauthorized and the token is still the same as when the request was made, clear it"

Suggested change
if (!isUnauthorizedError(error)) {
return
}
// Stale token - drop it and let the user log in normally. Only if it's
// still the token we checked: the user can finish logging in while
// this request is in flight, and deleting the new token would send
// them straight back to the login form.
if (localStorage.openc3Token === token) {
delete localStorage.openc3Token
}
if (isUnauthorizedError(error) && localStorage.openc3Token === token) {
// Stale token - drop it and let the user log in normally. Only if it's
// still the token we checked: the user can finish logging in while
// this request is in flight, and deleting the new token would send
// them straight back to the login form.
delete localStorage.openc3Token
}

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.

3 participants