Audience: Platform and IdP administrators
Controller mode: AUTH_MODE=external
Applies to: Any OIDC-compliant provider (Keycloak, Auth0, Okta, Azure AD, etc.)
Controller uses one confidential OIDC client for browser and CLI authentication when AUTH_MODE=external.
| Use case | Grant / flow | Controller endpoint |
|---|---|---|
| Browser (EdgeOps Console) | Authorization code + PKCE S256 | GET /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 refresh | Refresh token | POST /api/v3/user/refresh |
| Profile | Bearer access token (+ UserInfo) | GET /api/v3/user/profile |
In external mode, access and refresh tokens are issued by the IdP. Controller validates access JWTs via the issuer JWKS and maps claims to RBAC.
| Variable | Required | Example |
|---|---|---|
AUTH_MODE |
Yes | external |
OIDC_ISSUER_URL |
Yes | https://auth.example.com/realms/myrealm |
OIDC_CLIENT_ID |
Yes | pot-controller |
OIDC_CLIENT_SECRET |
Yes | Confidential client secret |
CONTROLLER_PUBLIC_URL |
Yes | https://controller.example.com |
CONSOLE_URL |
Yes (browser login) | https://console.example.com |
AUTH_INSECURE_ALLOW_HTTP |
Development only | true when using http://localhost:* |
- OpenID Connect
- Confidential client (client authentication enabled; uses
OIDC_CLIENT_SECRET) - Public clients are not supported for the Controller OAuth BFF
| Flow | Required for | Notes |
|---|---|---|
| Standard flow (authorization code) | Browser Sign in | Required |
| Direct access grants (ROPC) | CLI POST /user/login |
Required if potctl uses password login against the IdP |
| Implicit flow | — | Off (not used) |
Controller always sends PKCE S256 on browser authorize (code_challenge, code_challenge_method=S256).
| Provider | Setting |
|---|---|
| Keycloak | PKCE Method = S256 (Capability config) |
| Others | Allow or require PKCE S256 on the authorization code flow |
Disabling PKCE on the IdP is a development workaround only, not recommended for production.
Register exactly:
{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.
If the Console calls the Controller API from the browser, allow:
{CONSOLE_URL}
Example: http://localhost:3000
Controller sends this scope string on authorize:
openid profile email groups offline_access
| Scope | Purpose |
|---|---|
openid |
OIDC baseline; sub, id_token |
profile |
preferred_username, display name |
email |
Email claim; identity linking |
groups |
RBAC group membership (see below) |
offline_access |
Refresh token on authorization code flow |
Every requested scope must be assigned to the client as a default and/or optional client scope.
| Scope | Typical assignment |
|---|---|
openid, profile, email, roles |
Default |
groups |
Optional (scope name must be groups, not group) |
offline_access |
Optional |
Common error: invalid_scope when a scope is requested but not assigned to the client, or when the scope name is wrong (group vs groups).
Controller resolves RBAC Group subjects from the access token (in order):
resource_access[{OIDC_CLIENT_ID}].roles— Keycloak client roles (recommended)- Top-level
rolesarray (if present) groupsarray — from thegroupsscope and a group mapper
Group names are lowercased before RBAC lookup. IdP role and group names should align with Controller RBAC groups (for example admin, viewer).
Option A — Client roles (simplest)
Assign roles on the client matching OIDC_CLIENT_ID. The default roles scope adds resource_access to the token.
Option B — groups scope
Create a realm client scope named exactly groups. Add a Group Membership or Realm Role mapper that emits a groups claim.
Controller reads the User subject from JWT claims (first match):
preferred_username → username → email → sub
Ensure at least one stable identifier is present for RBAC User bindings.
The issuer at OIDC_ISSUER_URL must expose:
| Endpoint | Used for |
|---|---|
authorization_endpoint |
Browser OAuth BFF |
token_endpoint |
Code exchange, ROPC, refresh |
jwks_uri |
Bearer JWT validation |
userinfo_endpoint |
GET /user/profile in external mode |
revocation_endpoint |
Optional; best-effort logout |
Example client: pot-controller
| Setting | Value |
|---|---|
| Client authentication | On |
| Standard flow | On |
| Direct access grants | On (if CLI login is required) |
| PKCE Method | S256 |
| Valid redirect URIs | {CONTROLLER_PUBLIC_URL}/api/v3/user/oauth/callback |
| Web origins | {CONSOLE_URL} |
| Client scopes | openid, profile, email, roles (default); groups, offline_access (optional) |
- 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
- Viewer Sign in → IdP login page (no
invalid_scopeor PKCE errors) - Callback →
{CONSOLE_URL}/login#accessToken=...&refreshToken=... GET /api/v3/user/profilewithAuthorization: Bearer <accessToken>→ 200
curl -sS -X POST '{CONTROLLER_PUBLIC_URL}/api/v3/user/login' \
-H 'Content-Type: application/json' \
-d '{"email":"<user>","password":"<pass>","totp":""}'Expect { "accessToken", "refreshToken" } (IdP tokens).
curl -sS -X POST '{CONTROLLER_PUBLIC_URL}/api/v3/user/refresh' \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"<refresh>"}'| Error | Likely cause |
|---|---|
invalid_scope |
Missing groups or offline_access on the client; wrong scope name (group vs groups) |
Missing parameter: code_challenge_method |
PKCE required on IdP but not sent by Controller (upgrade Controller) |
redirect_uri mismatch |
Callback URL not registered exactly |
Login works, no refreshToken in browser hash |
offline_access not requested or not assigned on the IdP client |
| 403 on API routes | Token valid but RBAC groups/roles not mapped; check client roles or groups claim |
See docs/swagger.yaml for /user/oauth/authorize, /user/oauth/callback, /user/login, /user/refresh, and /user/profile.