Skip to main content
Version: v3.9.2

External OIDC provider — client setup for Controller

Audience: Platform and IdP administrators
Controller mode: AUTH_MODE=external
Applies to: Any OIDC-compliant provider (Keycloak, Microsoft Entra ID, Okta, Auth0, and similar)

This page is the OIDC client contract: client type, grants, redirect URIs, scopes, and access-token rules. For default groups/roles and per-provider recipes, see external-oidc-providers.md. Operator env and login flows: oidc-configuration.md.

Overview​

Controller uses one confidential OIDC client for browser and CLI authentication when AUTH_MODE=external. The EdgeOps Console does not talk to the IdP as a public SPA. Controller is an OAuth BFF for the browser and a password-grant front door for the CLI.

Use caseGrant / flowController endpoint
Browser (EdgeOps Console)Authorization code + PKCE S256GET /api/v3/user/oauth/authorize → IdP → GET /api/v3/user/oauth/callback
CLI (potctl)Resource owner password (direct access)POST /api/v3/user/login
Session refreshRefresh tokenPOST /api/v3/user/refresh
ProfileBearer access token (+ UserInfo)GET /api/v3/user/profile
User APIs and operator WebSocketsBearer access JWT/api/v3/* except agent routes
Agent routesFog token/api/v3/agent/* — OIDC does not apply

In external mode:

  • Access and refresh tokens are issued by the IdP. Controller does not mint them.
  • Controller validates access JWTs via the issuer JWKS (aud must be OIDC_CLIENT_ID).
  • There is no local AuthUsers row for IdP users. RBAC comes from JWT claims (and optional RoleBindings).
  • MFA, password policy, and forced password change are owned by the IdP. Controller does not run embedded interaction UI in this mode.

Controller environment (minimum)​

VariableRequiredExample
AUTH_MODEYesexternal
OIDC_ISSUER_URLYesFull issuer URL (see providers)
OIDC_CLIENT_IDYespot-controller
OIDC_CLIENT_SECRETYesConfidential client secret
CONTROLLER_PUBLIC_URLYeshttps://controller.example.com
CONSOLE_URLYes (browser login)https://console.example.com
TRUST_PROXYWhen TLS terminates at ingresstrue
AUTH_SESSION_STORE_TYPEHA + browser logindatabase (mysql/postgres)
AUTH_INSECURE_ALLOW_HTTPDevelopment onlytrue when using http://localhost:*
OIDC_ISSUER_TLS_PATH_CAPrivate CAPEM file for the issuer CA. Wins over base64
OIDC_ISSUER_TLS_BASE64_CAPrivate CABase64 of the issuer CA PEM (or PEM bundle)

OIDC_ISSUER_URL must be the issuer string, not a generic IdP homepage. Discovery is:

GET {OIDC_ISSUER_URL}/.well-known/openid-configuration

Do not set legacy KC_*, auth.realm, or auth.realmKey.

When the issuer certificate is signed by a private CA, set OIDC_ISSUER_TLS_PATH_CA or OIDC_ISSUER_TLS_BASE64_CA. Path wins when both are set. Controller adds that CA to the Mozilla roots for discovery, the token call, and JWKS, and does not turn certificate checks off. These keys apply only when AUTH_MODE=external. NODE_EXTRA_CA_CERTS remains a process-wide alternative. Control plane YAML field auth.issuerCa is the base64 form.

Optional OIDC_CONSOLE_CLIENT_ID / AUTH_CONSOLE_CLIENT_ENABLED registers a future SPA-direct public client. Default is off. Primary Console login uses this confidential Controller client via the BFF. Do not point Console at a second public client unless you intend to enable that path.

IdP client — required settings​

Client type​

Create one client for Controller. Do not use a public SPA, native, or device client as this client.

SettingRequired valueWhy
ProtocolOpenID ConnectController is OIDC-only
Client typeConfidential (web application)BFF uses OIDC_CLIENT_SECRET at the token endpoint
Client authenticationclient_secret_basic or client_secret_postPublic (none) is not supported
PKCES256 required or allowedBrowser authorize always sends code_challenge_method=S256
Implicit / hybridOffNot used
Device codeOffNot used
Client credentials grantUnusedNot used for operator login

Enabled grant types / flows​

FlowRequired forNotes
Standard flow (authorization code)Browser Sign inRequired
Refresh token (offline_access)Console/CLI session refreshRequired for POST /api/v3/user/refresh
Direct access grants (ROPC)CLI POST /user/loginRequired if potctl uses password login against the IdP
Implicit flow—Off (not used)

If your organization has disabled ROPC (common on Entra ID), browser login still works; CLI password login will not.

PKCE​

Controller always sends PKCE S256 on browser authorize (code_challenge, code_challenge_method=S256).

ProviderSetting
KeycloakPKCE Method = S256 (Capability config)
OthersAllow or require PKCE S256 on the authorization code flow

Disabling PKCE on the IdP is a development workaround only, not recommended for production.

Redirect URIs​

Register exactly as a Web redirect URI (not SPA):

{CONTROLLER_PUBLIC_URL}/api/v3/user/oauth/callback

Examples:

  • Production: https://controller.example.com/api/v3/user/oauth/callback
  • Local: http://localhost:51121/api/v3/user/oauth/callback

Avoid overly broad wildcards in production.

Web origins (CORS)​

If the Console calls the Controller API from a different origin, allow:

{CONSOLE_URL}

Example: http://localhost:3000 or https://console.example.com

Scopes​

Requested by Controller (browser OAuth BFF)​

Controller sends this scope string on authorize (not configurable):

openid profile email groups offline_access
ScopePurpose
openidOIDC baseline; sub, id_token
profilepreferred_username, display name
emailEmail claim; identity linking
groupsRBAC group membership — name must be groups, not group
offline_accessRefresh token on authorization code flow

IdP client scope assignment​

Every requested scope must be assigned to the client as a default and/or optional client scope.

ScopeTypical assignment
openid, profile, email, rolesDefault
groupsOptional (scope name must be groups)
offline_accessOptional

Common error: invalid_scope when a scope is requested but not assigned to the client, when the scope name is wrong (group vs groups), or when the provider only accepts a full URI scope (api://…/groups) instead of the short name groups.

Access token requirements​

Controller validates the access token, not the ID token, on API calls. Groups and roles on the ID token alone are ignored for authorization.

The access token must:

  1. Be a JWT (opaque tokens fail).
  2. Have iss equal to the discovered issuer.
  3. Have aud equal to OIDC_CLIENT_ID (string or array containing it).
  4. Carry RBAC claims on the access token (see external-oidc-providers.md).
  5. Not be a refresh token (token_use must not be refresh).

If aud is Microsoft Graph, Okta api://default, or any API identifier that is not the client ID, Bearer validation fails and APIs return 401.

Issuer discovery (minimum metadata)​

The issuer at OIDC_ISSUER_URL must expose:

EndpointUsed for
authorization_endpointBrowser OAuth BFF
token_endpointCode exchange, ROPC, refresh
jwks_uriBearer JWT validation
userinfo_endpointGET /user/profile in external mode
revocation_endpointOptional; best-effort logout

Groups, roles, and user identity​

Create IdP groups or application roles named admin, sre, developer, and viewer. Those names map directly to Controller system roles (no RoleBinding required). Full mapping, claim order, and Keycloak / Entra ID / Okta / Auth0 recipes: external-oidc-providers.md.

User subject (first match): preferred_username → username → email → sub.

Group subjects from the access token (all lowercased):

  1. resource_access[{OIDC_CLIENT_ID}].roles
  2. Top-level roles array
  3. Top-level groups array

MFA and forced password change​

  • Browser: enforced by the IdP during authorize (for example Keycloak required action UPDATE_PASSWORD)
  • CLI: IdP password-grant policy applies
  • Controller does not run embedded interaction UI in external mode

High availability​

For multiple Controller replicas with browser login, set AUTH_SESSION_STORE_TYPE=database (mysql/postgres) so OAuth state and the PKCE verifier survive any replica. memory is only safe for a single replica.

Verification​

Browser​

  1. Console Sign in → IdP login page (no invalid_scope or PKCE errors)
  2. Callback → {CONSOLE_URL}/login#accessToken=...&refreshToken=...
  3. Decode the access token: aud is OIDC_CLIENT_ID; iss matches the issuer; roles / groups / resource_access contains a system role name
  4. GET /api/v3/user/profile with Authorization: Bearer <accessToken> → 200

CLI​

curl -sS -X POST '{CONTROLLER_PUBLIC_URL}/api/v3/user/login' \
-H 'Content-Type: application/json' \
-d '{"email":"<login-id>","password":"<pass>","totp":""}'

email is the IdP login identifier (username or UPN). Expect { "accessToken", "refreshToken" } (IdP tokens).

Refresh​

curl -sS -X POST '{CONTROLLER_PUBLIC_URL}/api/v3/user/refresh' \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"<refresh>"}'

Troubleshooting​

ErrorLikely cause
invalid_scopeMissing groups or offline_access on the client; wrong scope name (group vs groups); provider requires a full URI scope
Missing parameter: code_challenge_methodPKCE required on IdP but not sent by Controller (upgrade Controller)
redirect_uri mismatchCallback URL not registered exactly; URI registered as SPA instead of Web
Login works, no refreshToken in browser hashoffline_access not requested or not assigned on the IdP client
401 on API routes after loginOpaque access token; aud is not OIDC_CLIENT_ID; wrong iss
403 on API routesToken valid but RBAC groups/roles not on the access token; see external-oidc-providers.md
CLI login fails, browser worksROPC / direct access grants disabled
OAuth state errors on HAAUTH_SESSION_STORE_TYPE still memory
DocumentTopic
external-oidc-providers.mdDefault groups/roles; Keycloak, Entra ID, Okta, Auth0 recipes
oidc-configuration.mdAuth modes and environment variables
rbac-reference.mdSystem roles, verbs, RoleBindings
swagger.yaml/user/oauth/authorize, /user/oauth/callback, /user/login, /user/refresh, /user/profile