OIDC realm:
Summary
The OIDC realm in PVE appears to use the configured client-key (client secret) on every token exchange against the IdP, regardless of whether the auth code being exchanged was obtained via a confidential-client flow (browser → PVE UI redirect URI) or a public-client flow (browser → loopback redirect URI on a CLI tool's local listener).
This means a single OIDC realm cannot simultaneously serve:
- the PVE web UI, whose registered redirect URI lives on a "Web" platform in the IdP and requires a client secret on token exchange, and
- a CLI/automation tool that uses the same realm via /access/openid/auth-url + /access/openid/login, with a loopback redirect-url, where the IdP treats that auth code as having come from a public client and rejects token exchanges that include a client secret.
The workaround we've ended up at is two PVE realms backed by two separate IdP app registrations — one confidential, one public — which works but is duplicative and surprising. This post lays out what each side is doing and where the conflict is, in case it's useful as a feature request or design discussion.
Environment
- PVE 8.x cluster (issue reproducible across 8.2 and 8.3)
- IdP: Microsoft Entra ID (Azure AD), but the underlying behaviour is per the OAuth 2.0 / OIDC spec and would presumably reproduce against any compliant IdP that distinguishes confidential and public clients
- One realm of type openid, issuer-url set to https://login.microsoftonline.com/<tenant>/v2.0
What the web UI does (works with
Standard Authorization Code flow, confidential client:
- User clicks the realm in the PVE login dropdown.
- PVE constructs the auth URL with redirect_uri=https://<pve-host>:8006.
- Browser → IdP → user authenticates → browser is redirected back to https://<pve-host>:8006/?code=...&state=....
- PVE's frontend posts the code + state to /access/openid/login.
- PVE backend exchanges the code at the IdP's token endpoint, sending client_id + client_secret (the configured client-key).
- IdP returns ID/access tokens. PVE validates and issues a PVE ticket.
The IdP app registration for this case has:
- A Web platform configured with redirect URI https://<pve-host>:8006.
- A client secret issued and stored in PVE as client-key.
- Treated by the IdP as a confidential client — token exchange requires the secret.
This all works correctly. No issue here.
What a CLI tool does (only works with
We have an internal script that deploys VMs to a dev cluster. Rather than ask users to paste tokens or manage API tokens manually, it reuses the realm's OIDC config by:
- Binding a short-lived HTTP listener on 127.0.0.1:<random-port>.
- Calling POST /api2/json/access/openid/auth-url with realm=<our-realm> and redirect-url=http://127.0.0.1:<port>/callback. PVE accepts the loopback redirect URI as-is and constructs an IdP auth URL using it.
- Opening the browser at the returned URL. User signs in at the IdP.
- IdP redirects back to http://127.0.0.1:<port>/callback?code=...&state=... — caught by the local listener.
- Script posts the code + state + redirect-url to /api2/json/access/openid/login.
- PVE backend tries to exchange the code at the IdP's token endpoint.
- IdP rejects the exchange because the auth code was issued under a redirect URI registered on a public-client platform, but PVE is presenting client credentials (the client-key).
The relevant IdP-side detail: loopback redirect URIs like http://127.0.0.1 and http://localhost are only permitted under the IdP's "Mobile and desktop applications" / public-client platform configuration. Once a redirect URI is on a public-client platform, the IdP enforces public-client semantics on the resulting auth code — specifically, the token exchange must not include client credentials, and PKCE is expected.
If client-key is unset in domains.cfg, PVE's token exchange request omits the secret and the IdP accepts the exchange. The CLI flow then completes and returns a PVE ticket.
Removing client-key to make the CLI flow work breaks the UI flow, because the UI's auth code came from a confidential-client redirect URI and the IdP refuses to exchange it without the secret.
Hence the toggle: secret in domains.cfg → UI works, CLI breaks. Secret removed → CLI works, UI breaks.
Why this is surprising
From the realm's point of view, both flows hit the same two endpoints (/access/openid/auth-url and /access/openid/login) with the same realm name. The only difference is the value of redirect-url supplied by the caller. The IdP cares about that difference and switches between confidential and public client semantics accordingly; PVE does not, and applies the configured client-key uniformly.
A few things contribute to the gotcha:
- PVE accepts arbitrary redirect-url values from the API caller (including loopback URIs), which strongly suggests the realm is intended to support both UI and external/CLI consumers.
- The realm config has no field to indicate "this realm is public" vs "this realm is confidential," and client-key is treated as the switch — but only by absence.
- Documentation around the realm doesn't currently call out that the redirect-url parameter on /access/openid/auth-url must be consistent with the IdP-side platform classification of that URI, which is the actual constraint at play.
Workaround in use
Two PVE realms, two IdP app registrations:
- entra-ui realm — client-key set, IdP app has Web platform + secret, used by browser logins.
- entra-cli realm — client-key unset, IdP app has Mobile and desktop applications platform with loopback redirect URIs, no secret, "Allow public client flows" enabled. Used by the CLI tool by setting PROXMOX_REALM=entra-cli.
This works but means:
- Two IdP app registrations to maintain, with parallel group/claim mappings.
- Users appear in PVE as user@entra-ui and user@entra-cli, requiring duplicated ACLs or a shared group with ACLs granted to the group.
- New automation tools have to know to target the CLI realm specifically.
What would help
Either of these would close the gap; (1) is the more spec-aligned answer, (2) is the smaller change:
- Public-client + PKCE support in the OIDC realm. Add a realm flag (e.g. client-type = confidential | public, or pkce = 1) that, when set, causes PVE to perform a PKCE-protected auth code flow and omit client credentials on token exchange. The same realm could then serve UI and CLI as long as the IdP app registration is configured as a public client with PKCE.
- Per-request override. Allow the caller of /access/openid/auth-url and /access/openid/login to opt into a public-client exchange for that specific code, e.g. by sending a public=1 flag alongside redirect-url. PVE would then skip sending the secret for that exchange. UI flows continue to use the confidential exchange by default. This keeps the single-realm/single-app-registration model viable.
Either change would let one realm and one IdP app registration cleanly serve both the UI and CLI/automation use cases.
Reproduction steps
- Configure an OIDC realm against any IdP that distinguishes public and confidential clients, with client-key set.
- Register a Web-platform redirect URI for the PVE UI in the IdP. Log in via the UI — works.
- From a script, POST /api2/json/access/openid/auth-url with redirect-url=http://127.0.0.1:<port>/callback. Register the loopback URI on a public-client platform in the IdP.
- Complete the flow and POST /api2/json/access/openid/login with the resulting code. Observe the token exchange fail at the IdP with an error along the lines of "client_secret is not allowed for public clients" or equivalent.
- Unset client-key on the realm. Repeat step 4 — succeeds. Repeat step 2 — fails.
Happy to provide IdP-side error payloads and PVE-side debug logs if useful. Also happy to test patches against our cluster.
Related code
Relevant call sites in pve-access-control (paths may differ across versions):
- PVE::API2::OpenId::auth_url — constructs the IdP auth URL from realm config + caller-supplied redirect-url.
- PVE::API2::OpenId::login — performs the token exchange via PVE::RS::OpenId / proxmox-openid.
- proxmox-openid (Rust) — the underlying OIDC client; this is where the confidential-client assumption is baked in.
Thanks for reading — happy to clarify anything or move this to the bug tracker if that's a better venue.