Skip to content

OAuth2 Provider: option to require PKCE for public clients #25520

Description

@markussiebert

Is your feature request related to a problem?

authentik cannot require PKCE when acting as an OpenID Provider. It only verifies PKCE when the authorization code already carries a challenge:

# authentik/providers/oauth2/token/authorization_code.py
if self.authorization_code.code_challenge:
    if not self.code_verifier:
        raise TokenError("invalid_grant")

If a client never sends code_challenge in the authorization request, there is nothing to verify. The authorization endpoint has a PKCE hook, but it only validates the transformation method, and only when a challenge is actually present:

# authentik/providers/oauth2/views/authorize.py
def check_code_challenge(self):
    """PKCE validation of the transformation method."""
    if self.code_challenge and self.code_challenge_method not in [
        PKCE_METHOD_PLAIN,
        PKCE_METHOD_S256,
    ]:
        raise AuthorizeError(...)

and create_code() attaches the challenge conditionally, issuing a perfectly valid code without one otherwise:

if self.code_challenge and self.code_challenge_method:
    code.code_challenge = self.code_challenge
    code.code_challenge_method = self.code_challenge_method

client_type is not consulted anywhere in these paths, and the OAuth2Provider model has no PKCE field, so PKCE is not implicitly required for public clients either. This is easy to confirm against a running instance: an authorization request for a public client that omits code_challenge entirely is accepted and redirected to the flow.

For a public client this is exploitable by a party that controls a registered redirect target:

  1. A local process (a second app on the phone claiming the same custom scheme, or any process binding a loopback port for a client whose redirect URI is http://localhost:*) starts its own authorization request with the provider's client_id — which is not a secret — and omits code_challenge.
  2. The user is shown a genuine authentik login page, on the real domain, with valid TLS, and authenticates.
  3. The code is delivered to the attacker's redirect target.
  4. The token exchange succeeds: no client secret is required (public client) and no PKCE has to be satisfied (the code carries no challenge).

With a confidential client step 4 fails with invalid_client. With a public client there is nothing left to stop it. PKCE is exactly the mitigation RFC 8252 §8.6 names for this, but the operator currently has no way to insist on it — the honest client uses PKCE and the dishonest one simply doesn't.

This is not a niche configuration. Public clients are what upstream vendors ship, and some of them document require_pkce as required:

  • ownCloud Infinite Scale / OpenCloud: "All clients provided by OpenCloud (Web, Desktop, Android and iOS) are implemented as public clients using the authorization code flow with PKCE." Their integration documentation configures each client with public: true, require_pkce: true, pkce_challenge_method: 'S256'. An authentik operator can satisfy the first of the three and neither of the other two. The desktop client's redirect URI is a loopback regex (http://localhost(:[0-9]+)?/.*), and its client ID is hardcoded and identical in every installation worldwide.
  • paperless-ngx with the swift-paperless iOS app: "The provider must support PKCE for public clients without a client secret, since a client secret cannot be safely stored in the app." The callback is the custom scheme x-paperless://oidc-callback.

Describe the solution you'd like

A field on OAuth2Provider, e.g. require_pkce: bool. The natural place to enforce it is the existing check_code_challenge(), which already runs for every authorization request — it would gain a "challenge missing while required" branch alongside the method validation it does today. No new call site is needed.

Two refinements worth considering:

  • Also allow requiring S256. authorize.py currently defaults code_challenge_method to "plain" when the parameter is absent, and authorization_code.py accepts plain by comparing the verifier directly. A pkce_challenge_method setting (or folding it into the same field) would let operators reject plain, which RFC 9700 and OAuth 2.1 discourage.
  • Consider defaulting it to true for newly created public clients. Existing providers would keep today's behaviour, so nothing breaks on upgrade, but the secure choice becomes the default for exactly the client type that needs it.

code_challenge_methods_supported is already published in provider metadata (#5991), so discovery would stay consistent.

Describe alternatives that you've considered

  • Make the client confidential. Not possible: the clients above run on the user's device and hold no secret. Their vendors document a public client as a requirement, and their client IDs are compiled into shipped binaries.
  • Rely on strict redirect URIs. Already in use, and they do prevent redirecting to an arbitrary host. They do not help here, because the attacker's target is a registered URI — a custom scheme another app on the same device can claim, or a loopback port any local process can bind. RFC 8252 §8.6 acknowledges this and names PKCE as the mitigation.
  • Rely on the client using PKCE. This protects the honest flow but not this one: the attacker starts their own authorization request and simply omits the challenge. Client behaviour cannot be a server-side control.
  • An expression policy on the authorization flow. This one does appear to work, and I want to be upfront that a workaround exists. authorize.py passes the parsed parameters into the flow plan (PLAN_CONTEXT_PARAMS: self.params in the planner.plan(...) call), so a policy can reach request.context["goauthentik.io/providers/oauth2/params"].code_challenge and deny when it is empty. But that means depending on an internal, namespaced context key and the attribute layout of the OAuthAuthorizationParams dataclass — neither is a documented interface, and both can change in any refactor without it being a breaking change. It also has to be bound and maintained per flow rather than sitting on the provider it describes, and it fails open if someone attaches a different flow to the provider. That seems like the wrong shape for protocol-level validation that RFC 8252 treats as the primary mitigation.
  • Front authentik with something that rejects requests lacking code_challenge. Fragile, duplicates protocol knowledge in a proxy, and cannot distinguish which provider the request targets without parsing client_id and reimplementing provider lookup.

Additional context

AI disclosure, per AI_POLICY.md: this issue was drafted with the help of an LLM (Claude). I set the direction and the argument, every source reference was checked against this repository rather than recalled, and the behaviour described was reproduced against my own instance before writing it down. One claim in the alternatives section — that flow policies could not see the authorization parameters — turned out to be wrong on checking and is corrected above rather than dropped. I have edited the text and can answer questions about any part of it without assistance.

This is not CVE-2023-48228. That advisory (fixed in 2023.8.5 / 2023.10.4) was about failing to verify the verifier when the code did carry a challenge, and the fix is the code quoted above. This request is about being able to require that the challenge is there in the first place — a policy control, not a missing check. authentik's behaviour today is spec-conformant; RFC 7636 §4.5 only obliges the server to verify when a challenge was issued.

authentik already has this setting, on the other side of the connection. OAuthSource gained a PKCE field in authentik/sources/oauth/migrations/0012_oauthsource_pkce.py, for when authentik acts as a client against a foreign IdP. Issue #16398 is authentik tripping over exactly this control at Okta ("PKCE code challenge is required by the application" when Okta's Require PKCE as additional verification is enabled). So the capability is understood and consumed by authentik — it just cannot be offered by it.

I could not find an existing request for this. Searching issues for pkce, code_challenge, public client, require pkce, enforce pkce and mandatory pkce returns only two open results, both unrelated (#17491, about a nonce for OIDC Sources, and #24179, a Postgres JIT performance issue); everything else is closed and concerns authentik acting as an OAuth client. Discussions return a single pkce hit, also unrelated. So I assume this simply hasn't been raised rather than having been considered and declined — happy to be pointed at an existing thread instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions