Skip to content

fix(auth): remove dashboard bearer token and add OIDC token verification (#294) - #345

Merged
Vishnu2707 merged 2 commits into
OWASP:devfrom
parthrohit22:fix/issue-294-oidc-auth-boundary
Sep 18, 2026
Merged

Vishnu2707 merged 2 commits into
OWASP:devfrom
parthrohit22:fix/issue-294-oidc-auth-boundary

Conversation

@parthrohit22

Copy link
Copy Markdown
Collaborator

What does this PR do?

Removes the bearer token built into the dashboard and adds OIDC token verification (JWKS signature, issuer, audience, tenant, IdP app roles) to the API. This is the second containment step for #294, after #320 (token expiry, roles, subscription allowlist).

Type of change

  • New scan rule
  • Remediation playbook
  • Bug fix (security)
  • Dashboard/front-end work
  • API endpoint (authentication middleware)
  • Documentation
  • Compliance mapping

Changes

Frontend: no credential in public JS

  • Removed the VITE_JWT_TOKEN bootstrap and the dev-local-token fallback from App.jsx. Anything in a VITE_* variable is compiled into the public bundle.
  • api.js now keeps the token in memory only (setToken, getToken, clearToken) and deletes any legacy localStorage.jwt_token on load without using it. aiApi.js reads from the same in-memory store.
  • CI guard: the frontend job builds with a canary VITE_JWT_TOKEN and fails if a JWT-shaped value, the canary or dev-local-token appears in frontend/dist. I checked it both ways: the guard catches the canary on current dev code and passes with this change. The canary is assembled at run time, so Gitleaks doesn't flag the workflow file.

API: durable token boundary (api/auth.py)

The auth mode is set with OPENSHIELD_AUTH_MODE:

shared_secret (default, unchanged for local/CI) oidc (new, enterprise)
Signature HS256 with JWT_SECRET Asymmetric only (default RS256) via OIDC_JWKS_URL (5 min key cache)
Required claims exp, sub (new); iss/aud when JWT_ISSUER/JWT_AUDIENCE are set exp, iat, iss, aud, sub
Tenant tid must be in OIDC_ALLOWED_TENANTS when set
Role role claim IdP app roles (roles by default, e.g. OpenShield.Operator), mapped via OIDC_ROLE_MAP; a self-asserted role claim is ignored
  • Algorithm confusion: HS256 and alg: none tokens are refused in oidc mode before key lookup, so a token minted with JWT_SECRET can never pass as an IdP token.
  • Fails closed:
    • an unreachable JWKS endpoint returns 503
    • an identity with no OpenShield role returns 403
    • the API refuses to start if oidc mode is missing issuer, audience or JWKS, uses a non-HTTPS JWKS URL, or configures a symmetric algorithm
  • The API logs a startup warning when shared_secret mode runs in production.
  • The middleware in app.py now calls the verifier. The viewer-can't-write gate and all existing error messages are unchanged.

Docs and scripts

  • New docs/security/authentication.md, covering:
    • choosing a mode
    • Entra ID setup (app roles, required assignment, v2 tokens, env vars)
    • an 8-step containment checklist (stop exposure, suspend, rotate JWT_SECRET, restrict scope, review logs, verify, restore)
    • routine secret rotation
  • generate_demo_jwt.py now defaults to a 1h viewer token, is for API testing only, and no longer tells anyone to set it as VITE_JWT_TOKEN.
  • Updated api-reference.md, FRONTEND_API_TESTING.md, API_ENDPOINTS.txt, scripts/README.md, .env.example and CHANGELOG.md.

Behavior change to note

A dashboard deployment that relied on VITE_JWT_TOKEN will stop sending a token. Reads then work only against an API running with OPENSHIELD_PUBLIC_DEMO=true (non-sensitive data). This is intentional, per #294's containment step "Remove VITE_JWT_TOKEN and the automatic dev-local-token bootstrap". The public API is still suspended (#243).

Operator actions (not code, can't be done from a PR)

  • Delete VITE_JWT_TOKEN from the Vercel environment and rotate JWT_SECRET on the API, following the checklist in docs/security/authentication.md.
  • Before restoring the API with real data, register the Entra app roles and switch to OPENSHIELD_AUTH_MODE=oidc.

Testing

  • tests/test_oidc_auth.py: 34 new tests using a throwaway RSA key and a stub JWKS, with no network access. They cover:
    • a valid principal, highest-role selection and a custom role claim or map
    • expired tokens, wrong issuer, wrong audience, and each missing required claim
    • a tenant outside the allowlist, a missing tenant, and the allowlist being optional
    • no role → 403, and a self-asserted role claim ignored
    • an unknown signing key, an unknown kid, an HS256 token, alg: none, and a garbage token
    • an unreachable JWKS → 503
    • every startup configuration failure
    • shared-secret mode: missing sub, iss/aud checks, and RS256 refused
    • middleware end to end: viewer reads but can't write, operator writes, the HS256 conftest token → 401 in oidc mode, wrong tenant → 401
  • Existing tests/test_auth.py and tests/test_subscription_authorization.py pass unchanged.
  • Full backend suite: 1080 passed. The 2 failures in test_devops_client.py are local-only (azure-devops isn't installed on my machine) and also fail on unmodified dev.
  • Frontend: npm run lint, api.test.mjs (26, including a new memory-only/legacy-purge test), aiApi.test.mjs, usePageData.test.mjs, a11y and i18n checks, and npm run build with the bundle guard.
  • ruff check, ruff format --check and bandit -r api/ -ll are clean.
  • No hardcoded credentials or secrets.

Related issue

Partially addresses #294. Still open under that issue:

  • dashboard sign-in with Authorization Code and PKCE
  • persisted tenant/subscription ownership, with tenant context in every repository query and an RLS evaluation
  • cross-tenant integration tests across scans, findings, compliance, resources, drift, AI and enrichment

Checklist

  • Every commit includes a DCO Signed-off-by trailer
  • Branch name follows the convention: fix/description
  • I have not committed any real credentials

…ion (OWASP#294)

Frontend
- remove the VITE_JWT_TOKEN bootstrap and dev-local-token fallback
- keep bearer tokens in memory only and purge legacy localStorage tokens
- fail CI if a JWT-shaped value reaches the public bundle

API
- move token verification into api/auth.py with two modes:
  shared_secret (HS256, now also requires sub, optional iss/aud) and
  oidc (JWKS-verified asymmetric tokens with issuer, audience, expiry,
  issued-at, subject, tenant allowlist and IdP app-role mapping)
- refuse HS256/none in oidc mode, fail closed with 503 when JWKS is
  unreachable, and refuse to start on incomplete oidc configuration
- warn when shared_secret mode runs in production

Docs
- authentication setup, containment checklist and JWT_SECRET rotation
- demo JWT script is now short-lived and for API testing only

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
Comment thread api/auth.py Fixed
Comment thread api/auth.py Fixed
Comment thread api/auth.py Fixed
Rename the shared-secret mode constant so the credential scan does not
read it as a hardcoded secret, generate the test signing secret at run
time, and reword rejection log messages flagged by Semgrep.

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>

@m-khan-97 m-khan-97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I reviewed the current authentication head carefully, including the middleware boundary, OIDC configuration validation, JWKS failure behavior, algorithm restrictions, issuer/audience/expiry checks, tenant allowlist, server-side role mapping, and removal of the dashboard token bootstrap. I also ran the focused authentication and subscription suites locally: all 61 tests passed.

Approved as the next containment step. This approval is for the scope stated in the PR, not closure of #294. Before restoring an API that carries real data, the operator actions still need to be completed, and the remaining PKCE sign-in plus persisted tenant ownership/query isolation work needs to stay tracked under #294.

@ritiksah141 ritiksah141 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Reviewed end to end: read the full diff, checked the branch out locally, and re-ran the suites and the CI guard independently.

Local verification

  • Full backend suite: 1085 passed, 6 skipped. The 2 test_devops_client.py failures mentioned in the description are skips in this environment (azure-devops not installed).
  • tests/test_oidc_auth.py, tests/test_auth.py, tests/test_subscription_authorization.py: 61 passed.
  • ruff check, ruff format --check, bandit -r api/ -ll: clean.
  • Frontend api.test.mjs (26), aiApi.test.mjs, usePageData.test.mjs: pass.
  • Reproduced the canary build in both directions: building with the canary VITE_JWT_TOKEN set passes the guard, and the guard grep catches a JWT-shaped value planted in frontend/dist, so the gate is proven non-vacuous (job working-directory: frontend makes the dist path resolve correctly).

Security review notes

  • Algorithm confusion is blocked twice: the unverified-header alg allowlist check happens before key lookup, and jwt.decode pins algorithms again. Startup refuses symmetric algorithms, a non-HTTPS JWKS URL, and missing issuer/audience/JWKS.
  • Confirmed against the pinned PyJWT 2.13.0 that PyJWKClient.get_signing_key_from_jwt reads only kid and never honors a token-supplied jku, so there is no token-controlled key injection path. The client always fetches the configured HTTPS URL.
  • Fails closed as described: unreachable JWKS returns 503, an identity with no mapped role returns 403, a self-asserted role claim is ignored in oidc mode, and tenant rejection returns the generic "Invalid token" without leaking claim details.
  • g.user is not read anywhere else in api/, so the principal shape change is safe.
  • sub is newly required in shared_secret mode: generate_demo_jwt.py and the conftest fixture already include it, so existing flows do not break.

Nits (optional, non-blocking)

  1. _get_jwks_client builds PyJWKClient with its default timeout=30. If the IdP endpoint hangs rather than refusing connections, every request can tie up a worker for up to 30s before returning 503, which could exhaust workers during an IdP outage. Consider passing a shorter timeout (5-10s).
  2. The cache_keys=True per-kid LRU has no TTL: a resolved key is trusted until process restart or eviction, and the 300s JWKS-set lifespan only applies to unknown kids. Low risk with Entra since kid values are certificate thumbprints and are not reused, but emergency key revocation would require a restart. Worth one line in the docs.
  3. Production startup still requires a strong JWT_SECRET (_resolve_jwt_secret runs regardless of mode) even though oidc mode never uses it for verification. It fails closed with a clear message, but docs/security/authentication.md does not mention this, and an operator who deletes JWT_SECRET after switching to oidc will hit a startup failure. A doc line would prevent the confusion.
  4. .env.example omits OIDC_ALGORITHMS and OIDC_CLOCK_SKEW_SECONDS (both are documented in authentication.md).
  5. Behavior note: shared_secret mode now accepts tokens carrying an aud claim when JWT_AUDIENCE is unset (verify_aud: False), where PyJWT's default previously rejected them. Defensible for this mode since trust anchors on the signature, just a subtle loosening to be aware of.

Solid containment step for #294: the credential is out of the public bundle behind a proven CI guard, tokens are memory-only with a legacy purge, and the OIDC verifier is fail-closed with strong test coverage.

@Vishnu2707
Vishnu2707 merged commit 74e27e4 into OWASP:dev Sep 18, 2026
21 checks passed
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.

6 participants