proxmox oidc PKCE support

davidludvigsson

New Member
May 20, 2026
1
0
1

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:


  1. User clicks the realm in the PVE login dropdown.
  2. PVE constructs the auth URL with redirect_uri=https://<pve-host>:8006.
  3. Browser → IdP → user authenticates → browser is redirected back to https://<pve-host>:8006/?code=...&state=....
  4. PVE's frontend posts the code + state to /access/openid/login.
  5. PVE backend exchanges the code at the IdP's token endpoint, sending client_id + client_secret (the configured client-key).
  6. 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:


  1. Binding a short-lived HTTP listener on 127.0.0.1:<random-port>.
  2. 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.
  3. Opening the browser at the returned URL. User signs in at the IdP.
  4. IdP redirects back to http://127.0.0.1:<port>/callback?code=...&state=... — caught by the local listener.
  5. Script posts the code + state + redirect-url to /api2/json/access/openid/login.
  6. PVE backend tries to exchange the code at the IdP's token endpoint.
  7. 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:


  1. 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.
  2. 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​


  1. Configure an OIDC realm against any IdP that distinguishes public and confidential clients, with client-key set.
  2. Register a Web-platform redirect URI for the PVE UI in the IdP. Log in via the UI — works.
  3. 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.
  4. 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.
  5. 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.