Step-by-step guide for Okta SSO integration, Generic SSO (Generic_OIDC), and role inheritance from IdP groups for the OSCAL SOA/SSP/CCM Generator. Covers the amsgovcloud.com.au deployment and other environments (e.g. keekar.3utilities.com, local development).
Portability: The Generic SSO patterns documented in section 3 are IdP-agnostic (Authentik is the reference IdP in this repo). You can copy the same frontend/backend flow, security controls, and checklists to other Node/React projects by replacing placeholders (discovery URL, client ID, redirect patterns, group names).
- What You Get
- Best practices adopted in this project
- Generic SSO (
Generic_OIDC) — implementation reference - Application URLs by Environment
- Where to Do What – Quick Map
- Okta Admin Console – Application
- Okta Admin Console – Groups Claim (for role inheritance)
- OSCAL App – SSO Settings
- Groups Claim & Role Mapping
- Checklist & Verification
- Troubleshooting
- Chrome "Dangerous site" / Safe Browsing warning
- References
- SSO (Okta): Users sign in with Okta on production / EC2 deployments (no app password).
- SSO (Generic): Users sign in with "Sign in with Generic SSO" on local laptop and Docker when
Generic_OIDCis enabled (reference IdP: Authentik atsso.keekar.au). - Role inheritance: App role (Platform Admin, Assessor, User) is derived from IdP group membership (Okta or Generic) and synced on every login when configured.
- JIT (optional): New users can be auto-created on first sign-in with a default or group-derived role.
Okta login flow: User clicks "Sign in with Okta" → redirect to Okta → sign-in → redirect back to app with authorization code → app exchanges code for tokens and creates a session.
Generic SSO login flow: User clicks "Sign in with Generic SSO" → backend authorize route → redirect to IdP → sign-in → redirect to /auth/callback → frontend exchanges code server-side → app session created. See End-to-end flow for the full sequence.
This section summarizes the SSO/OIDC patterns and best practices implemented in the OSCAL Report Generator. They align with OWASP, OAuth 2.0 / OIDC standards, and operational security.
| Practice | Implementation |
|---|---|
| Authorization Code flow only | No implicit flow; the app uses response_type=code and exchanges the code for tokens server-side. |
| PKCE (RFC 7636) | Every authorize request sends code_challenge (S256) and code_challenge_method=S256. At token exchange, code_verifier is sent. Required when Okta has "Require PKCE" enabled; harmless otherwise. |
| State parameter (CSRF) | State is signed (HMAC-SHA256 with client secret). Payload includes redirect_uri, code_verifier, and timestamp. Validated at callback; works across server restarts and load-balanced instances without a shared store. |
| Redirect URI binding | The callback uses the redirect_uri from the validated state (or config). Must match exactly what was sent to Okta (no trailing slash, same scheme/host/path). |
| Scopes | Default requested scope is openid profile email. The app does not require a groups scope; Okta can include the groups claim with Include in: Always to avoid scope misconfiguration. |
| Practice | Implementation |
|---|---|
| Groups from multiple sources | Groups are merged from userinfo response, access token JWT, and ID token JWT. Okta often puts groups only in tokens; the backend merges all sources so role resolution works regardless. |
| Highest-privilege role | When multiple Okta groups map to different app roles, the app assigns the highest role (Platform Admin > Assessor > User). |
| Case-insensitive group match | Group names in the mapping are matched case-insensitively against IdP group names. |
| Sync role on every login | Optional "Sync role from Okta groups on every login" updates the user’s app role on each sign-in from current group membership (configurable in Settings). |
- Add mapping: Enter Okta group name and select app role (User, Assessor, Platform Admin); add multiple rows.
- Edit: Change group name or role per row.
- Remove: Per-row "Remove" to delete a mapping.
- Default role for new users: Used when no group matches or groups claim is missing (e.g. User, Assessor, Platform Admin).
- JIT provisioning & role: Toggle "Enable JIT provisioning"; "Default role for new users"; "Sync role from Okta groups on every login."
| Practice | Implementation |
|---|---|
| Optional JIT | Configurable in Settings → SSO Integration. When enabled, users not in the app’s user list are created on first sign-in. |
| Default role | Configurable (User, Assessor, Platform Admin). Used for JIT-created users when no group matches. |
| Group-derived role | JIT-created users get the role resolved from Okta groups (same mapping as existing users). |
| Reactivation | If an existing user is deactivated, successful Okta sign-in (with JIT or pre-existing user) can reactivate the account. |
| Audit | JIT-created users are tagged with createdVia: 'oidc-jit' for traceability. |
| Practice | Implementation |
|---|---|
| Encrypted config (_cfgenc) | Default for local/Docker: saving secrets in the GUI writes { "_cfgenc": "v1$..." } to config (requires OSCAL_CONFIG_FIELD_SECRET or SESSION_SECRET). |
| AWS SM (EC2) | Production uses { "_sm": "..." } pointers; secrets live in the SM bundle. Saves fail if SM is unavailable. |
| Legacy pass (optional) | _pass pointers are migrated to _cfgenc or SM on startup; pass sync scripts are operator-only. |
| API masking | Client secrets are never returned to the client; the Settings API returns masked values or placeholders. |
Release 1.7.20 adds a built-in OIDC provider Generic_OIDC for any standards-compliant IdP. Authentik at sso.keekar.au is the reference deployment in this repo. Use Generic SSO on local laptop and Docker where Okta is not the primary IdP. EC2 (AMS Gov Cloud) continues to use Okta; Generic_OIDC may still appear in config but production sign-in is Okta-first.
This section documents how the "Sign in with Generic SSO" button and integration are implemented so you can reuse the pattern in other projects.
| Context | Primary IdP |
|---|---|
| Local laptop, Docker, homelab | Generic_OIDC (e.g. Authentik at sso.keekar.au) |
| EC2 / AMS Gov Cloud production | Okta (Generic_OIDC optional in config; not primary) |
sequenceDiagram
participant User
participant Login as Login.jsx
participant Backend
participant IdP as GenericIdP
participant Callback as GenericOidcCallback.jsx
participant Auth as AuthContext.jsx
Login->>Backend: GET /api/auth/sso/login-providers
Backend-->>Login: Generic_OIDC + authorizeUrl
User->>Backend: GET /api/auth/oidc/generic-oidc/authorize
Backend->>IdP: 302 with PKCE + signed state
IdP->>Callback: /auth/callback?code&state
Callback->>Backend: GET /api/csrf-token
Callback->>Backend: POST /exchange-token
Backend->>Backend: verify state, token exchange, userinfo, JIT/roles
Callback->>Auth: completeOidcLogin
Auth->>User: redirect to /
Key design choices:
| Choice | Rationale |
|---|---|
Login button is a plain <a href={authorizeUrl}> |
Full browser navigation to the backend authorize route; no SPA fetch on click. |
| Token exchange is server-side | Client secret and PKCE verifier never leave the backend. |
Callback is a frontend route (/auth/callback) |
IdP redirects to the SPA; the callback component POSTs code+state to the backend. |
| Signed state is stateless | HMAC payload embeds redirect_uri and code_verifier; survives server restarts without Redis. |
| Area | Behaviour |
|---|---|
| Upper form | Username/password, Sign in with Okta (when Okta is enabled). |
| Lower panel | Sign in with Generic SSO (orange button) when Generic_OIDC is enabled, followed by access-policy text (15-user cohort, 30-day dormancy). |
| Expedited entry (optional) | Shown only when not on EC2 (showExpeditedAccessPolicy from GET /api/auth/sso/login-providers; false when OSCAL_SECRETS_MODE=aws-sm). |
The application is an OAuth 2.0 / OIDC client only. It does not call IdP Admin APIs to create applications, groups, or claims. All IdP configuration is done in the IdP admin console (or separate authorized automation).
For Authentik (reference):
- Create an OAuth2/OpenID Provider and Application for your app.
- Set Redirect URI to
{origin}/auth/callbackfor each deployment host (must matchcallbackPathandredirectUriPatternsin config). - Note Client ID and Client Secret — enter in
config/app/config.json(or env). - Ensure the
groupsclaim is emitted in userinfo and/or access/ID token JWTs so role mapping works. - Copy the discovery URL (
.well-known/openid-configuration) into config.
Redirect URI example: http://localhost:3021/auth/callback (local), https://oscal.keekar.au/auth/callback (Docker/homelab).
| File | Responsibility |
|---|---|
frontend/src/components/Login.jsx |
On mount, GET /api/auth/sso/login-providers; renders "Sign in with Generic SSO" when a provider with id === 'Generic_OIDC' is returned. |
frontend/src/components/GenericOidcCallback.jsx |
Handles /auth/callback; fetches CSRF token, POSTs { code, state } to exchange-token, calls completeOidcLogin, redirects to /. |
frontend/src/App.jsx |
Routes unauthenticated users on /auth/callback to GenericOidcCallback. |
frontend/src/contexts/AuthContext.jsx |
completeOidcLogin(user, token) stores sessionToken and user in localStorage; subsequent API calls use Bearer auth. |
frontend/src/utils/safeAxios.js |
Shared Axios instance with CRLF header validation for all frontend API calls. |
Button behaviour: The Generic SSO label is hardcoded as "Sign in with Generic SSO" in Login.jsx. The displayName field from config (default "Generic SSO") is shown in Settings → SSO Integration but not on the login button. Okta uses dynamic displayName on its button — porters may wire genericProvider.displayName the same way if desired.
Error handling on login page: when sign-in cannot start, the authorize route redirects back to the login page with the actual reason in the query string, and frontend/src/utils/loginErrors.js builds the message from those facts. Only values that match a strict pattern are used (host name, system error code, HTTP status, setting name, callback address), so nothing an attacker could shape is printed. The error codes are listed under Troubleshooting.
| File | Responsibility |
|---|---|
backend/auth/genericOidc.js |
PKCE (S256), signed state (HMAC-SHA256, 15 min TTL), OIDC discovery, redirect URI allowlist (strict + regex), token exchange, JWT group decode, client secret resolution. |
backend/server.js |
Routes: login-providers, authorize, exchange-token; rate limit on exchange (20 req / 15 min). |
backend/auth/userManager.js |
findOrCreateOidcUser() — JIT provisioning, group→role (highest privilege), session creation. |
backend/utils/defaultOidcGroupRoleMapping.js |
Default group→role mappings merged on config load; user config overrides same keys. |
backend/utils/safeAxios.js |
CRLF-safe outbound HTTP (CWE-113); only allowed direct axios import in backend. |
backend/utils/urlValidator.js |
SSRF-safe URL validation for discovery URL (private IPs allowed for homelab). |
backend/utils/oidcHttpsAgent.js |
Optional TLS-relaxed HTTPS agent when tlsRelaxed is enabled. |
backend/configManager.js |
Loads/merges Generic_OIDC defaults and applies default group mappings. |
Security controls (Generic SSO):
| Control | Implementation |
|---|---|
| Authorization Code + PKCE | S256 code_challenge on authorize; code_verifier in signed state and token POST. |
| Signed state (CSRF) | HMAC with client secret; 15 min TTL; embeds redirect_uri + code_verifier. |
| Redirect URI allowlist | Strict and regex patterns in redirectUriPatterns; validated at authorize and exchange. |
| CSRF | POST /api/auth/oidc/generic-oidc/exchange-token is CSRF-protected; frontend fetches token first. |
| Rate limiting | 20 requests per 15 minutes on exchange-token. |
| Secret handling | _cfgenc / _sm / env; never returned to non-admin API clients. |
| Groups merge | userinfo + access token JWT + ID token JWT (deduped) for role resolution. |
| Outbound HTTP | safeAxios + validateUrl on discovery; no secrets in structured logs. |
| Session | In-memory Bearer token, 24h expiry via validateSession. |
User lifecycle (local Generic SSO):
- Self-registration (
POST /api/auth/self-register) has been removed; use SSO JIT provisioning or admin-created accounts. - Legacy self-registered users (existing rows) are still subject to deactivation after 30 days of inactivity (
userCleanup.js); email blocklist cooldown is 30 days. - Hard delete after deactivation remains 45 days (admin lifecycle in
userManager.js). - JIT-created users are tagged
createdVia: 'oidc-jit'. endSessionUrlis stored in config but not invoked by backend logout (app session only).
| Step | Method | Endpoint | Caller | Notes |
|---|---|---|---|---|
| 1 | GET |
/api/auth/sso/login-providers |
Login.jsx |
Returns { providers: [{ id, displayName, authorizeUrl }], showExpeditedAccessPolicy }. Generic appears when ssoConfig.oauth.enabled, providers.Generic_OIDC.enabled, and client ID + discovery URL + secret are set. |
| 2 | GET |
/api/auth/oidc/generic-oidc/authorize |
Browser (link) | Backend builds PKCE + signed state, fetches discovery, 302 to IdP authorization_endpoint. |
| 3 | — | IdP redirect | Browser | {origin}/auth/callback?code=...&state=... |
| 4 | GET |
/api/csrf-token |
GenericOidcCallback.jsx |
Required before exchange-token POST. |
| 5 | POST |
/api/auth/oidc/generic-oidc/exchange-token |
GenericOidcCallback.jsx |
Body: { code, state }. Returns { success, user, sessionToken }. |
| 6 | GET |
/api/auth/session |
AuthContext.jsx |
Validates stored Bearer token on next app load. |
| Admin | GET / POST |
/api/sso/config |
SSOIntegration.jsx |
Platform Admin save; secrets masked for others. |
| Admin | POST |
/api/sso/test |
SSOIntegration.jsx |
Body: { provider: 'Generic_OIDC' } — discovery + redirect URI probe. |
Provider block: ssoConfig.oauth.providers.Generic_OIDC in config/app/config.json. See config/app/config.json.example for the full default.
Where the identity host is defined (single source). Set OSCAL_GENERIC_SSO_ISSUER (exact issuer, ends with /) and OSCAL_GENERIC_SSO_CLIENT_ID in the backend environment (.env locally, environment: in Docker). They override the saved provider's issuer, discovery and end-session URLs and client ID at runtime, and Guardian's token verification reads the same two variables (JWKS is <issuer>jwks/). Nothing deployment-specific is shipped as a default any more; if the variables are unset the values saved in config.json are used, so existing deployments keep working. The Authentik URL layout (.well-known/openid-configuration, end-session/, jwks/) is assumed; override Guardian's JWKS with OSCAL_GUARDIAN_GENERIC_SSO_JWKS_URL if an IdP differs.
Placeholder table (for other projects):
| Placeholder | OSCAL example |
|---|---|
PROVIDER_ID |
Generic_OIDC |
DISCOVERY_URL |
<issuer>.well-known/openid-configuration (derived from OSCAL_GENERIC_SSO_ISSUER, e.g. https://sso.example.com/application/o/oscal-report-generator/) |
CLIENT_ID |
oscal-report-generator |
CALLBACK_PATH |
/auth/callback |
SCOPE |
openid profile email groups |
REDIRECT_URI_PATTERNS |
Strict URLs + regex per host (localhost, LAN, public domain) |
Generic_OIDC config keys:
| Key | Purpose |
|---|---|
enabled |
Show provider on login page when other prerequisites met. |
displayName |
Label in admin UI (default "Generic SSO"). |
issuerUrl |
IdP issuer (informational). |
discoveryUrl |
.well-known/openid-configuration URL (required). |
clientId |
OAuth client ID (required). |
clientSecret |
Plain string, { "_cfgenc": "v1$..." }, or { "_sm": "..." }. |
callbackPath |
Frontend path segment (default /auth/callback). |
scope |
Space-separated scopes (default openid profile email; example adds groups). |
redirectUriPatterns |
`[{ matchingMode: "strict" |
tlsRelaxed |
true to skip strict TLS verify for IdP outbound calls (homelab only). |
endSessionUrl |
Stored; not used in backend logout today. |
Shared OAuth keys (apply to both Okta and Generic_OIDC):
| Key | Purpose |
|---|---|
ssoConfig.oauth.enabled |
Master OAuth toggle. |
jitProvisioning |
Auto-create users on first SSO login. |
jitDefaultRole |
Role when no group matches. |
syncRoleFromGroups |
Update role on every login (default true). |
groupToRoleMapping |
IdP group name → app role object. |
Environment variables:
| Variable | Purpose |
|---|---|
GENERIC_OIDC_CLIENT_SECRET / OSCAL_GENERIC_OIDC_CLIENT_SECRET |
Client secret override. |
OSCAL_GENERIC_OIDC_TLS_RELAXED / OSCAL_OIDC_TLS_RELAXED |
1/true → TLS-relaxed IdP calls. |
OSCAL_CONFIG_FIELD_SECRET / SESSION_SECRET |
Decrypt _cfgenc secrets. |
| AWS SM | OSCAL/sso-oauth-generic-oidc-client-secret (via sensitiveConfigKeys.js). |
| File | Behaviour |
|---|---|
frontend/src/components/SettingsWithTabs.jsx |
Platform Settings → tab SSO Integration. |
frontend/src/components/SSOIntegration.jsx |
Generic OIDC card: Platform Admin can enable/disable; issuer, discovery URL, client ID, callback path are read-only (deployment config). Test button calls POST /api/sso/test. |
Shared OAuth settings (under Okta section in UI, used by both providers on backend):
- JIT provisioning toggle, default role, sync role from groups.
- Group → role mapping table (
groupToRoleMapping).
Some IdPs (including Authentik on sso.keekar.au with certain Let's Encrypt chains) serve a certificate chain that browsers and curl accept but Node.js 20 / OpenSSL in Alpine rejects with UNABLE_TO_GET_ISSUER_CERT_LOCALLY during OIDC discovery.
| Approach | When to use |
|---|---|
| Fix IdP chain (preferred) | Serve the full chain to ISRG Root X1 on the reverse proxy; then leave tlsRelaxed: false. |
tlsRelaxed: true |
Set on ssoConfig.oauth.providers.Generic_OIDC in config.json (Docker/NAS bind-mount) when you cannot change the IdP chain immediately. Discovery/token/userinfo calls skip strict TLS verification for that provider only. |
OSCAL_GENERIC_OIDC_TLS_RELAXED=1 |
Container/env override when config is not writable. |
SSO Integration → Test connection surfaces whether tlsRelaxed is active. Do not enable on EC2 production unless Adobe security approves; EC2 production sign-in remains Okta.
Use this when adding Generic SSO to a new Node/React app, using OSCAL as the reference implementation:
- IdP (admin console): Register OAuth/OIDC client; set redirect URI
{origin}/auth/callback; configuregroupsclaim in userinfo and/or tokens. - Backend — authorize route:
GET .../authorize— generate PKCE verifier/challenge, create signed state (HMAC + TTL), resolve redirect URI from request headers, validate against allowlist, fetch discovery, 302 toauthorization_endpoint. - Backend — exchange route:
POST .../exchange-token— verify signed state, re-validate redirect URI, exchange code + verifier at token endpoint, fetch userinfo, merge groups from userinfo + JWTs, create app session (JIT optional). - Backend — login providers API:
GET .../login-providers— return enabled providers withauthorizeUrlonly when fully configured. - Frontend — login page: Fetch providers; render link/button when Generic provider present.
- Frontend — callback page: Parse
code+statefrom URL; CSRF + POST to exchange; store session; redirect home. - Config: Provider block with discovery URL, client credentials, redirect allowlist (strict + regex for dev hosts).
- RBAC: Shared
groupToRoleMapping, highest-privilege wins, optional JIT and role sync on login. - Security: CSRF on state-changing exchange, rate limits,
safeAxios/validated URLs, encrypted secrets, no secrets in logs. - Tests: Unit tests for PKCE, signed state, redirect allowlist, group merge (see
test_cases/backend/unit/genericOidc.test.js,defaultOidcGroupRoleMapping.test.js).
Reference implementation files:
| Layer | Primary files |
|---|---|
| Backend OIDC helpers | backend/auth/genericOidc.js |
| Backend routes | backend/server.js (~lines 1434–1672) |
| User/session/JIT | backend/auth/userManager.js |
| Frontend login | frontend/src/components/Login.jsx |
| Frontend callback | frontend/src/components/GenericOidcCallback.jsx |
| Admin UI | frontend/src/components/SSOIntegration.jsx |
| Example config | config/app/config.json.example |
Config / IdP
- OAuth client created at IdP with redirect URI
{base}/auth/callback. -
discoveryUrl,clientId, andclientSecretset in config (or env). -
redirectUriPatternsincludes your deployment host (strict or regex). -
groupsclaim configured at IdP for role mapping.
App
-
ssoConfig.oauth.enabledandproviders.Generic_OIDC.enabledaretrue. -
GET /api/auth/sso/login-providersreturns a provider withid: "Generic_OIDC". - Sign in with Generic SSO button visible on login page.
- Group → role mapping and JIT/sync settings configured in Settings → SSO Integration.
- SSO Integration → Test connection passes for
Generic_OIDC.
Test
- Open a private/incognito window and go to your app URL.
- Click Sign in with Generic SSO.
- Sign in at the IdP; you should land on
/with an active session. - Confirm role matches IdP group mapping (highest privilege if multiple groups match).
See docs/CHANGELOG.md for release notes (1.7.20 Generic SSO, 1.7.21 TLS).
Use the exact values below when configuring your IdP (Okta or Generic OIDC). Redirect URIs must match exactly in both the IdP and app config (no trailing slash mismatch, same scheme/host/path).
| Environment | Base URL | Initiate login URI | Callback URI (Sign-in redirect) | Logout redirect |
|---|---|---|---|---|
| AMS Gov Cloud | https://oscal.amsgovcloud.com.au/ | https://oscal.amsgovcloud.com.au/api/auth/okta/authorize |
https://oscal.amsgovcloud.com.au/auth/okta/callback |
https://oscal.amsgovcloud.com.au/ |
| Production (Keekar) | https://keekar.3utilities.com/ | https://keekar.3utilities.com/api/auth/okta/authorize |
https://keekar.3utilities.com/auth/okta/callback |
https://keekar.3utilities.com/ |
| Local development | http://localhost:3021/ | (app-derived) | http://localhost:3021/auth/okta/callback |
http://localhost:3021/ |
| Environment | Base URL | Authorize (app starts login) | Callback URI (IdP redirect) |
|---|---|---|---|
| Local development | http://localhost:3021/ | http://localhost:3021/api/auth/oidc/generic-oidc/authorize |
http://localhost:3021/auth/callback |
| Docker / homelab | https://oscal.keekar.au/ | https://oscal.keekar.au/api/auth/oidc/generic-oidc/authorize |
https://oscal.keekar.au/auth/callback |
| AMS Gov Cloud (if enabled) | https://oscal.amsgovcloud.com.au/ | https://oscal.amsgovcloud.com.au/api/auth/oidc/generic-oidc/authorize |
https://oscal.amsgovcloud.com.au/auth/callback |
Generic SSO does not use a separate "initiate login URI" at the IdP — the user clicks the app login link, which hits the backend authorize route. Register only the callback URI at the IdP.
| # | Where | What |
|---|---|---|
| 1 | Okta Admin → Applications | Create/configure OIDC app; set Initiate login URI and Callback URI for your deployment (e.g. oscal.amsgovcloud.com.au). |
| 2 | Okta Admin → Security → API (Authorization Server) | Add groups claim so tokens contain group membership (required for role inheritance). |
| 3 | OSCAL App → Settings → SSO Integration | Enable OAuth + Okta; set Domain, Client ID, Client Secret, Redirect URI; configure group → role mapping and "Sync role from Okta groups". |
| 4 | Browser | Sign in with Okta and confirm role in app. |
| # | Where | What |
|---|---|---|
| 1 | IdP Admin (e.g. Authentik) | Create OAuth/OIDC application; set Redirect URI to {base}/auth/callback; configure groups claim. |
| 2 | config/app/config.json |
Set Generic_OIDC: discoveryUrl, clientId, clientSecret, redirectUriPatterns; enable ssoConfig.oauth.enabled. |
| 3 | OSCAL App → Settings → SSO Integration | Enable Generic OIDC toggle; configure shared group → role mapping and JIT; run Test connection. |
| 4 | Browser | Click Sign in with Generic SSO and confirm role in app. |
See Generic SSO verification checklist for the full test list.
Where: Okta Admin Console (e.g. your-org.okta.com or your-org.oktapreview.com).
-
Go to Applications → Applications → Create App Integration.
-
Select OIDC - OpenID Connect and Web Application (or Single-Page Application). Click Next.
-
Configure (use values from Application URLs by Environment for your deployment):
Field Example for oscal.amsgovcloud.com.au App name e.g. OSCAL Report Generator (amsgovcloud)Grant type ✅ Authorization Code Initiate login URI https://oscal.amsgovcloud.com.au/api/auth/okta/authorizeCallback URI (Sign-in redirect URIs) https://oscal.amsgovcloud.com.au/auth/okta/callbackLogout redirect URIs https://oscal.amsgovcloud.com.au/ -
Controlled access: Assign to the right groups or "Everyone". Click Save.
-
Note Client ID and Client Secret — you will enter these in the OSCAL app.
To get role inheritance from Okta groups, the authorization server must include a groups claim in the access token (or ID token).
Where: Okta Admin → Security → API → Authorization Servers.
- Use an existing Custom Authorization Server or Add Authorization Server. Note its Issuer or ID (e.g.
default) — use this as Authorization Server ID in the OSCAL app. - Open that Authorization Server → Claims → Add Claim:
- Name:
groups - Include in token type: Access Token (and ID token if desired).
- Value type: Groups.
- Filter: e.g.
.*for all groups, or^OSCAL-for specific groups.
- Name:
- Set Include in to Always so the claim is in the token without requiring a separate
groupsscope (avoids "One or more scopes are not configured"). - Ensure your Application (from the previous section) uses this Authorization Server.
- With the default org server, you can often add a groups claim to the ID token only. The OSCAL app reads groups from both access and ID tokens, so ID token groups will work.
- The app does not request a
groupsscope by default. In Okta, set thegroupsclaim to Always include in the token (Claims → groups → Include in: Always). Then you do not need agroupsscope.
Where: Your app URL (e.g. https://oscal.amsgovcloud.com.au/ or https://keekar.3utilities.com/) → log in (e.g. as Platform Admin) → Settings (gear) → SSO Integration.
-
OAuth 2.0 / OpenID Connect: Enable OAuth and Okta.
-
Fill in:
Field Value Okta Domain Your Okta host, e.g. your-org.okta.com(nohttps://, no path, no trailing slash)Authorization Server ID The ID from the Groups Claim step (e.g. default), or leave blank if using org serverClient ID From Okta app Client Secret From Okta app Redirect URI Must match Okta exactly, e.g. https://oscal.amsgovcloud.com.au/auth/okta/callbackorhttps://keekar.3utilities.com/auth/okta/callback -
Redirect URI must be exactly what you added in Okta (same scheme, host, path). If the app derives it from the current origin, ensure you are on the correct base URL when saving.
-
(Optional) Scope: e.g.
openid profile emailoropenid profile email groupsif you added a groups scope. -
Click Save Settings.
Shared by Okta and Generic_OIDC: groupToRoleMapping, jitProvisioning, jitDefaultRole, and syncRoleFromGroups live under ssoConfig.oauth and apply to both OAuth providers. The backend merges groups from userinfo + access token + ID token for either IdP.
-
Sync role from Okta groups on every login: leave on for role inheritance (applies to Generic SSO groups as well).
-
IdP group → app role mapping: Add rows, e.g.:
Okta group name App role OSCAL-AdminsPlatform Admin OSCAL-AssessorsAssessor OSCAL-UsersUser Use the exact IdP group names (case-insensitive match in app).
-
Default role for new users: e.g. User (used when no group matches or groups claim is missing).
-
JIT provisioning: Enable if you want users not in the app’s user list to be created on first sign-in.
The app reads groups from the userinfo response and from the access token and ID token, then resolves the highest matching role. Default mappings from backend/utils/defaultOidcGroupRoleMapping.js are merged on config load; explicit config entries override the same group key.
- Application created (OIDC, Web or SPA).
- Sign-in redirect URI matches your deployment (e.g.
https://oscal.amsgovcloud.com.au/auth/okta/callback). - Client ID and Client Secret copied.
- Authorization Server has
groupsclaim with Include in: Always (or scope granted if using a groups scope).
- Settings → SSO Integration: OAuth and Okta enabled.
- Okta Domain, Authorization Server ID, Client ID, Client Secret set.
- Redirect URI matches Okta exactly for your environment.
- Group → role mapping and "Sync role from Okta groups" configured.
- Settings saved.
- Open a private/incognito window and go to your app URL.
- Click Sign in with Okta.
- Sign in at Okta; you should be redirected back and signed in.
- Confirm your role in the app (e.g. in the header or Settings). It should match the role for the Okta group(s) you belong to. If you are in multiple mapped groups, the app assigns the highest role (Platform Admin > Assessor > User).
| Issue | What to check |
|---|---|
| "One or more scopes are not configured for the authorization server resource" | Set the groups claim in Okta to Always include in the token (no groups scope). The app only requests openid profile email by default. |
| "Okta sign-in is not configured" | Enable OAuth and Okta in Settings → SSO Integration; ensure Client ID and Domain are set. |
| "Redirect URI mismatch" | Redirect URI in Okta and in the app must match exactly (e.g. https://oscal.amsgovcloud.com.au/auth/okta/callback — no trailing slash, correct scheme and host). |
| "Invalid or expired state" | Complete sign-in within a few minutes; avoid refreshing the Okta callback URL; try "Sign in with Okta" again from the login page. |
| "Code may be expired" | Complete the Okta login and return to the app within about a minute. |
| "The client secret supplied for a confidential client is invalid" | Okta is rejecting the client secret. In Okta Admin: (1) Confirm Client ID matches. (2) Client secret must match exactly — copy from Okta or regenerate and update app config / OSCAL_OKTA_CLIENT_SECRET on the server. (3) Sign-in redirect URIs must include your callback (e.g. https://oscal.amsgovcloud.com.au/auth/okta/callback). On EC2, set secret via env OSCAL_OKTA_CLIENT_SECRET or ensure config has the correct secret. |
| Role not updating from groups | Okta: Authorization Server has groups claim; set Include in Always or ensure scope is granted. OSCAL: "Sync role from Okta groups" on; group → role mapping has correct Okta group names. |
| User not found / 403 | Enable JIT provisioning in Settings → SSO, or add the user's email to Users (Platform Admin). |
| Chrome "Dangerous site" / Safe Browsing warning | See Chrome Safe Browsing warning below. |
| Issue | What to check |
|---|---|
"Could not reach the SSO server <host>: … (<system error>)" (?error=generic_oidc_unreachable&host=…&detail=…) |
A reachability problem, not configuration: the OSCAL server could not connect to the identity provider. detail is the real system error: EHOSTUNREACH (no route to the host), ECONNREFUSED (refused), ENOTFOUND (name not resolved), ETIMEDOUT (timed out), ENETUNREACH. Check the OSCAL server's network, VPN, DNS (including split-horizon DNS that returns a LAN address) and any firewall or endpoint-security rule for the server process; test with node from the same machine, not only curl or a browser. |
"The certificate of the SSO server … could not be verified" (?error=generic_oidc_tls) |
The IdP's certificate chain is incomplete, expired or for another name. See UNABLE_TO_GET_ISSUER_CERT_LOCALLY below. |
"The SSO server … answered with HTTP <status>" (?error=generic_oidc_discovery_failed) |
The server was reached but did not return its sign-in configuration. Check the discovery URL path and that the application exists at the IdP. |
"… responded, but its configuration has no authorization endpoint" (?error=generic_oidc_discovery_invalid) |
The discovery URL returns something that is not OpenID Connect metadata. |
"Generic SSO is not configured: <what is missing>" (?error=generic_oidc_not_configured&missing=…) |
missing names the one setting at fault: oauth-disabled (ssoConfig.oauth.enabled), provider-missing, provider-disabled (providers.Generic_OIDC.enabled), client-id, discovery-url or client-secret (not set or could not be decrypted or read). |
"The sign-in callback address <uri> is not in the allowed redirect list" (?error=generic_oidc_redirect_not_allowed&redirect=…) |
The callback address derived from the browser's host is not in redirectUriPatterns. Add it, or sign in from an allowed address. |
| Button not shown on login page | GET /api/auth/sso/login-providers must return Generic_OIDC. Check enabled flags and non-empty client ID, discovery URL, secret. |
| Redirect URI not allowed | Add strict or regex pattern for {scheme}://{host}{port}/auth/callback in redirectUriPatterns. Authorize and exchange both validate the URI. |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY (discovery/token) |
IdP TLS chain incomplete for Node/OpenSSL. Fix chain on reverse proxy, or set tlsRelaxed: true / OSCAL_GENERIC_OIDC_TLS_RELAXED=1 (homelab only). See TLS and tlsRelaxed. |
| "Invalid or expired state" | Complete sign-in within 15 minutes; do not refresh /auth/callback; restart login from the button. Server restart is OK (state is signed, not server-stored). |
| "Code may be expired" / authorization code expired | Complete IdP login and return within ~1 minute. |
Invalid client / invalid_client at token endpoint |
Confirm Client ID and Client secret match IdP; try env GENERIC_OIDC_CLIENT_SECRET or OSCAL_GENERIC_OIDC_CLIENT_SECRET. Backend retries token POST with HTTP Basic if first attempt fails. |
| Role not updating from groups | IdP must emit groups in userinfo and/or JWTs. Enable "Sync role from groups"; verify groupToRoleMapping uses correct group names (case-insensitive). |
| User not found / 403 | Enable JIT provisioning, or add user in Users (Platform Admin). |
| SSO Integration → Test fails | Check discovery URL reachable from server, redirect URI probe, TLS settings. Test uses same genericOidc.js helpers as live login. |
Okta:
- With _cfgenc (local/Docker): Saving the Okta client secret in the GUI encrypts it into config (
_cfgencenvelope). RequiresOSCAL_CONFIG_FIELD_SECRETorSESSION_SECRET. - With AWS SM (EC2): Saving stores the secret in the SM bundle; config holds
{ "_sm": "OSCAL/sso-oauth-okta-client-secret" }only. - Env override: Setting
OSCAL_OKTA_CLIENT_SECRETon the server overrides the config value (e.g. systemdEnvironment=OSCAL_OKTA_CLIENT_SECRET=...). - Okta domain: You can enter the domain with or without
https://in the GUI; the app normalizes it to hostname only when saving.
Generic SSO:
- With _cfgenc (local/Docker):
clientSecretas{ "_cfgenc": "v1$..." }in config; decrypt withOSCAL_CONFIG_FIELD_SECRETorSESSION_SECRET. - With AWS SM (EC2):
{ "_sm": "OSCAL/sso-oauth-generic-oidc-client-secret" }. - Env override:
GENERIC_OIDC_CLIENT_SECRETorOSCAL_GENERIC_OIDC_CLIENT_SECRET. - Encrypt helper:
node backend/scripts/encrypt-generic-oidc-secret.mjs(operators).
When you click Sign in with Okta, Chrome may show a red "Dangerous site" or "Deceptive site ahead" page. This is Google Safe Browsing flagging the domain (often the app URL or the page Okta redirects back to). It can be a false positive, especially for personal or small domains.
What to do:
- Confirm which URL is flagged — Note whether the warning appears when you open the app, when Okta redirects you back (callback URL), or on the Okta domain.
- Request a review from Google (if you own the site) — On the Chrome warning page, use "Details" then "Report that this site doesn't pose a danger". Or use Google Safe Browsing – Request a review. Reviews can take hours to a few days.
- Temporary workaround (only if you fully trust the site) — On the warning page, click "Details", then "Visit this unsafe site". Use only on a trusted network and only for a site you control.
- Use another browser or device — Try signing in via Okta in Firefox or Safari; they may not show the warning.
- Verify your site is not compromised — Ensure server and app are up to date, no injected content or malware, and HTTPS is valid.
For users (not site owners): Ask the site owner to request a Safe Browsing review. Do not bypass the warning unless your organization has confirmed the site is safe.
Any backend OIDC-related HTTP client code that uses Axios must import backend/utils/safeAxios.js (not the axios package directly) so merged outbound headers are validated for CR/LF (CWE-113). Do not log full authorization headers or client secrets in structured logs. See docs/BEST_PRACTICES.md (Outbound HTTP (Axios)) and .cursor/rules/security-standards.mdc.
On local/Docker (OSCAL_SECRETS_MODE=config), OAuth client secrets and other sensitive config values are stored as _cfgenc envelopes in config.json (same PBKDF2 + AES-256-GCM stack as user password hashing). Set OSCAL_CONFIG_FIELD_SECRET or SESSION_SECRET.
- Migrate legacy plaintext/_pass:
node backend/scripts/migrate-config-to-cfgenc.mjs(runs automatically in Docker entrypoint) - Laptop pass vault: Optional for operators; pass is not required for app runtime (EC2 reads secrets from AWS Secrets Manager directly)
- EC2 production: AWS SM only; startup auto-migrates plaintext/
_cfgenc/_passto SM
See DEPLOYMENT_AND_OPERATIONS.md and DEPLOYMENT_AND_OPERATIONS.md.
- Okta Admin Console
- Okta OIDC and OAuth 2.0
- Authentik OAuth2/OpenID Provider
- OAuth 2.0 Authorization Code + PKCE (RFC 7636)
- OSCAL reference implementation:
backend/auth/genericOidc.js,frontend/src/components/Login.jsx,frontend/src/components/GenericOidcCallback.jsx,config/app/config.json.example - AMS Gov Cloud app: https://oscal.amsgovcloud.com.au/
- Production (Keekar): https://keekar.3utilities.com/
- Homelab IdP (reference): https://sso.keekar.au/
Version: 1.9.0 · Last updated: July 2026