24 KiB
OpenID Connect (OIDC) SSO Authentication in ntopng
This document describes the design, implementation, and configuration of the OpenID Connect Single Sign-On support in ntopng.
Table of Contents
- Overview
- Authentication Flow
- Configuration
- Implementation Details
- Login Page Integration
- REST API
- Preferences UI
- Dependencies
- Security Considerations
- Testing with a Local IdP
- Extending the Implementation
Overview
The OIDC feature adds support for identity providers (IdPs) that speak the OpenID Connect 1.0 protocol — such as Keycloak, Okta, Auth0, Azure AD / Entra ID, Google, and any other standards-compliant IdP.
The implementation uses the Authorization Code Flow, which keeps tokens off the browser and is the recommended flow for server-side web applications.
No external OIDC/OAuth2 library is required. Everything is implemented on top of libraries that are basic ntopng dependencies:
| Library | Used for |
|---|---|
| libcurl | HTTP requests to IdP (discovery, token exchange, JWKS) |
| OpenSSL (libssl + libcrypto) | JWT signature verification (RS256/RS384/RS512, ES256/ES384/ES512) |
json-c (bundled in third-party/json-c/) |
JSON parsing |
Authentication Flow
Browser ntopng Identity Provider (IdP)
│ │ │
│ GET /lua/login.lua │ │
│──────────────────────────>│ │
│ (shows login page with │ │
│ "Login with SSO" btn) │ │
│ │ │
│ GET /oidc_start │ │
│──────────────────────────>│ │
│ │ generate state, nonce │
│ │ store in Redis (10 min TTL) │
│ │ fetch discovery doc (cached) │
│ 302 → IdP auth URL │ │
│<──────────────────────────│ │
│ │ │
│ GET /authorize?... │ │
│──────────────────────────────────────────────────────────────>│
│ (user authenticates at IdP — may include MFA) │
│<──────────────────────────────────────────────────────────────│
│ 302 → /oidc_callback?code=…&state=… │
│ │ │
│ GET /oidc_callback │ │
│──────────────────────────>│ │
│ │ validate state (Redis lookup) │
│ │ POST token endpoint │
│ │──────────────────────────────────>│
│ │ { id_token, access_token, … } │
│ │<──────────────────────────────────│
│ │ fetch JWKS (cached) │
│ │ validate id_token JWT: │
│ │ • signature (RS256/ES256) │
│ │ • iss, aud, exp, nonce │
│ │ derive username from claims │
│ │ auto-create user if configured │
│ │ create session cookie │
│ 302 → original referer │ │
│<──────────────────────────│ │
Configuration
All OIDC settings are stored in Redis under the ntopng.prefs.oidc.* namespace.
They can be set through:
- The ntopng Preferences → Authentication tab (web UI)
- The REST API endpoints documented in the REST API section
- Directly via
redis-clifor scripted deployments
Configuration Keys
| Redis key | Type | Default | Description |
|---|---|---|---|
ntopng.prefs.oidc.enabled |
"0" / "1" |
"0" |
Enable OIDC authentication |
ntopng.prefs.oidc.client_id |
string | — | OAuth2 client ID registered at the IdP |
ntopng.prefs.oidc.client_secret |
string | — | OAuth2 client secret |
ntopng.prefs.oidc.issuer_url |
URL | — | IdP issuer URL (OIDC discovery appends /.well-known/openid-configuration) |
ntopng.prefs.oidc.base_redirect_uri |
URL | — | ntopng's publicly reachable base URL; the callback will be {base}/oidc_callback |
ntopng.prefs.oidc.scopes |
string | "openid profile email roles" |
Space-separated OIDC scopes to request |
ntopng.prefs.oidc.group_claim |
string | "groups" |
JWT claim that carries group membership |
ntopng.prefs.oidc.admin_group |
string | — | Value in the group claim that grants ntopng admin role; empty → all users are unprivileged |
ntopng.prefs.oidc.auto_create_users |
"0" / "1" |
"0" |
Automatically create an ntopng user on first OIDC login |
Minimal Example (via redis-cli)
redis-cli set ntopng.prefs.oidc.enabled "1"
redis-cli set ntopng.prefs.oidc.client_id "ntopng"
redis-cli set ntopng.prefs.oidc.client_secret "s3cr3t"
redis-cli set ntopng.prefs.oidc.issuer_url "https://keycloak.example.com/realms/myrealm"
redis-cli set ntopng.prefs.oidc.base_redirect_uri "https://ntopng.example.com"
redis-cli set ntopng.prefs.oidc.admin_group "ntopng-admins"
redis-cli set ntopng.prefs.oidc.auto_create_users "1"
The IdP must have https://ntopng.example.com/oidc_callback registered as an
allowed redirect URI.
Implementation Details
Key Files
| File | Role |
|---|---|
include/OIDCAuthenticator.h |
Class declaration |
src/OIDCAuthenticator.cpp |
Full implementation |
include/ntop_defines.h |
URL and Redis key constants (OIDC_*, PREF_OIDC_*) |
include/Ntop.h |
OIDCAuthenticator* oidcAuth member + getOIDCAuthenticator() getter |
include/ntop_includes.h |
#include "OIDCAuthenticator.h" entry |
src/Ntop.cpp |
Instantiation in constructor, deletion in destructor |
src/HTTPserver.cpp |
oidc_start() / oidc_callback() handler functions; URL whitelist; route dispatch |
scripts/lua/login.lua |
"Login with SSO" button (conditional on oidc.enabled) |
scripts/lua/admin/prefs.lua |
POST handler to save OIDC preferences from the web UI |
scripts/lua/rest/v2/get/ntopng/oidc_config.lua |
REST GET endpoint |
scripts/lua/rest/v2/set/ntopng/oidc_config.lua |
REST POST/SET endpoint |
scripts/locales/en.lua |
login.sso_login and login.oidc-error i18n strings |
C++ Class: OIDCAuthenticator
The class lives in include/OIDCAuthenticator.h / src/OIDCAuthenticator.cpp
and is owned by the Ntop singleton as oidcAuth (always instantiated, never
guarded by a compile-time #ifdef).
Ntop
└── OIDCAuthenticator* oidcAuth
├── startAuthFlow(referer) → authorization URL (string)
└── handleCallback(code, state, ...) → bool + username/group/referer
Key Methods
// Returns true when ntopng.prefs.oidc.enabled == "1"
bool isEnabled() const;
// Generates state+nonce, stores them in Redis, and returns the full IdP
// authorization URL to redirect the browser to.
std::string startAuthFlow(const char *referer);
// Validates state, exchanges code for tokens, validates JWT, maps to a
// ntopng user. Returns true and populates username/group/referer on success.
bool handleCallback(const char *code, const char *state,
std::string &username, std::string &group,
std::string &referer_out);
Internal Structure
startAuthFlow()
└── loadConfig() reads all prefs from Redis
└── ensureEndpointsLoaded() fetches .well-known/openid-configuration (1h cache)
└── generateRandom() crypto-random state + nonce (OpenSSL RAND_bytes)
└── storeState() Redis key "oidc.state.<state>" with 10-min TTL
└── builds authorization URL
handleCallback()
└── popState() validates + atomically deletes state from Redis
└── loadConfig()
└── ensureEndpointsLoaded()
└── curlPostForm() POST to token_endpoint (form-encoded)
└── getJWKS() fetches jwks_uri (1h cache)
└── parseAndValidateJWT()
└── base64urlDecode()
└── verifySignature()
└── buildRSAKeyFromJWK() or buildECKeyFromJWK()
└── EVP_DigestVerify*()
└── validates iss, aud, exp, nonce
└── extracts preferred_username / email / sub
└── checks group_claim for admin_group membership
└── username sanitization
└── auto-create user via ntop->addUser() if configured
Thread Safety
OIDCAuthenticator is shared across all Mongoose worker threads.
The Endpoints discovery cache and the JWKS cache are protected by
pthread_mutex_t mutex_. Configuration reads go directly to Redis on each
request (same pattern used by RADIUS and HTTP auth).
HTTP Endpoints
Two new endpoints are registered in src/HTTPserver.cpp:
GET /oidc_start
Query parameters:
referer— URL to redirect to after successful login (must start with/)
Behaviour:
- Checks
OIDCAuthenticator::isEnabled(). - Calls
startAuthFlow(referer)to get the IdP authorization URL. - Responds with
HTTP 302 → <authorization_url>.
On error: redirects to /lua/login.lua?reason=oidc-error.
GET /oidc_callback
Query parameters (set by IdP):
code— authorization codestate— echoed state valueerror— present when the IdP rejects the request (e.g.access_denied)
Behaviour:
- If
erroris present, redirects to login withreason=oidc-error. - Calls
handleCallback(code, state, ...). - On success: calls
set_session_cookie()and redirects to the stored referer. - On failure: redirects to login with
reason=oidc-error.
Both endpoints are added to isWhitelistedURI() so they are reachable without
an existing session.
JWT Validation
The id_token is a JSON Web Token (JWT) with three base64url-encoded parts:
<header>.<payload>.<signature>.
Validation steps in parseAndValidateJWT():
- Split on
.→ header, payload, signature. - Decode header → extract
alg(required) andkid(optional). - Algorithm check — only
RS256/RS384/RS512andES256/ES384/ES512are accepted.noneand symmetric algorithms are rejected. - Signature verification via
verifySignature():- Parse JWKS, find the key matching
kid(anduse=sigif present). - Build an
EVP_PKEYfrom the JWK's raw parameters:- RSA: decode
n(modulus) ande(public exponent) from base64url, build RSA key viaRSA_set0_key(). - EC (P-256/P-384/P-521): decode
xandycoordinates from base64url, build EC key viaEC_KEY_set_public_key_affine_coordinates().
- RSA: decode
- For ES* algorithms, convert the raw
R||Ssignature to ASN.1 DER format required by OpenSSL'sEVP_DigestVerifyFinal. - Verify using
EVP_DigestVerify*()with the appropriate digest (SHA-256/384/512 selected by algorithm suffix).
- Parse JWKS, find the key matching
- Decode payload → validate standard claims:
iss— must equalendpoints_.issuer(from discovery document).aud— must containclient_id(string or array).exp— must be in the future (time(NULL)).nonce— must match the value stored in Redis for this state.
- Extract user info:
- Username:
preferred_username→email→sub(in priority order). - Email:
emailclaim. - Admin: check
group_claimclaim (string or array) foradmin_group.
- Username:
JWKS Key Selection
The JWKS may contain multiple keys. Key selection logic:
- If the JWT header contains a
kid, skip keys with a differentkid. - Skip keys with
useset to something other thansig. - Use the first matching key whose
ktyisRSAorEC. - If no key matches after applying filters, log a warning and return failure.
State and Nonce Management
CSRF protection and replay prevention are handled via a short-lived Redis key:
Key: oidc.state.<state_value>
Value: <nonce>|<referer>
TTL: 600 seconds (OIDC_STATE_TTL)
stateis a 32-byte cryptographically random value (base64url-encoded viaRAND_bytes). It prevents CSRF attacks on the callback endpoint.nonceis a 32-byte cryptographically random value included in the authorization request and verified inside the JWT payload. It prevents token replay attacks.popState()performs a RedisGETfollowed byDEL— if the same callback URL is hit twice (e.g. browser back button), the second request will fail because the key no longer exists.
User Mapping and Auto-Creation
After successful JWT validation the username is derived as follows:
- Use
preferred_usernameclaim if present and non-empty. - Fall back to
emailclaim. - Fall back to
sub(subject) claim.
The derived string is then sanitized:
- Lowercased.
- Only
[a-z0-9._-]characters kept;@is replaced with_. - Truncated to
NTOP_USERNAME_MAXLEN - 1characters.
Admin role assignment is determined by checking whether the configured
group_claim contains the configured admin_group value (supports both
string-valued and array-valued claims). An empty admin_group means all OIDC
users get the unprivileged role.
auto_create_users = 1 (opt-in):
- If the derived username does not exist in ntopng (
ntop->existsUser()returns false),ntop->addUser()is called with:- A random password (the user will never need it).
full_nameset to the email address if available.- Group set to admin or unprivileged based on claims.
- No special network/interface/pool restrictions.
auto_create_users = 0 (default):
- The ntopng user must be pre-created manually (with any password). If the derived username does not exist, the login is rejected. This allows an administrator to control which IdP users can access ntopng.
Login Page Integration
scripts/lua/login.lua reads the ntopng.prefs.oidc.enabled preference via
ntop.getPref() and conditionally renders a "Login with SSO" button:
local oidc_enabled = ntop.getPref("ntopng.prefs.oidc.enabled") == "1"
if oidc_enabled then
-- renders an <a> tag pointing to /oidc_start?referer=<current_referer>
end
The button is rendered outside the credentials <form> so it functions as
a plain link, not a form submission. The referer query parameter carries the
page the user was originally trying to access so they land there after login.
REST API
GET /lua/rest/v2/get/ntopng/oidc_config.lua
Returns current OIDC configuration. The client_secret field is always
masked as "********" in the response. Requires preferences capability.
Example response:
{
"rc": 0,
"rc_str": "OK",
"rsp": {
"enabled": true,
"client_id": "ntopng",
"client_secret": "********",
"issuer_url": "https://keycloak.example.com/realms/myrealm",
"scopes": "openid profile email roles",
"group_claim": "groups",
"admin_group": "ntopng-admins",
"base_redirect_uri": "https://ntopng.example.com",
"auto_create_users": true
}
}
POST /lua/rest/v2/set/ntopng/oidc_config.lua
Saves OIDC configuration. Accepts both form-encoded and JSON body.
Requires preferences capability.
Request fields (all optional; omitted fields are not changed):
| Field | Type | Notes |
|---|---|---|
enabled |
bool / "0"/"1" |
|
client_id |
string | |
client_secret |
string | Ignored if value is "********" |
issuer_url |
string | |
scopes |
string | Default "openid profile email roles" if blank |
group_claim |
string | Default "groups" if blank |
admin_group |
string | |
base_redirect_uri |
string | |
auto_create_users |
bool / "0"/"1" |
Example curl:
curl -u admin:password -X POST \
'https://ntopng.example.com/lua/rest/v2/set/ntopng/oidc_config.lua' \
-H 'Content-Type: application/json' \
-d '{
"enabled": true,
"client_id": "ntopng",
"client_secret": "s3cr3t",
"issuer_url": "https://keycloak.example.com/realms/myrealm",
"base_redirect_uri": "https://ntopng.example.com",
"admin_group": "ntopng-admins",
"auto_create_users": true
}'
Preferences UI
OIDC settings are handled in scripts/lua/admin/prefs.lua under the auth tab
(same tab as LDAP, RADIUS, and HTTP auth settings). POST fields:
| POST field | Redis key updated |
|---|---|
toggle_oidc_auth |
ntopng.prefs.oidc.enabled |
oidc_client_id |
ntopng.prefs.oidc.client_id |
oidc_client_secret |
ntopng.prefs.oidc.client_secret (skipped if "********") |
oidc_issuer_url |
ntopng.prefs.oidc.issuer_url |
oidc_scopes |
ntopng.prefs.oidc.scopes |
oidc_group_claim |
ntopng.prefs.oidc.group_claim |
oidc_admin_group |
ntopng.prefs.oidc.admin_group |
oidc_base_redirect_uri |
ntopng.prefs.oidc.base_redirect_uri |
toggle_oidc_auto_create_users |
ntopng.prefs.oidc.auto_create_users |
Note: A Vue component for the OIDC settings form in the auth tab still needs to be added to
http_src/vue/. SeeREADME.GUI.frontend.mdfor the frontend development workflow.
Dependencies
No new runtime dependencies are introduced. All required libraries are already mandatory for building ntopng:
| Library | Minimum version | Notes |
|---|---|---|
| libcurl | any recent | Used for HTTP GET/POST to IdP |
| OpenSSL | 1.1+ (3.0+ recommended) | RSA/EC low-level APIs used; deprecation warnings suppressed for OpenSSL 3.0 via #pragma GCC diagnostic |
| json-c | any | Bundled in third-party/json-c/ |
Security Considerations
| Concern | Mitigation |
|---|---|
| CSRF on callback | Cryptographic state parameter verified against Redis |
| Token replay | nonce claim included in authorization request and verified in JWT |
| State enumeration | State values are 192-bit random (32 bytes via RAND_bytes), TTL 10 min |
| Signature forgery | alg: none and symmetric algorithms rejected; only RS*/ES* accepted |
| Token expiry | exp claim validated against time(NULL) |
| Audience mismatch | aud claim must contain client_id |
| Open redirects | referer must begin with / before being used as a redirect target |
| Username injection | Derived usernames are sanitized to [a-z0-9._-] |
| Client secret exposure | Secret is stored in Redis; GET config API masks it as "********" |
| HTTP vs HTTPS | The Secure flag is added to session cookies when the connection is HTTPS (existing get_secure_cookie_attributes() logic) |
Recommended Production Checklist
- ntopng is served over HTTPS.
base_redirect_uriuses HTTPS.- The IdP's allowed redirect URIs list contains exactly
{base_redirect_uri}/oidc_callback(no wildcards). client_secretis rotated periodically at the IdP and updated in Redis.auto_create_usersis disabled in high-security environments; users are pre-provisioned manually.admin_groupis set to a specific group that only trusted users belong to; leave empty to deny admin access to all OIDC users.
Testing with a Local IdP
Keycloak can be run locally via Docker for development and testing:
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
- Open
http://localhost:8080→ Administration Console. - Create a realm (e.g.
ntopng-test). - Create a client:
- Client ID:
ntopng - Client authentication: ON (confidential client)
- Valid redirect URIs:
http://localhost:3000/oidc_callback - Copy the client secret from the Credentials tab.
- Client ID:
- Create a group
ntopng-adminsand add your test user to it. - Add
groupsas a token claim:- Client → Client scopes →
ntopng-dedicated→ Add mapper → Group membership - Token Claim Name:
groups, Full group path: OFF.
- Client → Client scopes →
- Configure ntopng:
redis-cli set ntopng.prefs.oidc.enabled "1"
redis-cli set ntopng.prefs.oidc.client_id "ntopng"
redis-cli set ntopng.prefs.oidc.client_secret "<secret from step 3>"
redis-cli set ntopng.prefs.oidc.issuer_url "http://localhost:8080/realms/ntopng-test"
redis-cli set ntopng.prefs.oidc.base_redirect_uri "http://localhost:3000"
redis-cli set ntopng.prefs.oidc.admin_group "ntopng-admins"
redis-cli set ntopng.prefs.oidc.auto_create_users "1"
- Start ntopng on port 3000:
./ntopng -w 3000. - Open
http://localhost:3000→ login page should show "Login with SSO".
Extending the Implementation
Adding MFA for OIDC Users
Currently, OIDC-authenticated users skip ntopng's own TOTP MFA step (the IdP is expected to enforce its own MFA). To add ntopng MFA on top of OIDC:
In src/HTTPserver.cpp, oidc_callback(), after the handleCallback() call
succeeds:
if (ntop->isTOTPEnabled(out_username.c_str())) {
char token[33];
if (ntop->createMFAPendingToken(out_username.c_str(), referer.c_str(),
token, sizeof(token))) {
redirect_to_mfa(conn, token);
return;
}
}
// otherwise fall through to set_session_cookie()
Supporting PKCE
For public clients (no client secret), extend startAuthFlow() to generate a
code_verifier / code_challenge pair and include code_challenge and
code_challenge_method=S256 in the authorization URL. Pass code_verifier
to handleCallback() and include it in the token exchange POST body.
Supporting the Userinfo Endpoint
Some IdPs do not include all claims in the id_token but expose them at the
userinfo_endpoint. After a successful token exchange, make an additional
GET {userinfo_endpoint} request with Authorization: Bearer <access_token>
and merge the resulting JSON into the claims used for username/group mapping.
Caching Endpoints and JWKS Invalidation
The discovery document and JWKS are cached for 1 hour (3600 seconds).
Forced invalidation (e.g. after IdP key rotation) can be triggered by:
# Force re-discovery on next request
redis-cli del "ntopng.prefs.oidc.*" # or restart ntopng
Alternatively, add a REST endpoint that calls
oidcAuth->invalidateCache() (not yet implemented).