Skip to main content
Version: v3.9.0

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_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.

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