Skip to content

feat: support PKCE code_verifier in exchange_auth_code_for_tokens - #791

Open
gagalago wants to merge 2 commits into
auth0:masterfrom
gagalago:feat/pkce-code-verifier
Open

feat: support PKCE code_verifier in exchange_auth_code_for_tokens#791
gagalago wants to merge 2 commits into
auth0:masterfrom
gagalago:feat/pkce-code-verifier

Conversation

@gagalago

Copy link
Copy Markdown

Changes

Adds an optional code_verifier keyword to Auth0::Api::AuthenticationEndpoints#exchange_auth_code_for_tokens, so the Authorization Code Flow with PKCE can be completed through the SDK.

def exchange_auth_code_for_tokens(
  code,
  redirect_uri: nil,
  client_id: @client_id,
  client_secret: @client_secret,
  code_verifier: nil
)

The parameter is sent only when provided:

request_params[:code_verifier] = code_verifier unless code_verifier.nil?

No endpoint is added, removed or changed. POST /oauth/token is already the target; this only allows one more documented body parameter to reach it. The keyword is appended last and defaults to nil, so every existing call site behaves exactly as before — when code_verifier is omitted the request body is byte-for-byte what it is today.

Usage, completing the flow that authorization_url can already start via additional_parameters:

code_verifier = Base64.urlsafe_encode64(SecureRandom.hex(32), padding: false)
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)

client = Auth0Client.new(
  client_id: client_id,
  client_secret: client_secret,
  domain: domain,
  additional_parameters: {
    code_challenge: code_challenge,
    code_challenge_method: "S256"
  }
)

# Send the user to client.authorization_url(redirect_uri), then exchange the code:
tokens = client.exchange_auth_code_for_tokens(
  code,
  redirect_uri: redirect_uri,
  code_verifier: code_verifier
)

References

In #305 a maintainer wrote that "code_verifier specifically should be a first-class parameter on that method, and should be optional", which is the shape implemented here. That thread also documented a workaround that reaches request_with_retry directly; it still works, but it depends on a method outside the documented public API.

code_verifier does not currently appear anywhere in the repository, so as of v6.1.0 there is no supported way to perform a PKCE exchange. For reference, node-auth0 exposes this as authorizationCodeGrantWithPKCE, which requires code and code_verifier.

Testing

Two cases added to test/unit/authentication_endpoints_test.rb, next to the existing exchange_auth_code_for_tokens tests and using the same WebMock body-matching style:

  • test_exchange_auth_code_for_tokens_with_code_verifier — asserts code_verifier is present in the request body when passed
  • test_exchange_auth_code_for_tokens_omits_code_verifier_when_not_provided — asserts the key is absent from the body when it is not, which is what protects existing callers

To run them:

bundle exec rake test TEST=test/unit/authentication_endpoints_test.rb TESTOPTS="--name=/code_verifier/"

I checked that the first test actually fails if the one-line implementation is removed, so it is not a test that passes regardless.

Full suite on Ruby 3.3.9, before and after: 554 runs, 4582 assertions, 0 failures, 0 errors.

Unrelated heads-up while testing on Ruby 4.0.6: the full suite reports one error there, NoMethodError: undefined method 'parse' for class CGI, from CGI.parse at test/unit/authentication_endpoints_test.rb:480 on master. CGI.parse was removed in Ruby 4.0. It reproduces on a clean checkout of master without this branch, and nothing in this change touches CGI — flagging it only because required_ruby_version is >= 3.3.0 with no upper bound, so Ruby 4 users will hit it. Happy to open a separate issue or pull request for that if useful.

One note on style: lib/auth0/api/authentication_endpoints.rb uses single-quoted strings throughout and is listed under AllCops.Exclude in .rubocop.yml ("Files ported verbatim from the legacy ruby-auth0 SDK... Excluded to preserve exact compatibility with the original source"), so I followed the surrounding convention rather than the double_quotes style enforced elsewhere. bundle exec rubocop reports the same 134 offences with and without this branch — it introduces none.

  • This change adds unit test coverage
  • This change adds integration test coverage
  • This change has been tested on the latest version of Ruby

The two new tests pass on both Ruby 3.3.9 and Ruby 4.0.6 (2 runs, 4 assertions, 0 failures).

Checklist

headers: { "Content-Type" => "application/json" }
)

result = @client_secret_instance.send(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new tests run through @client_secret_instance, so the case PKCE is actually built for, a public client with no secret, never gets exercised.
Could we add a third case on a no-secret instance that asserts code_verifier is present and client_secret is absent from the body?

code: code,
redirect_uri: redirect_uri
}
request_params[:code_verifier] = code_verifier unless code_verifier.nil?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds the key only when it is set, with unless code_verifier.nil?, while the other optional inputs on this method (redirect_uri right above, and client_secret) go into the hash as plain nil values and get sent as null. So code_verifier is the one param here that stays out of the body entirely when unset.

It reads inconsistent with the sibling params on the same method, and someone will eventually question the intent. We can add a small comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants