This page covers how the OpenShield API authenticates callers, how to set up an
enterprise identity provider, and what to do if a bearer credential or
JWT_SECRET may have been exposed. It tracks issue #294.
The dashboard used to copy a pre-signed JWT from the VITE_JWT_TOKEN build
variable into localStorage, falling back to a dev-local-token placeholder.
Anything in a VITE_* variable is compiled into the public JavaScript bundle,
so that token was readable by anyone who loaded the site, and localStorage
kept it available to any script on the page.
Current behavior:
- The frontend never reads a build-time token and never persists one. Tokens
are held in memory for the life of the page (
api.setToken), and a legacyjwt_tokenentry inlocalStorageis deleted on load without being used. - CI builds the dashboard with a canary
VITE_JWT_TOKENand fails if any JWT-shaped value, the canary, ordev-local-tokenappears infrontend/dist. - The API supports an
oidcmode that trusts only an enterprise identity provider's signing keys and app-role assignments.
shared_secret (default) |
oidc |
|
|---|---|---|
| Who can mint a token | Anyone holding JWT_SECRET |
Only the identity provider |
| Role source | role claim chosen by whoever signs |
App roles assigned in the IdP |
| Tenant check | None | tid must be in OIDC_ALLOWED_TENANTS (when set) |
| Revocation | Rotate JWT_SECRET (invalidates every token) |
Remove the role assignment or disable the user; tokens expire on the IdP's lifetime |
| Use for | Local development, CI smoke tests | Any deployment holding real scan data |
The API logs a startup warning when shared_secret mode runs in production.
-
Register the API. In Entra ID, create an app registration for the OpenShield API. Under Expose an API, set the Application ID URI (for example
api://openshield). -
Define app roles on that registration, allowed for users/groups (and applications, if automation needs them):
Value Grants OpenShield.ViewerRead-only API access OpenShield.OperatorRead plus scan trigger and AI endpoints OpenShield.AdminEverything an operator can do -
Require assignment. In the enterprise application, enable Assignment required and assign users or groups to the roles. Identities without a role receive
403. -
Set the tokens to v2 (
accessTokenAcceptedVersion: 2in the manifest) so the issuer below matches. -
Configure the API:
OPENSHIELD_AUTH_MODE=oidc OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0 OIDC_AUDIENCE=<application-client-id> # the aud claim in issued access tokens OIDC_JWKS_URL=https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys OIDC_ALLOWED_TENANTS=<tenant-id>
Optional:
OIDC_ROLE_CLAIM(defaultroles),OIDC_ROLE_MAP(<claim value>=viewer|operator|admin, comma-separated),OIDC_ALGORITHMS(asymmetric only, defaultRS256), andOIDC_CLOCK_SKEW_SECONDS(0–300, default 60).
The API refuses to start if oidc mode is missing the issuer, audience or JWKS
URL, if the JWKS URL is not HTTPS, or if a symmetric algorithm is configured.
Signing keys are cached for five minutes, so IdP key rotation is picked up
automatically. If the JWKS endpoint is unreachable, requests fail closed with
503.
A browser sign-in flow (Authorization Code with PKCE) for the dashboard is
tracked separately under #294. Until it lands, the dashboard sends no token:
reads work only against an API running with OPENSHIELD_PUBLIC_DEMO=true and
non-sensitive data, and writes require calling the API with an IdP-issued
token.
Work through these in order and record each step, with times and the person who performed it, on a private security advisory or incident issue.
-
Stop further exposure. Remove the value from wherever it leaked (frontend build variables, CI variables, logs, tickets). For a
VITE_JWT_TOKEN, delete the variable in the hosting provider and redeploy the frontend from a commit that includes this change. -
Suspend the API if data may be at risk. Scale the API service to zero or block public ingress until the remaining steps are complete.
-
Rotate
JWT_SECRET. Generate a new value and set it in the API environment:python -c "import secrets; print(secrets.token_urlsafe(32))"Restarting with the new secret invalidates every token signed with the old one. Update any CI secret used by smoke tests at the same time.
-
Prefer
oidcmode for the restored deployment so no long-lived shared signing secret authorizes access. -
Restrict scope. Set
OPENSHIELD_AUTHORIZED_SUBSCRIPTIONSto the subscriptions this deployment may scan, and confirmOPENSHIELD_PUBLIC_DEMOis unset for any deployment with real data. -
Review access. Pull API request logs for the exposure window and look for write requests (
POST /api/scans/trigger,/api/ai/*), requests for unexpected subscription IDs, and unfamiliar source addresses. Each log line carries a request ID for correlation. -
Verify before restoring. Confirm that:
- a request with the old token returns
401; grep -rE 'eyJ[A-Za-z0-9_-]{8,}\.eyJ' frontend/distfinds nothing in the deployed build;- a
vieweridentity receives403onPOST /api/scans/trigger; - in
oidcmode, a token for another tenant or audience returns401.
- a request with the old token returns
-
Restore and record. Re-enable the API, then close the incident with the evidence from step 7.
Rotate at least when a maintainer with access leaves, whenever a leak is suspected, and before re-enabling a suspended deployment. Rotation has no overlap window: tokens signed with the old secret stop working as soon as the API restarts, so schedule it with any smoke-test or automation owners.
- Dashboard sign-in with Authorization Code and PKCE.
- Persisted organization/tenant ownership for scans, findings, resources, drift and AI data, with tenant context required in every repository query, and an evaluation of PostgreSQL row-level security.
- Cross-tenant integration tests across scans, findings, compliance, resources, drift, AI and enrichment routes.