Subject: bug#79242: [PATCH 7/8] Implement OAuth2 PKCE extension (RFC7636)

From: Xiyue Deng <[email-masked]>
Date: Fri, 15 Aug 2025 03:06:13 -0700

Proof Key for Code Exchange is an extension to prevent CSRF and
authorization code injection attacks.  This is implemented in other
OAuth2 providers, e.g. thunderbird, mutt_oauth2.py, etc.  On testing
with predefined credentials, it looks like Outlook requires this
extension for requesting access-token or the connection will be denied
even with a retrieved access-token.  This is opt-in, and is enabled
when passing use-pkce as non-nil.

* packages/oauth2/oauth2.el (oauth2-token): Add code-verifier slot.
* packages/oauth2/oauth2.el (oauth2--generate-code-verifier,
oauth2--get-challenge-from-verifier): Add.
* packages/oauth2/oauth2.el (oauth2--update-plstore): Store
code-verifier in plstore.
* packages/oauth2/oauth2.el (oauth2-request-authorization,
oauth2-request-access, oauth2-auth): Add `code-verifier' parameter and
pass down or add to request URL data.
* packages/oauth2/oauth2.el (oauth2-auth-and-store): Generate
`code-verifier' and pass down.
* packages/oauth2/oauth2-tests.el: Add simple unit tests.
---
 oauth2-tests.el | 23 ++++++++++++
 oauth2.el       | 97 +++++++++++++++++++++++++++++++++++++------------
 2 files changed, 97 insertions(+), 23 deletions(-)

diff --git a/oauth2-tests.el b/oauth2-tests.el
index ae6d9babe3..88708155c6 100644
--- a/oauth2-tests.el
+++ b/oauth2-tests.el
@@ -27,3 +27,26 @@
                               "complex" "1+2@3#4_5/6"
                               "empty2" "")
            "https://localhost?simple=plain&complex=1%2B2%403%234_5%2F6")))
+
+(ert-deftest oauth2--generate-code-verifier-length-test ()
+  ;; base64 encoding on a string of 90 results in 120.
+  (should (=
+           (length (oauth2--generate-code-verifier 90))
+           120)))
+
+(ert-deftest oauth2--get-challenge-from-verifier-test ()
+  ;; Using pre-generated code-verifier values from mutt_oauth2.py for testing.
+  (let ((test-cases
+         '((:verifier
+            "nDe_cq5hGQC6-_OUhE4Y3jVdrPmRVvzSRuNci4efeXeHBiGSqAmVbzMioNMwD1fQn96IL2mChFBzhv2kI02kHNTU1tHI2T9tWn5_Lp9rqy3fGR90WYxYXGKz"
+            :challenge "hqvORBgWMedJHg2HnNs7DcRjEnVuk7gGQi9iBcp7PRs")
+           (:verifier
+            "WItNqcP9W_HFOZV__P5FgYKlbkTOBolU0jWMMIiTTh6rcG3TyoRtV4Ozx7nIJhowhjAjt41gmHwuKgxGhtv1k_5XDj52udYwHdSgqUrmkvhaqYgLADAp7rrf"
+            :challenge "lB2AKQFg6caqfa3u0cnxXihnU69vvGG1cUPRi8_cvpE")))
+        (expected-challenge-length 43))
+    (dolist (test-case test-cases)
+      (let* ((verifier (plist-get test-case :verifier))
+             (challenge (oauth2--get-challenge-from-verifier verifier))
+             (expected-challenge (plist-get test-case :challenge)))
+        (should (string= challenge expected-challenge))
+        (should (= (length challenge) expected-challenge-length))))))
diff --git a/oauth2.el b/oauth2.el
index 8649af0bb8..1ad65d672f 100644
--- a/oauth2.el
+++ b/oauth2.el
@@ -138,6 +138,8 @@ Returns nil if the slot is unavailable."
   (plstore-put plstore (oauth2-token-plstore-id token)
                nil `(:request-cache
                      ,(oauth2-token-request-cache token)
+                     :code-verifier
+                     ,(oauth2-token-code-verifier token)
                      :access-response
                      ,(oauth2-token-access-response token)))
   (plstore-save plstore))
@@ -176,8 +178,33 @@ address to build the full URL."
                       (url-encode-url (car data))))))
     (concat address "?" data-str)))
 
+(defun oauth2--generate-code-verifier (&optional verifier-length)
+  "Generate a random string of VERIFIER-LENGTH long for code_challenge.
+The string should be of length 43 to 128 (inclusive).  If
+VERIFIER-LENGTH is nil, we default to 90 as mutt_oauth2.py did.  See
+RFC7636 for more details."
+  (let* ((func-name "oauth2--generate-code-verifier")
+         (valid-chars
+          "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
+         (verifier-length (or verifier-length 90))
+         result-list)
+    (dotimes (_ verifier-length)
+      (let ((i (random (length valid-chars))))
+        (push (substring valid-chars i (1+ i)) result-list)))
+    (base64url-encode-string (string-join result-list))))
+
+(defun oauth2--get-challenge-from-verifier (code-verifier)
+  "Get the code_challenge from CODE-VERIFIER."
+  ;; base64url-encode-string returns a string that ends with '=' so the last
+  ;; character should be skipped.
+  (substring (base64url-encode-string (secure-hash 'sha256
+                                                   code-verifier
+                                                   nil nil t))
+             0 -1))
+
 (defun oauth2-request-authorization (auth-url client-id &optional scope state
-                                              redirect-uri user-name)
+                                              redirect-uri user-name
+                                              code-verifier)
   "Request OAuth authorization at AUTH-URL by launching `browse-url'.
 CLIENT-ID is the client id provided by the provider which uses
 REDIRECT-URI when requesting an access-token.  The default redirect_uri
@@ -186,20 +213,30 @@ identifies the resources that your application can access on the user's
 behalf.  STATE is a string that your application uses to maintain the
 state between the request and redirect response. USER-NAME is used to
 provide the login_hint which will fill the login user name on the
-requesting webpage to save users some typing.
+requesting webpage to save users some typing.  CODE-VERIFIER when
+provided enables the PKCE extension and will generate and provide the
+code_challenge using method S256 when requesting authorization.
 
 Returns the code provided by the service."
   (let* ((func-name "oauth2-request-authorization")
-         (url (oauth2--build-url auth-url
-                                 "client_id" client-id
-                                 "response_type" "code"
-                                 "redirect_uri"
-                                 (or redirect-uri oauth2--default-redirect-uri)
-                                 "scope" scope
-                                 "state" state
-                                 "login_hint" user-name
-                                 "access_type" "offline"
-                                 "prompt" "consent")))
+         (url (let ((param `("client_id" ,client-id
+                             "response_type" "code"
+                             "redirect_uri"
+                             ,(or redirect-uri oauth2--default-redirect-uri)
+                             "scope" ,scope
+                             "state" ,state
+                             "login_hint" ,user-name
+                             "access_type" "offline"
+                             "prompt" "consent")))
+                (when (and code-verifier
+                           (not (string-empty-p code-verifier)))
+                  (setq param (plist-put param "code_challenge"
+                                         (oauth2--get-challenge-from-verifier
+                                          code-verifier)))
+                  (setq param (plist-put param
+                                         "code_challenge_method" "S256")))
+                (add-to-list 'param auth-url)
+                (apply 'oauth2--build-url param))))
     (oauth2--do-trivia "[%s]: url: %s" func-name url)
     (browse-url url)
     (read-string (concat "Follow the instruction on your default browser, or "
@@ -236,12 +273,14 @@ Returns the code provided by the service."
   access-token
   refresh-token
   request-cache
+  code-verifier
   auth-url
   token-url
   access-response)
 
 (defun oauth2-request-access (auth-url token-url client-id client-secret code
-                                       &optional redirect-uri host-name)
+                                       &optional redirect-uri host-name
+                                       code-verifier)
   "Request OAuth access.
 TOKEN-URL is the URL for making the request.  CLIENT-ID and
 CLIENT-SECRET are provided by the service provider.  The CODE should be
@@ -251,7 +290,8 @@ usually \"urn:ietf:wg:oauth:2.0:oob\".  HOST-NAME is the server to
 request access, e.g. IMAP or SMTP server address.  Its value should
 match the one when calling `oauth2-auth-and-store'.  Leaving HOST-NAME
 as nil effectively disables caching and will request a new token on each
-request.
+request.  CODE-VERIFIER is used for the PKCE extension and is required
+when it was already provided during authorization.
 
 Returns an `oauth2-token'."
   (when code
@@ -262,6 +302,7 @@ Returns an `oauth2-token'."
                               "client_id" client-id
                               "client_secret" client-secret
                               "code" code
+                              "code_verifier" code-verifier
                               "redirect_uri" (or redirect-uri
                                                  oauth2--default-redirect-uri)
                               "grant_type" "authorization_code")))
@@ -275,6 +316,7 @@ Returns an `oauth2-token'."
                          :access-token access-token
                          :refresh-token refresh-token
                          :request-cache request-cache
+                         :code-verifier code-verifier
                          :auth-url auth-url
                          :token-url token-url
                          :access-response access-response))))
@@ -330,7 +372,7 @@ TOKEN should be obtained with `oauth2-request-access'."
 ;;;###autoload
 (defun oauth2-auth (auth-url token-url client-id client-secret
                              &optional scope state redirect-uri user-name
-                             host-name)
+                             host-name code-verifier)
   "Authenticate application via OAuth2."
   (oauth2-request-access
    auth-url
@@ -338,9 +380,10 @@ TOKEN should be obtained with `oauth2-request-access'."
    client-id
    client-secret
    (oauth2-request-authorization auth-url client-id scope state redirect-uri
-                                 user-name)
+                                 user-name code-verifier)
    redirect-uri
-   host-name))
+   host-name
+   code-verifier))
 
 (defun oauth2-compute-id (auth-url token-url scope client-id user-name)
   "Compute an unique id mainly to use as plstore id.
@@ -351,7 +394,7 @@ USER-NAME to ensure the plstore id is unique."
 ;;;###autoload
 (defun oauth2-auth-and-store (auth-url token-url scope client-id client-secret
                                        &optional redirect-uri state user-name
-                                       host-name)
+                                       host-name use-pkce)
   "Request access to a resource and store it.
 AUTH-URL and TOKEN-URL are provided by the service provider.  CLIENT-ID
 and CLIENT-SECRET should be generated by the service provider when a
@@ -362,7 +405,9 @@ redirect response. USER-NAME is the login user name and is required to
 provide a unique plstore id for users on the same service provider.
 HOST-NAME is the server to request authentication, e.g. IMAP or SMTP
 server address.  Leaving HOST-NAME as nil effectively disables caching
-and will request a new token on each refresh.
+and will request a new token on each refresh.  USE-PKCE controls whether
+to enable the PKCE extension of RFC7636 which is supported by most
+OAuth2 providers and recommended.
 
 Returns an `oauth2-token'."
   ;; We store a MD5 sum of all URL
@@ -380,7 +425,8 @@ Returns an `oauth2-token'."
                (request-cache (plist-get plist :request-cache))
                (access-token (or (oauth2--get-from-request-cache
                                   request-cache host-name :access-token)
-                                 "")))
+                                 ""))
+               (code-verifier (plist-get plist :code-verifier)))
          (progn
            (oauth2--do-trivia "[%s]: found matching plstore-id from plstore."
                               func-name)
@@ -390,6 +436,7 @@ Returns an `oauth2-token'."
                               :access-token access-token
                               :refresh-token refresh-token
                               :request-cache request-cache
+                              :code-verifier code-verifier
                               :auth-url auth-url
                               :token-url token-url
                               :access-response access-response))
@@ -397,9 +444,13 @@ Returns an `oauth2-token'."
         (concat "[%s]: no matching plstore-id found or cache invalid.  "
                 "Requesting new oauth2-token.")
         func-name)
-       (let ((token (oauth2-auth auth-url token-url
-                                 client-id client-secret scope state
-                                 redirect-uri user-name host-name)))
+       (let* ((code-verifier (if use-pkce
+                                 (oauth2--generate-code-verifier)
+                               ""))
+              (token (oauth2-auth auth-url token-url
+                                  client-id client-secret scope state
+                                  redirect-uri user-name host-name
+                                  code-verifier)))
          ;; Set the plstore
          (setf (oauth2-token-plstore-id token) plstore-id)
          (oauth2--update-plstore plstore token)
-- 
2.47.2