Authentication
How operators sign in, how the system keeps them signed in, and what happens when they leave.
Every page in the GUI sits behind an authentication wall. A user must present valid credentials, receive a token, and carry that token on every subsequent request. The login screen at /login is the only unauthenticated page in the appliance. Everything else redirects there if the token is missing, expired, or revoked.
This chapter covers the login experience, the token model the system uses to keep a user signed in, optional second-factor enrolment, optional sign-in with Google, and how sessions end. For the user accounts themselves see User Management (chapter 27). For the session list and the audit log see Sessions and Audit Log (chapter 30).
The Login Page
Section titled “The Login Page”A single card on a dark background with one purpose: get the operator a valid token.
The login page shows the product name, a username field, a password field with a show/hide eye, and a Sign In button. If sign-in with Google has been turned on by an administrator, a second button appears below a divider labelled “or”.
The form is fully server-validated:
- Empty username or password is rejected on the client; the Sign In button is only enabled when both fields have content.
- The submit calls the appliance’s login endpoint. The server compares the username (case-insensitive) and bcrypt-hashed password against the user account table.
- The result is one of: signed in, MFA required, password change required, password expired, account locked, IP not allowed, too many concurrent users.
Below the form, when applicable, the page renders contextual messages:
| Condition | What the user sees |
|---|---|
| Wrong credentials | ”Invalid credentials” |
| Account locked after repeated failures | ”Account locked. Try again in N minute(s).” |
| IP restricted | ”Access denied from this IP address” with the restriction reason |
| Maximum concurrent users reached | ”Maximum concurrent users reached. Please try again later.” |
| Password has expired | ”Your password has expired. Please contact an administrator.” |
| Logged out by an admin | The reason carried over from the previous session (“Your session was terminated by an administrator”, etc.) |
Tip: the lockout counter, lockout duration, and counter-reset window are all administrator-controlled in Settings. Defaults are 5 failed attempts, 15-minute lockout, and a 30-minute reset window for the failure counter when no further failures occur.
What the Token Carries
Section titled “What the Token Carries”The appliance issues a signed JWT on successful login. The browser stores it and sends it with every request.
The token is a standard JSON Web Token signed with a secret that is regenerated on every appliance restart. It carries:
- The numeric user ID.
- The username (already normalised to lowercase).
- The role:
admin,operator, orviewer. - The list of permissions the role grants.
- Two MFA flags: whether MFA is enabled for the account and whether this token has cleared the MFA challenge.
- Issued-at and expires-at timestamps.
The token is never stored on the appliance itself; only its hash is, alongside the session record (see Sessions and Audit Log, chapter 30). When the browser presents the token, the appliance verifies the signature, checks the expiry, and looks up the active session record by hash. If any of those fail the request is rejected.
Token Lifetime
Section titled “Token Lifetime”The default token lifetime is 4 hours. An administrator can override the lifetime per user in User Management (chapter 27) via the per-user “Login Timeout” setting, and a system-wide idle timeout can be configured under Settings. Resolution order, highest priority first:
- The user’s own configured login timeout (admin-set).
- The system-wide idle timeout (system setting).
- The 4-hour default.
The “idle timeout” is enforced server-side: if the session record has not seen activity within the configured window, the session is terminated on the next request even if the token itself has not yet expired.
Token Refresh
Section titled “Token Refresh”The GUI extends the session before it expires by calling the refresh endpoint with the current token. The appliance:
- Verifies the current token is valid.
- Mints a new token with a fresh expiry.
- Rewrites the session record so the new token hash replaces the old.
Refresh is automatic — the operator does not see it. It is logged in the audit log as a Token Refresh event.
Multi-Factor Authentication
Section titled “Multi-Factor Authentication”A user who has enrolled a TOTP authenticator must provide a six-digit code before the login completes.
When the username and password are accepted, if the account has MFA enabled, the response from the login endpoint does not include a full token. Instead it includes a short-lived MFA token (valid for 5 minutes) and a flag telling the GUI to show the MFA challenge.
The challenge screen replaces the password form. It shows:
- A keyring icon.
- “Two-Factor Authentication” heading.
- The username being signed in as.
- A six-digit numeric input that auto-focuses and only accepts digits.
- A Verify & Sign In button (disabled until 6 digits are entered).
- A Back-to-login link.
On submit the GUI calls the MFA verification endpoint with the MFA token and the code. The server:
- Validates the MFA token (must be a fresh MFA token, not a regular session token).
- Looks up the user’s TOTP secret and checks the code against the current time window.
- If the code is wrong, falls back to checking the user’s backup codes (single-use recovery codes generated when MFA was enrolled).
- On success, issues a full session token with the MFA-verified flag set and records the login in the audit log as
local+mfa.
A failed MFA challenge increments no lockout counter directly, but is recorded in the audit log as MFA Failed. The MFA token expires after 5 minutes, after which the user must enter their username and password again.
Backup codes are managed by the user under the user profile page. Each backup code is consumed on use.
Sign In with Google
Section titled “Sign In with Google”Optional. When enabled, a “Sign in with Google” button appears below the password form.
The Google sign-in flow is OAuth 2.0 / OpenID Connect. The appliance redirects the user to Google’s consent screen, Google redirects back to the appliance with a one-time code, and the appliance exchanges the code for the user’s identity. Internally:
- The appliance generates a one-time state parameter to defeat CSRF and stores it briefly.
- The browser is redirected to Google’s authorisation endpoint with the appliance’s client ID and the state.
- After the user consents, Google redirects back to the appliance’s callback URL with an authorisation code.
- The appliance exchanges the code for an access token and ID token, fetches the user’s email and name, and either looks up or auto-creates the matching local user account.
- A normal session token is minted and the user is sent to the dashboard.
Configuration
Section titled “Configuration”OAuth providers are configured under Settings (admins only). The Google provider exposes:
| Field | Effect |
|---|---|
| Enabled | When off, the Sign-in-with-Google button is hidden on the login page. |
| Client ID / Client Secret | Issued by Google Cloud Console when the OAuth app is registered. |
| Default Role | Role given to brand-new accounts auto-created from Google sign-in. |
| Auto-create accounts | When on, any successfully-authenticated Google user becomes a local account on first login. When off, only pre-registered usernames are accepted. |
| Allowed email domains | Comma-separated list. When non-empty, only addresses ending in these domains may sign in. |
Error Codes Shown on the Login Page
Section titled “Error Codes Shown on the Login Page”OAuth flows can fail in many ways; the login page surfaces the cause:
| Message shown | Cause |
|---|---|
| ”You cancelled the Google sign-in. Please try again.” | The user denied consent at Google. |
| ”Google sign-in is not configured properly. Please contact your administrator.” | The client ID / secret pair is missing or wrong. |
| ”Your account is not registered. Please contact your administrator to request access.” | Auto-create is off and no local user matches the Google identity. |
| ”Email domain ‘<domain>’ is not allowed. Please contact your administrator.” | The user’s email is outside the allowed domain list. |
| ”Failed to complete Google sign-in. Please try again.” | The code-for-token exchange with Google failed. |
| ”Invalid login session. Please try again.” | The OAuth state parameter expired or was tampered with. |
OAuth logins are recorded in the audit log as OAuth Login events; new account creations triggered by Google sign-in show as User Created followed by OAuth Linked.
Note: OAuth-authenticated users are also subject to the same role-based access control as local users. If their account role is
viewer, they only see read-only pages no matter how they signed in.
Forced Password Changes
Section titled “Forced Password Changes”A user can be required to set a new password before they are fully signed in.
Two cases trigger this:
- A new account created by an administrator with the “must change password on next login” option.
- An existing account where the administrator clicked “Force password reset” in User Management (chapter 27).
The flow:
- The user submits their current credentials.
- The server validates them as usual.
- Instead of returning a full session token, the server returns a limited 15-minute token that only permits password change.
- The GUI shows a yellow “Password Change Required” panel with the same Back-to-login link and a New Password / Confirm Password form.
- On submit, the limited token is exchanged for a successful password change, and then the GUI silently re-authenticates with the new password to obtain a real session token.
If the user clicks Back-to-login at this point, the limited token is dropped and they must start over from the username field.
Password Expiry
Section titled “Password Expiry”If the administrator configures a password maximum age, a user whose password is older than the limit is forced to update it.
When the login endpoint detects an expired password it returns a password_expired flag with no token. The GUI shows:
“Your password has expired. Please contact an administrator.”
In this scenario the user cannot proceed without administrator help — the appliance does not currently offer a self-service password-reset flow. The administrator either resets the password and forces a change on next login (see above) or extends the user’s password expiry.
The password policy itself — maximum age, minimum length, complexity requirements — is configured under Settings. By default password expiry is off.
Account Lockout
Section titled “Account Lockout”Repeated failed logins lock the account temporarily.
The login endpoint counts consecutive failures. When the counter hits the configured threshold the account is locked for the configured duration. While locked:
- The login endpoint returns a “locked” response with the remaining minutes.
- The GUI disables the Sign In button and displays “Account locked. Try again in N minute(s).”
- A new audit log entry records the lockout.
The counter resets to zero on the first successful login, or after the configured reset window elapses with no further failed attempts (so an occasional typo does not slowly accumulate into a lock).
Operator recovery: an administrator can manually unlock the account by editing the user in User Management. This is recorded as an
Account Unlockedevent in the audit log.
Note: lockouts protect against password guessing; they do not apply to IP restrictions, MFA failures, or session-limit rejections. Those use their own enforcement.
IP Restrictions
Section titled “IP Restrictions”A user can be confined to specific source IP ranges. Logins from any other IP are refused.
IP restrictions are set per user in User Management. When set, every login attempt evaluates the user’s IP against the allow / deny lists before checking the password. A blocked IP gets a clear message and a login_failed audit event with reason ip_restricted.
IP restrictions apply equally to password and OAuth logins.
Session Limits
Section titled “Session Limits”The appliance enforces both a per-user maximum and a system-wide maximum concurrent sessions cap.
- The per-user maximum terminates the oldest active session for that user when the limit would be exceeded. Configurable under Settings.
- The system-wide maximum comes from the appliance licence (
max_usersfield). When the licence allows, for example, 25 concurrent users and 25 sessions are already active, the 26th login is rejected with “Maximum concurrent users reached.” Admin users are exempt from this limit so the system cannot be locked out.
Both limits are evaluated after MFA verification, just before the session token is minted, so a rejected login still consumes no session slot.
See Sessions and Audit Log (chapter 30) for the live session table where administrators can see and terminate active sessions.
Logout
Section titled “Logout”Logout is two things at once: drop the token in the browser, and mark the session inactive on the appliance.
When the user clicks Sign Out (or selects it from the user menu in the top-right):
- The GUI calls the logout endpoint, which records a
Logoutaudit event and marks the corresponding session row inactive in the session table. - The GUI clears the token from local storage and redirects to
/login. - Any other tabs in the same browser observe the storage event and follow suit.
If the appliance terminates a session out from under an active operator (for example because an administrator clicked “End session” on the sessions page), the next API call returns 401 and the GUI redirects to /login with a message explaining why.
Note: the appliance uses stateless JWTs but pairs them with a server-side session record. Terminating the session row makes the token effectively dead because the session lookup will fail on the next request, even though the token signature is still valid until its expiry.
Multi-Tab Behaviour
Section titled “Multi-Tab Behaviour”The token lives in the browser’s local storage. All tabs in the same browser share it.
Open three tabs of the GUI: they all share the same authentication state. Sign out in one, and within a moment the other two redirect to /login. Refresh the token in one, and the new token is automatically picked up by the others.
The GUI uses a small auth-store that listens for cross-tab storage events. There is no per-tab session — the user is signed in to the browser, not the page.
Implication: if you sign in to the GUI on someone else’s machine and walk away, every tab they open still has your session. Always sign out, or close the browser.
What an Administrator Sees vs an Operator vs a Viewer
Section titled “What an Administrator Sees vs an Operator vs a Viewer”Roles are checked on both ends. The GUI hides what the role cannot reach; the appliance enforces it regardless.
After login the token carries the role. The GUI uses the role to decide what menu items to render, which pages to allow, and which buttons to expose. The appliance independently checks the role on every API call, so a user who hand-crafts a request to an endpoint they should not be able to reach still gets a 403 Forbidden.
| Role | Sees | Can do |
|---|---|---|
| admin | Everything, including User Management, Sessions, Audit Log, Settings, Firewall Manager, Licence, plus advanced administration and vendor-support controls | All actions, including destructive ones |
| operator | All operational pages (Dashboard, DHCP Stream, Devices, Actions, Automation, LLM analysis, Reports), most settings | Apply actions, edit automation rules, run analyses; no User Management, no Settings that affect security policy, no Terminal |
| viewer | All read-only pages | View only; all action buttons are hidden or disabled |
For the full per-permission breakdown see User Management (chapter 27).
API Access for Automation
Section titled “API Access for Automation”Programmatic clients use the same login endpoint to obtain a token.
A scripted client (curl, Postman, a custom integration) authenticates by posting a JSON body with username and password to the login endpoint and reading the token from the response. The token then goes in the Authorization: Bearer <token> header on every subsequent request.
This is the same flow the GUI uses; there is no separate API-token system at the login layer. For Prometheus scrape tokens, which are a different mechanism scoped to the metrics endpoint, see Prometheus Integration (chapter 40).
Tip: API clients should treat 401 responses as “token expired” and re-authenticate, rather than retrying with the same token. Tokens cannot be renewed once they expire — only refreshed while still valid.
Where Auth Events Land
Section titled “Where Auth Events Land”Every meaningful authentication event is recorded for review.
The audit log captures, at minimum:
- Every successful login, with method (
local,local+mfa,google). - Every failed login, with the reason (
user_not_found,account_inactive,account_locked,ip_restricted,invalid_password,session_limit_exceeded). - Every logout.
- Every token refresh.
- Every MFA challenge, verification, and failure.
- Every account lockout and unlock.
- Every OAuth link, unlink, and login.
- Every password change.
The full list with filters and CSV export is in Sessions and Audit Log (chapter 30).
OAuth Provider Setup
Section titled “OAuth Provider Setup”A practical appendix for administrators wiring the appliance up to an external identity provider for the first time.
The earlier “Sign In with Google” section described what an operator sees and what the appliance does at runtime. This appendix is the administrator-facing companion: which providers are production-ready today, how to register the appliance with Google or Keycloak, how role mapping really matches, how to migrate an existing local-auth deployment, and how to read the error messages that come out of a misconfigured setup.
Implementation Status
Section titled “Implementation Status”Not every provider in config.yaml is on the same footing. Read this table first.
| Provider | Status in current build | Where the runtime config lives |
|---|---|---|
| Production. DB-backed dynamic config, editable in the GUI under Configuration → External Auth, no restart required. | Database (system_config), with auth.oauth2.providers.google in config.yaml as the fallback default. | |
| Keycloak | Legacy path only. No GUI surface, no database overlay. | config.yaml only — every change needs a service restart. |
| Generic OIDC (Azure AD, Okta, Auth0, etc.) | Legacy path only. Same constraints as Keycloak. | config.yaml only. |
A future release is expected to lift Keycloak and generic OIDC onto the same DB-backed runtime as Google. Until then, treat anything beyond Google as a YAML-and-restart workflow and plan rollouts accordingly.
Google Provider Setup
Section titled “Google Provider Setup”Five steps. The first three are in Google Cloud Console, the last two are on the appliance.
- Create a Google Cloud project. Sign in to Google Cloud Console, create a new project (or reuse one), then go to APIs & Services → OAuth consent screen.
- Configure the OAuth consent screen. Pick Internal if you have Workspace, otherwise External. Fill in the app name (e.g. “DHCP DPI”), a user-support email, and a developer contact. Add the scopes
openid,email,profile. - Create OAuth credentials. Under APIs & Services → Credentials, click Create Credentials → OAuth client ID, pick Web application, then configure:
- Authorised JavaScript origins:
https://<your-appliance-domain> - Authorised redirect URI:
https://<your-appliance-domain>/api/auth/oauth2/google/callbackCopy the Client ID and Client Secret that Google issues.
- Authorised JavaScript origins:
- Configure the appliance. Either open Configuration → External Auth in the GUI (writes to the database, takes effect immediately, no restart) or edit
config.yamldirectly. Paste in the Client ID, set the Client Secret from an environment variable, and turnenabled: true. The redirect URI registered with Google must match the appliance exactly — including the protocol and the lack of a trailing slash. - Optionally restrict to your domain. In Google Admin Console, Security → Access and data control → API controls lets you limit which apps may access organisation data. Combine this with the appliance’s Allowed email domains setting (described earlier in this chapter) for defence in depth.
Tip: if you change the Google client secret in the GUI, the change is live on the next sign-in attempt. If you change it in
config.yaml, you must restart the service for the new value to take effect.
Keycloak and Generic OIDC (Legacy YAML Path)
Section titled “Keycloak and Generic OIDC (Legacy YAML Path)”These providers exist but have no GUI. Every change is a config.yaml edit and a restart.
For Keycloak:
- Create or pick a realm. Inside it create an OpenID Connect client (a common name is
dhcp-dpi). - Set the Valid Redirect URI on that client to
https://<your-appliance-domain>/api/auth/oauth2/keycloak/callback. - In the client’s Credentials tab, copy the Client Secret.
- Optionally create Keycloak roles (
dhcp_admin,dhcp_operator, etc.) and ensure therolesscope is mapped into the userinfo response — this is how the appliance learns which role to assign. - Add a
keycloakprovider block underauth.oauth2.providersinconfig.yamlwith the client ID, secret, the realm’s issuer URL, the scopesopenid,email,profile,roles, and arole_mappingtable that translates Keycloak role names to appliance roles. - Restart the appliance service.
For a generic OIDC provider such as Azure AD, the shape is the same. Set type: oidc, point issuer at the tenant’s OIDC discovery URL (for Azure that’s https://login.microsoftonline.com/<tenant-id>/v2.0), and list the group names the IdP returns in role_mapping. Group names must match exactly — there is no wildcard support, see Role Mapping below.
Role Mapping
Section titled “Role Mapping”Role mapping is exact-string match against a lookup table. The only wildcard is the literal key "*".
When the appliance receives a successful OAuth response it reads a list of group names, role names, or emails from the provider and consults the per-provider role_mapping map. Each key in that map is compared character-for-character against the values the IdP returned. The first match wins; the resulting value (admin, operator, viewer) becomes the appliance role for that session.
The literal key "*" is a catch-all: if it is present, any user who did not match a specific entry gets that role. This is the only wildcard supported.
| Mapping entry | Behaviour |
|---|---|
"dhcp-admins": "admin" | Exact group-name match — works. |
"admin@example.com": "admin" | Exact email match — works. |
"*": "viewer" | Literal catch-all — works. |
"*@example.com": "viewer" | Domain wildcard — not supported, silently never matches. |
"dhcp-*": "operator" | Glob — not supported, silently never matches. |
Footgun: the entries marked “not supported” parse fine and produce no warning. They simply never match. If you want “everyone in our domain gets viewer”, configure the IdP to assign a group like
dhcp-employeesto all users and map that group exactly, or rely on the per-providerdefault_rolesetting.
Users whose external identity does not match any role-mapping entry fall back to the provider’s default_role (typically viewer).
Migration: Local Auth to OAuth
Section titled “Migration: Local Auth to OAuth”Migrate in phases. Keep local auth on as a safety net until every administrator has a working OAuth login.
- Prepare. List the active users in the appliance (an administrator can query the user table directly from the appliance host, or browse User Management in the GUI). Make sure every active account has an email set — OAuth auto-linking is by email address. Configure the OAuth provider as described above.
- Enable OAuth alongside local auth. Set both
auth.local.enabled: trueandauth.oauth2.enabled: trueinconfig.yaml, with at least one provider turned on. Restart, then confirm the “Sign in with Google” button appears on the login page and that a known administrator can complete the OAuth flow. - Migrate users. Three paths exist. The easiest is auto-link: the user signs in via OAuth, the appliance matches their provider email to the existing local account and inserts an OAuth-link row. No password is changed and no audit history is lost. Alternatively a user can sign in with local credentials, open Profile → Linked Accounts, and link the provider explicitly. The third case is a fresh account: if no email match is found, a new account is created with the provider’s
default_role, and an administrator may need to adjust the role afterwards. - Verify. Spot-check that the linked accounts behave correctly and that the audit log shows
OAuth Loginevents with the expected method. Confirm that each administrator has at least one working OAuth login before continuing. - Optionally disable local auth. Once every administrator has a confirmed OAuth login, set
auth.local.enabled: falseand restart. The password form is removed from the login page and only OAuth sign-in remains. Existing OAuth links are unaffected.
Warning: never disable local auth until at least one administrator has signed in successfully via OAuth. If the OAuth provider becomes unreachable and no local administrator account is usable, recovery requires editing
config.yamlon the appliance directly.
Rollback. Re-enable local auth in config.yaml and restart. Existing OAuth links remain intact and continue to work the next time the provider is reachable.
Troubleshooting
Section titled “Troubleshooting”The error message on the login page or in the server log usually points straight at the misconfigured field.
| Symptom | Most likely cause and fix |
|---|---|
| ”Provider not found or not enabled” | The provider name in the callback URL does not match a key under auth.oauth2.providers, or enabled: true is missing at the top of the auth.oauth2 block or on the specific provider. For Keycloak / generic OIDC, also restart after editing. |
| ”Invalid OAuth state” | The CSRF state token expired (it lives roughly ten minutes) or was tampered with. The user should try again. Clear browser cookies if the problem repeats, and check clock sync between the appliance and the user’s browser. |
| ”Token exchange failed” | The appliance could not swap the authorisation code for tokens. Verify the Client ID and Client Secret. Confirm the redirect URI registered with the IdP matches what the appliance uses exactly, including the protocol and the absence of a trailing slash. Inspect the server log for the underlying HTTP error from the IdP. |
| User gets the wrong role | The IdP did not return the group name you expected, or the role_mapping entry does not match it character-for-character. Remember domain wildcards and globs do not work — only literal strings and "*". Turn on debug logging to see the raw claims the IdP returned. |
Debug logging. The appliance uses a numeric syslog log level (0=emergency through 8=trace). To see the OAuth claim payload and the IdP’s raw responses, raise logging.level to 8 in config.yaml and restart. Useful log lines to grep for include the OAuth callback error message, the state-validation failure, the token-exchange failure, the userinfo failure, and the successful OAuth login audit message.
Tip: to confirm an OIDC provider is reachable from the appliance, fetch
https://<provider>/.well-known/openid-configurationfrom the appliance host. A valid response is JSON withauthorization_endpoint,token_endpoint, anduserinfo_endpointpopulated.