Skip to content

get_authorization_url_with_pkce accepts client_id and silently ignores it (get_jwks_url honours it) #700

Description

@toolshedlabs-hash

Summary

On workos 10.0.0, user_management.get_authorization_url_with_pkce(client_id=...) accepts the
argument and ignores it. The URL it returns carries the client_id the WorkOSClient was
constructed with.

src/workos/user_management/_resource.py:2331 takes it and documents it:

def get_authorization_url_with_pkce(
    self,
    *,
    redirect_uri: str,
    client_id: Optional[str] = None,
    ...
client_id: The WorkOS client ID. Defaults to the client's configured ID.

Then at :2374 it calls get_authorization_url(...) without passing it, and that method has no
client_id parameter to pass it to. It reads the constructor value instead, at :2986:

params["response_type"] = "code"
if self._client.client_id is not None:
    params["client_id"] = self._client.client_id

The async copy at :4690 is identical.

Two methods in the same hand-written block do honour the same argument:

:2277  resolved_client_id = client_id or self._client._require_client_id()      # get_jwks_url
:2417  resolved_client_id = client_id or (self._client.client_id or "")         # authenticate_with_code_pkce

authenticate_with_code_pkce is the other half of the PKCE pair. So a caller who passes a
per-tenant client_id to both gets an authorization URL for one application and a token exchange
for another.

Reproduction

workos 10.0.0, Python 3.14.0, fresh venv. Constructor client_id is client_CONSTRUCTOR, the
override passed on every call is client_OVERRIDE. base_url points at a loopback origin that
records the request body, so the token exchange row is read off the wire. Nothing leaves the
machine.

client = WorkOSClient(api_key="sk_test", client_id="client_CONSTRUCTOR", base_url=LOOPBACK)
um = client.user_management

um.get_authorization_url_with_pkce(redirect_uri=CB, client_id="client_OVERRIDE")
um.get_authorization_url_with_pkce(redirect_uri=CB)
um.get_jwks_url(client_id="client_OVERRIDE")
um.authenticate_with_code_pkce(code=..., code_verifier=..., client_id="client_OVERRIDE")
call client_id actually used
get_authorization_url_with_pkce(client_id=OVERRIDE) client_CONSTRUCTOR
get_authorization_url_with_pkce() no override client_CONSTRUCTOR
get_jwks_url(client_id=OVERRIDE) client_OVERRIDE
authenticate_with_code_pkce(client_id=OVERRIDE) client_OVERRIDE
AsyncWorkOSClient same call client_CONSTRUCTOR

The second row is there so the first one means something. Without it, "the override is ignored"
would be indistinguishable from "the URL has no client_id at all". Rows three and four are the two
siblings that do the right thing.

Suggested fix

Either forward it, by giving get_authorization_url an optional client_id that falls back to
self._client.client_id, or drop the parameter from
get_authorization_url_with_pkce so the signature stops promising something it cannot do. The
first matches what authenticate_with_code_pkce already does, so the pair would agree.

This is a smaller problem than it looks at first glance. It only affects callers who set client_id
per request rather than on the client, which is the multi-tenant case the argument exists for.

Happy to open a PR either way, though I noticed most of this file is generated and the PKCE
methods sit inside the @oagen-ignore block at :2261 to :2444, so I wanted to check which
direction you would want before touching it.

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