diff --git a/Cask b/Cask index a594239..c9e1c53 100644 --- a/Cask +++ b/Cask @@ -1,7 +1,9 @@ (source melpa) (source gnu) -(package-file "org-gcal.el") +(package-descriptor "org-gcal-pkg.el") + +(files :defaults) (development (depends-on "el-mock") diff --git a/Makefile b/Makefile index 97b2c2a..3b2a22b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ THIS_MAKEFILE_DIR = $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) EMACS ?= emacs -SRC=org-gcal.el org-generic-id.el +SRC=org-gcal.el org-generic-id.el oauth2-auto.el TEST=test/org-gcal-test.el test/org-generic-id-test.el BUILD_LOG = build.log CASK ?= cask @@ -8,7 +8,7 @@ PKG_DIR := $(shell $(CASK) package-directory) ELCFILES = $(SRC:.el=.elc) .DEFAULT_GOAL := all -.PHONY: all clean load-path compile test elpa +.PHONY: all clean load-path compile test elpa update-oauth2-auto all: compile test @@ -27,3 +27,8 @@ compile: $(SRC) elpa test: $(SRC) $(TEST) elpa $(CASK) exec ert-runner -L $(THIS_MAKEFILE_DIR) \ $(foreach test,$(TEST),$(addprefix $(THIS_MAKEFILE_DIR)/,$(test))) + +# Vendor oauth2-auto from my fork until oauth2-auto is added to MELPA. +update-oauth2-auto: + curl -o oauth2-auto.el \ + https://raw.githubusercontent.com/telotortium/emacs-oauth2-auto/main/oauth2-auto.el diff --git a/README.org b/README.org index 2e04f1e..f86f9c8 100644 --- a/README.org +++ b/README.org @@ -30,6 +30,9 @@ screen that says "This app isn't verified". You will need to click on the - [[https://github.com/tkf/emacs-request][tkf/emacs-request]] - [[https://github.com/jwiegley/alert][jwiegley/alert]] - [[https://elpa.gnu.org/packages/persist.html][~persist~]] +- [[https://github.com/skeeto/emacs-aio][skeeto/emacs-aio]] +- [[https://github.com/rhaps0dy/emacs-oauth2-auto][rhaps0dy/emacs-oauth2-auto]] (actually using vendored fork + [[https://github.com/telotortium/emacs-oauth2-auto/tree/main][telotortium/emacs-oauth2-auto]]) =org-gcal= is now available in the famous emacs package repo [[http://melpa.milkbox.net/][MELPA]], so the recommended way is to install it through Emacs package management system. @@ -92,7 +95,7 @@ on resolving this issue. ICAL, and HTML tags, you will see your Calendar ID. 14. Copy the Calendar ID for use in the settings below, where you will - use it as the first element in the org-gcal-file-alist for + use it as the first element in the org-gcal-fetch-file-alist for associating calendars with specific org files. You can associate different calendars with different org files, so repeat this for each calendar you want to use. @@ -100,11 +103,21 @@ on resolving this issue. ** Setting example #+begin_src elisp -(require 'org-gcal) (setq org-gcal-client-id "your-id-foo.apps.googleusercontent.com" org-gcal-client-secret "your-secret" org-gcal-fetch-file-alist '(("your-mail@gmail.com" . "~/schedule.org") ("another-mail@gmail.com" . "~/task.org"))) +(require 'org-gcal) +#+end_src + +*** Note + +This package uses ~plstore~ as a dependency for storing OAuth tokens. In order +to avoid getting prompted all the time for the password to your plstore, it is +recommended that you put the following in your init.el: + +#+begin_src elisp +(setq plstore-cache-passphrase-for-symmetric-encryption t) #+end_src ** Multiple accounts @@ -247,12 +260,12 @@ in any way. ** Commands *** =org-gcal-fetch= - Fetch Google calendar events for all calendar IDs in =org-gcal-file-alist= + Fetch Google calendar events for all calendar IDs in =org-gcal-fetch-file-alist= occurring between =org-gcal-up-days= before today and =org-gcal-down-days= after today. If the events have already been retrieved and can be located using their Org-mode headline IDs, update the event in place. Otherwise, insert it at the end of the file corresponding to the event's calendar ID in - =org-gcal-file-alist=. Does not update events on the server. + =org-gcal-fetch-file-alist=. Does not update events on the server. *** =org-gcal-sync= Like =org-gcal-fetch=, but also update events on the server if they have changed locally. @@ -275,12 +288,6 @@ in any way. If the event has changed on the server since it was last retrieved (detected using the =ETag= property), automatically update the headline using the event data from the server instead of updating the event on the server. -*** =org-gcal-request-token= - Request new OAuth access and refresh tokens. You should not need to call - this function in normal use, since it is called automatically on the first - run. However, you can call it again if for some reason the tokens stop - working. This should be rare - =org-gcal= will automatically refresh the - OAuth access token when it expires (every 3600 seconds). ** Deleting events diff --git a/oauth2-auto.el b/oauth2-auto.el new file mode 100644 index 0000000..0a8055a --- /dev/null +++ b/oauth2-auto.el @@ -0,0 +1,549 @@ +;;; oauth2-auto.el --- Automatically refreshing OAuth 2.0 tokens -*- lexical-binding: t; -*- + +;; Copyright (C) 2011-2021 Free Software Foundation, Inc + +;; Author: Adrià Garriga-Alonso +;; URL: https://github.com/rhaps0dy/emacs-oauth2-auto +;; Version: 0.1 +;; Keywords: comm oauth2 +;; Package-Requires: ((emacs "26.1") (aio "1.0") (alert "1.2") (dash "2.19")) + +;; This file is part of GNU Emacs. + +;; GNU Emacs is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; GNU Emacs is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: + +;; State machine to fetch OAuth 2.0 tokens for various email accounts. +;; +;; Based on ~mutt_oauth2.py~, which is Copyright (C) 2020 Alexander Perlis, +;; licensed under the GPLv3 or later. +;; +;; The entry points are `oauth2-auto-plist' and the convenience function +;; `oauth2-auto-access-token'. + +;;; Code: +(require 'plstore) +(require 'aio) ; promises +(eval-when-compile (require 'dash)) ; `--map' +(require 'alert) ; `alert' to give the user a heads up to go to their browser and log in +(require 'url-auth) + +(defgroup oauth2-auto nil + "Automatically refreshing OAuth 2.0 tokens." + :group 'comm) + + +;; Endpoints and client secret/id used for various OAuth2 providers. + +(defcustom oauth2-auto-microsoft-default-tenant "common" + "Default tenant ID for Microsoft OAuth2." + :group 'oauth2-auto + :type 'string) + +(defcustom oauth2-auto-microsoft-client-id "" + "Default client ID for Microsoft OAuth2." + :group 'oauth2-auto + :type 'string) + +(defcustom oauth2-auto-microsoft-client-secret "" + "Default client secret for Microsoft OAuth2." + :group 'oauth2-auto + :type 'string) + +(defcustom oauth2-auto-google-client-id "" + "Default client ID for Google OAuth2." + :group 'oauth2-auto + :type 'string) + +(defcustom oauth2-auto-google-client-secret "" + "Default client secret for Google OAuth2." + :group 'oauth2-auto + :type 'string) + +(defcustom oauth2-auto-additional-providers-alist '() + "Additional OAuth2 providers following `oauth2-auto--default-providers'." + :group 'oauth2-auto + :type 'alist) + +(defun oauth2-auto--default-providers () + "Default OAuth2 providers." + (let ((ms-oauth2-url (concat "https://login.microsoftonline.com/" + oauth2-auto-microsoft-default-tenant + "/oauth2/v2.0/"))) + `((google + (authorize_url . "https://accounts.google.com/o/oauth2/auth") + (token_url . "https://oauth2.googleapis.com/token") + (scope . "https://mail.google.com/ https://www.googleapis.com/auth/calendar.events") + (client_id . ,oauth2-auto-google-client-id) + (client_secret . ,oauth2-auto-google-client-secret)) + (microsoft + (authorize_url . ,(concat ms-oauth2-url "authorize")) + (token_url . ,(concat ms-oauth2-url "token")) + (tenant . ,oauth2-auto-microsoft-default-tenant) + (scope . "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/POP.AccessAsUser.All https://outlook.office.com/SMTP.Send") + (client_id . ,oauth2-auto-microsoft-client-id) + (client_secret . ,oauth2-auto-microsoft-client-secret))))) + + +(defun oauth2-auto-providers-alist () + "Return all available OAuth2 providers. +This combines the providers specified in `oauth2-auto--default-providers' and +`oauth2-auto-additional-providers-alist'." + (append oauth2-auto-additional-providers-alist + (oauth2-auto--default-providers))) + + +;; Main data structure + +(defun oauth2-auto--make-plist (response plist) + "Make the main data structure of the module. + +This data structure is a plist containing an :access-token, :refresh-token, and +:expiration. This function takes RESPONSE from ‘oauth2-auto--request’, and data +from PLIST if non-nil. The return value is intended to be stored in plstore." + (let ((refresh-token (or (cdr (assoc 'refresh_token response)) + (plist-get plist :refresh-token)))) + (unless refresh-token + (error "No refresh token in response %s or plist %s" + (pp-to-string response) (pp-to-string plist))) + `(:access-token ,(cdr (assoc 'access_token response)) + :refresh-token ,refresh-token + :expiration ,(+ (oauth2-auto--now) + (cdr (assoc 'expires_in response)))))) + + +;; Checking token expiration + +(defun oauth2-auto--now () + "Current epoch in seconds." + (seconds-to-time nil 'integer)) + +(defun oauth2-auto--plist-needs-refreshing (plist) + "Return non-nil if the authentication-token in PLIST needs refreshing." + (or (not (plist-get plist :expiration)) + (> (oauth2-auto--now) + (plist-get plist :expiration)))) + + +;; Cache and plstore read/write + +(defcustom oauth2-auto-plstore (concat user-emacs-directory "oauth2-auto.plist") + "File to store the authenticated accounts to." + :group 'oauth2-auto + :type 'file) + +; TODO remove cache or invalidate it properly when other programs write to disk +(defvar oauth2-auto--plstore-cache + (make-hash-table :test 'equal) + "Cache the values written to and read from the plstore.") + +(defun oauth2-auto--compute-id (username provider) + "Unique ID for a USERNAME and PROVIDER." + (url-hexify-string (pp-to-string (list username provider)))) + +(defun oauth2-auto--plstore-write (username provider plist) + "Save data PLIST for USERNAME and PROVIDER to the plstore and cache." + (let ((id (oauth2-auto--compute-id username provider)) + (plstore (plstore-open oauth2-auto-plstore))) + (unwind-protect + (prog1 plist + (advice-add #'insert :before #'oauth2-auto--insert-break-on-secret-entries) + (plstore-put plstore id nil plist) + ;; Seems like we occasionally end up with a killed buffer in PLSTORE - reinitialize it in that case. + (if (buffer-live-p (plstore--get-buffer plstore)) + (progn + (plstore-save plstore) + (puthash id plist oauth2-auto--plstore-cache)) + (plstore-close plstore) + (oauth2-auto--plstore-write username provider plist))) + (plstore-close plstore) + (advice-remove #'insert #'oauth2-auto--insert-break-on-secret-entries)))) + +(defun oauth2-auto--insert-break-on-secret-entries (&rest args) + "Break if trying to insert secret entries outside of plstore buffer. +ARGS are those passed to ‘insert’. + +This function is added as before advice to ‘insert’ to attempt to reproduce and +fix https://github.com/rhaps0dy/emacs-oauth2-auto/issues/6." + (when + (and + (stringp (buffer-file-name)) + (not (file-equal-p oauth2-auto-plstore (buffer-file-name))) + (equal ";;; secret entries\n" (nth 0 args)) + (backtrace-frames 'oauth2-auto--plstore-write)) + (error "BUG: Attempted to write ‘oauth2-auto’ keys to %s, not ‘oauth2-auto-plstore’ (%s). Please report to https://github.com/rhaps0dy/emacs-oauth2-auto/issues/6." + (buffer-file-name) oauth2-auto-plstore))) + +(defun oauth2-auto--plstore-read (username provider) + "Read the data for USERNAME and PROVIDER from the cache, else from plstore. +Cache data if a miss occurs." + (let ((id (oauth2-auto--compute-id username provider))) + ; Assume cache is invalidated. FIXME + (or nil ;(gethash id oauth2-auto--plstore-cache) + (let ((plstore (plstore-open oauth2-auto-plstore))) + (unwind-protect + (puthash id + (cdr (plstore-get plstore id)) + oauth2-auto--plstore-cache) + (plstore-close plstore)))))) + + + +;; Main entry point + +(aio-defun oauth2-auto-plist (username provider) + "Returns a 'oauth2-token structure for USERNAME and PROVIDER." + ; Check the plstore for the requested username and provider + (let ((plist (oauth2-auto--plstore-read username provider))) + (if (not (oauth2-auto--plist-needs-refreshing plist)) + ; If expiration time is found and hasn't happened yet + plist + ; Otherwise refresh or authenticate the user, and write the result to + ; plstore. + (oauth2-auto--plstore-write + username provider + (aio-await + (oauth2-auto-refresh-or-authenticate username provider plist)))))) + +(aio-defun oauth2-auto-force-reauth (username provider) + "Authenticates USERNAME with PROVIDER again and saves to the plstore." + (oauth2-auto--plstore-write + username provider + (aio-await + (oauth2-auto-authenticate username provider)))) + + +(defun oauth2-auto-poll-promise (promise) + "Synchronously wait for PROMISE, polling every SECONDS seconds." + (let ((seconds 3)) + (while (null (aio-result promise)) + (sleep-for seconds)) + (funcall (aio-result promise)))) + +;;;###autoload +(defun oauth2-auto-plist-sync (username provider) + "Synchronously call ‘oauth2-auto-plist’ and return result. +For USERNAME and PROVIDER, see." + (oauth2-auto-poll-promise (oauth2-auto-plist username provider))) + +(aio-defun oauth2-auto-access-token (username provider) + "Returns access-token string used to authenticate USERNAME to PROVIDER." + (plist-get (aio-await (oauth2-auto-plist username provider)) + :access-token)) + +;;;###autoload +(defun oauth2-auto-access-token-sync (username provider) + "Synchronously call ‘oauth2-auto-access-token’ and return result. +For USERNAME and PROVIDER, see." + (oauth2-auto-poll-promise (oauth2-auto-access-token username provider))) + + +;; Making and encoding requests + +(defun oauth2-auto--provider-info (provider) + "Get data for PROVIDER from `oauth2-auto-providers-alist'." + (let ((provider-info (cdr (assoc provider (oauth2-auto-providers-alist))))) + (when (not provider-info) + (error "oauth2-auto: Unknown provider: %s" provider)) + (dolist (key '(client_id client_secret)) + (when (equal "" (cdr (assoc key provider-info))) + (error "oauth2-auto: Provider %s was requested but has no `%s' specified" provider key))) + provider-info)) + +(defun oauth2-auto--urlify-request (alist) + "Make ALIST of (symbol . string) into URL-formatted request." + (mapconcat (lambda (s) (concat (url-hexify-string (symbol-name (car s))) + "=" (url-hexify-string (cdr s)))) + alist "&")) + +(defun oauth2-auto--craft-request-alist (provider-info data-keys extra-alist) + "Make request for PROVIDER-INFO using the info in DATA-KEYS and EXTRA-ALIST." + (append (--filter (memq (car it) data-keys) provider-info) extra-alist)) + +(defun oauth2-auto--request-access-parse () + "Parse the result of an OAuth request. + +Code from `oauth2.el', licensed under GPLv3+. +See https://github.com/emacsmirror/oauth2." + (goto-char (point-min)) + (when (search-forward-regexp "^$" nil t) + (json-read))) + +(aio-defun oauth2-auto--request (provider url-key data-keys extra-alist) + "Asynchronously send a POST request to OAuth2 PROVIDER. +PROVIDER uses the url and data specified under URL-KEY and +DATA-KEYS in the provider info (see `oauth2-auto-providers-alist'). +Also send data in EXTRA-ALIST." + (let* ( + ;; Craft the request first + (provider-info (oauth2-auto--provider-info provider)) + (url (cdr (assoc url-key provider-info))) + (data-alist (oauth2-auto--craft-request-alist + provider-info data-keys extra-alist)) + (data (oauth2-auto--urlify-request data-alist)) + ;; Parameters for `url-retrieve' inside `aio-url-retrieve' + (url-registered-auth-schemes nil) + (url-request-method "POST") + (url-request-data data) + (url-request-extra-headers + '(("Content-Type" . "application/x-www-form-urlencoded"))) + ;; TODO: using `aio-url-retrieve' sometimes results in a hung + ;; connection, so we fall back to ‘url-retrieve-synchronously’ instead. + ;; This method shouldn’t be called very often, only when obtaining the + ;; initial access token and when refreshing tokens, which only happens + ;; every hour or so. Therefore I think this is an acceptable workaround + ;; for now. + (response-buffer (aio-await (url-retrieve-synchronously url))) + (response (with-current-buffer response-buffer + (prog1 (oauth2-auto--request-access-parse) + (kill-buffer (current-buffer)))))) + (cond + ((assoc 'error response) + (error "OAuth error. Request: %s. Response: %s" + (pp-to-string data-alist) (pp-to-string response))) + (t response)))) + + +;; Barebones HTTP server to receive the tokens + +(defun oauth2-auto--httpd-respond (process response) + "Send response for OAuth2 challenge-response. +PROCESS is the server process created in ‘oauth2-auto--browser-request’. +RESPONSE is the HTTP response body to send." + (process-send-string + process (concat "HTTP/1.0 200 OK\n" + "Content-Type: text/plain; charset=utf-8\n" + (format "Content-Length: %i\n\n" (length response)) + response + "\n\n")) + (process-send-eof process)) + +(defmacro oauth2-auto--query-case (&rest cases) + "Handle HTTP queries based on the keys present in ‘query-alist’. +‘query-alist’ is a free variable, bound by the caller of this macro. Each +element of CASES has the format ‘(symbols msg body)'. For each element of +CASES: + +- ‘symbols' is a list of at least one symbol, which should be keys in + ‘query-alist’. +- Extract and bind keys `symbols' from `query-alist'. +- If all of them are present, respond with `msg' and runs `body'. + +For example of usage see ‘oauth2-auto--httpd-filter’." + (declare + (debug (&rest ((symbolp &rest symbolp) form &rest form)))) + `(cond + ,@(mapcar (lambda (case) + (let ((symbols (car case)) + (msg (cadr case)) + (body (cddr case))) + `((and ,@(--map `(cdr (assoc ',it query-alist)) symbols)) + (let* (,@(--map `(,it (cdr (assoc ',it query-alist))) symbols) + (msg ,msg)) + ;; ‘ignore’ suppresses byte compiler warnings if the macro + ;; caller doesn’t use the variables declared in the ‘let*’ + ;; above. + (ignore msg ,@symbols) + (oauth2-auto--httpd-respond process msg) + ,@body)))) + cases))) + +(defun oauth2-auto--httpd-filter (process input) + "The HTTP handler for the OAuth2 challenge-response server. +PROCESS is the server process created in ‘oauth2-auto--browser-request’. +INPUT is the raw HTTP request." + (let ((query-alist + (with-temp-buffer + (insert input) + (goto-char (point-min)) + (re-search-forward + "^[[:space:]]*GET[[:space:]]+[/?]+\\([[:graph:]]*\\)[[:space:]]+HTTP/[0-9.]+[[:space:]]*$") + (mapcar + (lambda (it) (cons (intern (car it)) (cadr it))) + (url-parse-query-string (match-string 1)))))) + (oauth2-auto--query-case + ((error error_description) + (format "Error %s: %s" error error_description) + (error msg) + nil) + ((code state) + "Authentication token successfully obtained by Emacs! You may close this page now." + query-alist) + ((favicon.ico) + "" + nil) ; just return empty list if favicon.ico is requested + (() + (format "Could not parse query string %s" (pp-to-string query-alist)) + (error msg) + nil)))) + + +(aio-defun oauth2-auto--browser-request (provider url-key data-keys extra-alist &optional quiet) + "Open browser for the OAuth2 PROVIDER. +Browser is opened at url and parameters given by taking URL-KEY and DATA-KEYS +from the data of the PROVIDER, and adding EXTRA-ALIST. Then we listen to the +redirect response and return it. + +If QUIET is non-nil, suppress alerts." + (let* (; First open listener to some port in localhost + (server-proc-filter (aio-make-callback)) + (server-proc (make-network-process + :name "oauth2-auto--httpd" + :service t + :server t + :host 'local + :family 'ipv4 + :filter (car server-proc-filter) + :coding 'binary))) + (unwind-protect + (let* ((server-promise (cdr server-proc-filter)) + (server-url (format "http://localhost:%i/" + (cadr (process-contact server-proc)))) + (redirect-uri-elt (cons 'redirect_uri server-url)) + + ; Craft a request + (very-extra-alist (cons redirect-uri-elt extra-alist)) + ;; (very-extra-alist extra-alist) + ;; almost same code as beginning of `oauth2-auto--request' + (provider-info (oauth2-auto--provider-info provider)) + (data-alist (oauth2-auto--craft-request-alist + provider-info data-keys very-extra-alist)) + (data (oauth2-auto--urlify-request data-alist)) + + (url (cdr (assoc url-key provider-info))) + ; open authorization URL in browser + (response-alist nil)) + (browse-url (concat url "?" data)) + + (unless quiet + (alert "Log in to your account for Emacs in your browser window" + :title "Emacs OAuth2 login" + :category 'oauth2-auto)) + ; Wait until we get a reply containing 'code and 'state. + (while (not response-alist) + (setq response-alist + (apply #'oauth2-auto--httpd-filter (aio-chain server-promise)))) + + ; return the response, with the 'redirect_uri + (cons redirect-uri-elt response-alist)) + ; Always kill server-proc + (delete-process server-proc)))) + +(defconst oauth2-auto--url-unreserved + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY0123456789-_" + "List of valid non-padding characters in Base64 URL encoded string.") + +(defun oauth2-auto--random-string (len) + "Return a random string of length LEN. +Uses only characters valid in the output of `base64url-encode-string'." + ; inspired by http://xahlee.info/emacs/emacs/elisp_insert_random_number_string.html + (let ((rand-len (length oauth2-auto--url-unreserved))) + (with-temp-buffer + (dotimes (_l len) + (insert (elt oauth2-auto--url-unreserved (random rand-len)))) + (buffer-string)))) + +(defun oauth2-auto--base64url-encode-string (string &optional no-pad) + "Package-local version of ‘base64url-encode-string’. + +Base64url-encode STRING and return the result. + +Optional second argument NO-PAD means do not add padding char =. + +This produces the URL variant of base 64 encoding defined in RFC 4648. + +Exists because this package is compatible with Emacs 26.1, but +‘base64url-encode-string’ was only added in Emacs 27.1." + (if (fboundp 'base64url-encode-string) + ;; Use funcall to silence flycheck. + (funcall 'base64url-encode-string string no-pad) + (let* ((enc (base64-encode-string string t)) + (enc (replace-regexp-in-string "+" "-" enc)) + (enc (replace-regexp-in-string "/" "_" enc))) + (if no-pad + (replace-regexp-in-string "=" "" enc) + enc)))) + +;; Control flow to authenticate client to the OAuth2 providers + + +(aio-defun oauth2-auto-refresh-or-authenticate (username provider plist) + "Try to refresh, and if refreshing fails, authenticate. +For USERNAME, PROVIDER, and PLIST see ‘oauth2-auto-refresh’." + (let* ((promise (oauth2-auto-refresh username provider plist)) + (result (aio-await (aio-catch promise)))) + (if (eq (car result) :success) + ; If succeeded, return the result + (cdr result) + ; If failed, authenticate instead + (aio-await (oauth2-auto-authenticate username provider))))) + +(aio-defun oauth2-auto-authenticate (username provider) + "Authenticates USERNAME using PROVIDER and returns a plist." + (let* ((state (oauth2-auto--random-string 8)) + (code-verifier (oauth2-auto--random-string 43)) + (binary-code-challenge (secure-hash 'sha256 code-verifier nil nil t)) + (response (aio-await + (oauth2-auto--browser-request + provider 'authorize_url + '(client_id tenant scope) + `((login_hint . ,username) + (response_type . "code") + (response_mode . "query") ;; microsoft-only + (access_type . "offline") ;; google-only + (state . ,state) + (code_challenge . ,(oauth2-auto--base64url-encode-string binary-code-challenge t)) + (code_challenge_method . "S256"))))) + (response-state (cdr (assoc 'state response))) + (redirect-uri (cdr (assoc 'redirect_uri response))) + (code (cdr (assoc 'code response)))) + + ; Verify that the return state matches. + ; https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#successful-response + (unless (equal state response-state) + (error + "State sent and returned do not match - security risk: state=%s response_state=%s" + state response-state)) + + (oauth2-auto--make-plist + (aio-await + (oauth2-auto--request + provider 'token_url + '(client_id tenant client_secret) + `((redirect_uri . ,redirect-uri) + (code . ,code) + (grant_type . "authorization_code") + (code_verifier . ,code-verifier)))) + nil))) + +(aio-defun oauth2-auto-refresh (_username provider plist) + "Refresh access of USERNAME using PROVIDER using the refresh-token in PLIST. +Return the refreshed plist." + (let ((refresh-token (plist-get plist :refresh-token))) + (unless refresh-token + (error "Refresh token is nil in plist=%s" (pp-to-string plist))) + + ; Refresh an oauth2-token + (oauth2-auto--make-plist + (aio-await (oauth2-auto--request + provider 'token_url + '(client_id tenant client_secret) + `((refresh_token . ,refresh-token) + (grant_type . "refresh_token")))) + plist))) + +(provide 'oauth2-auto) + +;;; oauth2-auto.el ends here diff --git a/org-gcal-pkg.el b/org-gcal-pkg.el new file mode 100644 index 0000000..94ff4ca --- /dev/null +++ b/org-gcal-pkg.el @@ -0,0 +1,11 @@ +;; Keep in sync with Package-Requires lines in the package files. +(define-package "org-gcal" "0.4.0" + "Org sync with Google Calendar" + '((aio "1.0") + (alert "1.2") + (elnode "20190702.1509") + (emacs "26.1") + (org "9.3") + (persist "0.4") + (request "20190901") + (request-deferred "20181129"))) diff --git a/org-gcal.el b/org-gcal.el index 5f10b86..8e12d4a 100644 --- a/org-gcal.el +++ b/org-gcal.el @@ -2,10 +2,9 @@ ;; Author: myuhe ;; URL: https://github.com/kidd/org-gcal.el -;; Version: 0.3 +;; Version: 0.4.0 ;; Maintainer: Raimon Grau ;; Copyright (C) :2014 myuhe all rights reserved. -;; Package-Requires: ((request "20190901") (request-deferred "20181129") (alert) (persist) (emacs "26") (org "9.3")) ;; Keywords: convenience, ;; This program is free software; you can redistribute it and/or modify @@ -35,7 +34,9 @@ (require 'alert) (require 'json) -(require 'request-deferred) +(require 'aio) +;; Not on MELPA yet. Must install from https://github.com/rhaps0dy/emacs-oauth2-auto. +(require 'oauth2-auto) (require 'ol) (require 'org) (require 'org-archive) @@ -45,6 +46,7 @@ (require 'org-id) (require 'parse-time) (require 'persist) +(require 'request-deferred) (require 'cl-lib) (require 'rx) (require 'subr-x) @@ -299,15 +301,6 @@ See: https://developers.google.com/calendar/v3/reference/events/insert." :group 'org-gcal :type 'string) -(defconst org-gcal-auth-url "https://accounts.google.com/o/oauth2/auth" - "Google OAuth2 server URL.") - -(defconst org-gcal-token-url "https://www.googleapis.com/oauth2/v3/token" - "Google OAuth2 server URL.") - -(defconst org-gcal-resource-url "https://www.googleapis.com/auth/calendar" - "URL used to request access to calendar resources.") - (defun org-gcal-events-url (calendar-id) "URL used to request access to events on calendar CALENDAR-ID." (format "https://www.googleapis.com/calendar/v3/calendars/%s/events" @@ -360,7 +353,6 @@ SKIP-EXPORT. Set SILENT to non-nil to inhibit notifications." (user-error "org-gcal sync locked. If a previous sync has failed, call ‘org-gcal--sync-unlock’ to reset the lock and try again.")) (org-gcal--sync-lock) (org-generic-id-update-id-locations org-gcal-entry-id-property) - (org-gcal--ensure-token) (when org-gcal-auto-archive (dolist (i org-gcal-fetch-file-alist) (with-current-buffer @@ -533,10 +525,10 @@ CALENDAR-ID-FILE is a cons in ‘org-gcal-fetch-file-alist’, for which see." :type "GET" :headers `(("Accept" . "application/json") - ("Authorization" . ,(format "Bearer %s" (org-gcal--get-access-token)))) + ("Authorization" . ,(format "Bearer %s" (org-gcal--get-access-token calendar-id)))) :params (append - `(("access_token" . ,(org-gcal--get-access-token)) + `(("access_token" . ,(org-gcal--get-access-token calendar-id)) ("singleEvents" . "True")) (when org-gcal-local-timezone `(("timeZone" . ,org-gcal-local-timezone))) (seq-let [expires sync-token] @@ -566,10 +558,10 @@ CALENDAR-ID-FILE is a cons in ‘org-gcal-fetch-file-alist’, for which see." :type "GET" :headers `(("Accept" . "application/json") - ("Authorization" . ,(format "Bearer %s" (org-gcal--get-access-token)))) + ("Authorization" . ,(format "Bearer %s" (org-gcal--get-access-token calendar-id)))) :params (append - `(("access_token" . ,(org-gcal--get-access-token)) + `(("access_token" . ,(org-gcal--get-access-token calendar-id)) ("timeMin" . ,(org-gcal--format-time2iso up-time)) ("timeMax" . ,(org-gcal--format-time2iso down-time))) (when page-token `(("pageToken" . ,page-token)))) @@ -600,7 +592,7 @@ objects for further processing." "Received HTTP 401" "OAuth token expired. Now trying to refresh-token") (deferred:$ - (org-gcal--refresh-token) + (org-gcal--refresh-token calendar-id) (deferred:nextc it (lambda (_unused) (funcall retry-fn))))) @@ -801,7 +793,6 @@ to “org”." (when org-gcal--sync-lock (user-error "org-gcal sync locked. If a previous sync has failed, call ‘org-gcal--sync-unlock’ to reset the lock and try again.")) (org-gcal--sync-lock) - (org-gcal--ensure-token) (let* ((name (or (buffer-file-name) (buffer-name)))) (deferred:try @@ -1220,7 +1211,6 @@ If SKIP-EXPORT is not nil, don’t overwrite the event on the server. For valid values of EXISTING-MODE see ‘org-gcal-managed-post-at-point-update-existing'." (interactive) - (org-gcal--ensure-token) (save-excursion ;; Post entry at point in org-agenda buffer. (when (eq major-mode 'org-agenda-mode) @@ -1330,7 +1320,6 @@ If called with prefix or with CLEAR-GCAL-INFO non-nil, will clear calendar info from the entry even if deleting the event from the server fails. Use this to delete calendar info from events on calendars you no longer have access to." (interactive "P") - (org-gcal--ensure-token) (save-excursion ;; Delete entry at point in org-agenda buffer. (when (eq major-mode 'org-agenda-mode) @@ -1384,92 +1373,21 @@ delete calendar info from events on calendars you no longer have access to." (deferred:succeed nil)))) (deferred:succeed nil))))) -(defun org-gcal-request-authorization () - "Request OAuth authorization at AUTH-URL by launching `browse-url'. - CLIENT-ID is the client id provided by the provider. - It returns the code provided by the service." - (let* ((gcal-auth-url - (concat org-gcal-auth-url - "?client_id=" (url-hexify-string org-gcal-client-id) - "&response_type=code" - "&redirect_uri=" (url-hexify-string "urn:ietf:wg:oauth:2.0:oob") - "&scope=" (url-hexify-string org-gcal-resource-url))) - (prompt - (format - "Please visit (if it doesn't open automatically): %s\n\nEnter the code your browser displayed:" - gcal-auth-url))) - (browse-url gcal-auth-url) - (read-string prompt))) - -(defun org-gcal-request-token () - "Refresh OAuth access at TOKEN-URL. - - Returns a ‘deferred’ object that can be used to wait for completion." - (interactive) - (deferred:$ - (request-deferred - org-gcal-token-url - :type "POST" - :data `(("client_id" . ,org-gcal-client-id) - ("client_secret" . ,org-gcal-client-secret) - ("code" . ,(org-gcal-request-authorization)) - ("redirect_uri" . "urn:ietf:wg:oauth:2.0:oob") - ("grant_type" . "authorization_code")) - :parser 'org-gcal--json-read) - (deferred:nextc it - (lambda (response) - (let - ((data (request-response-data response)) - (status-code (request-response-status-code response)) - (error-thrown (request-response-error-thrown response))) - (cond - ;; If there is no network connectivity, the response will not - ;; include a status code. - ((eq status-code nil) - (org-gcal--notify - "Got Error" - "Could not contact remote service. Please check your network connectivity.") - (error "Network connectivity issue %s: %s" status-code error-thrown)) - ;; Generic error-handler meant to provide useful information about - ;; failure cases not otherwise explicitly specified. - ((not (eq error-thrown nil)) - (org-gcal--notify - (concat "Status code: " (number-to-string status-code)) - (pp-to-string error-thrown)) - (error "Got error %S: %S" status-code error-thrown)) - ;; Fetch was successful. - (t - (when data - (setq org-gcal-token-plist data) - (org-gcal--save-sexp data org-gcal-token-file)) - (deferred:succeed nil)))))))) - -(defun org-gcal--refresh-token () +(defun org-gcal--get-access-token (calendar-id) + "Return the access token for CALENDAR-ID." + (aio-wait-for + (oauth2-auto-access-token calendar-id 'org-gcal))) + +(defun org-gcal--refresh-token (calendar-id) "Refresh OAuth access and return the new access token as a deferred object." - (deferred:$ - (request-deferred - org-gcal-token-url - :type "POST" - :data `(("client_id" . ,org-gcal-client-id) - ("client_secret" . ,org-gcal-client-secret) - ("refresh_token" . ,(org-gcal--get-refresh-token)) - ("grant_type" . "refresh_token")) - :parser 'org-gcal--json-read) - (deferred:nextc it - (lambda (response) - (let ((data (request-response-data response)) - (status-code (request-response-status-code response)) - (error-thrown (request-response-error-thrown response))) - (cond - ((eq error-thrown nil) - (plist-put org-gcal-token-plist - :access_token - (plist-get data :access_token)) - (org-gcal--save-sexp org-gcal-token-plist org-gcal-token-file) - (let ((_token (plist-get org-gcal-token-plist :access_token))) - (deferred:succeed nil))) - (t - (error "Got error %S: %S" status-code error-thrown)))))))) + ;; FIXME: For now, we just synchronously wait for the refresh. Once the + ;; project has been rewritten to use aio + ;; (https://github.com/kidd/org-gcal.el/issues/191), we can wait for this + ;; asynchronously as well. + (let ((token + (aio-wait-for + (oauth2-auto-access-token calendar-id 'org-gcal)))) + (deferred:succeed token))) ;;;###autoload (defun org-gcal-sync-tokens-clear () @@ -1491,7 +1409,7 @@ delete calendar info from events on calendars you no longer have access to." ; Check if headline is managed by `org-gcal', and hasn't been archived ; yet. Only in that case, potentially archive. (when (and (assoc "ORG-GCAL-MANAGED" properties) - (not (assoc "ARCHIVE_TIME" properties))) + (not (assoc "ARCHIVE_TIME" properties))) ; Go to beginning of line to parse the headline (beginning-of-line) @@ -1550,30 +1468,6 @@ delete calendar info from events on calendars you no longer have access to." (decode-coding-string (buffer-substring-no-properties (point-min) (point-max)) 'utf-8)))) -(defun org-gcal--get-refresh-token () - (if org-gcal-token-plist - (plist-get org-gcal-token-plist :refresh_token) - (progn - (if (file-exists-p org-gcal-token-file) - (progn - (with-temp-buffer (insert-file-contents org-gcal-token-file) - (plist-get (plist-get (read (buffer-string)) :token) :refresh_token))) - (org-gcal--notify - (concat org-gcal-token-file " does not exist.") - (concat "Please create " org-gcal-token-file " before proceeding.")))))) - -(defun org-gcal--get-access-token () - (if org-gcal-token-plist - (plist-get org-gcal-token-plist :access_token) - (progn - (if (file-exists-p org-gcal-token-file) - (progn - (with-temp-buffer (insert-file-contents org-gcal-token-file) - (plist-get (plist-get (read (buffer-string)) :token) :access_token))) - (org-gcal--notify - (concat org-gcal-token-file " is not exists") - (concat "Make " org-gcal-token-file)))))) - (defun org-gcal--safe-substring (string from &optional to) "Call the `substring' function safely. No errors will be returned for out of range values of FROM and @@ -1913,7 +1807,7 @@ access token A-TOKEN is not specified, it is loaded from the token file. Returns a ‘deferred’ function that on success returns a ‘request-response‘ object." - (let ((a-token (org-gcal--get-access-token))) + (let ((a-token (org-gcal--get-access-token calendar-id))) (deferred:$ (request-deferred (concat @@ -1944,7 +1838,7 @@ object." "Received HTTP 401" "OAuth token expired. Now trying to refresh token.") (deferred:$ - (org-gcal--refresh-token) + (org-gcal--refresh-token calendar-id) (deferred:nextc it (lambda (_unused) (org-gcal--get-event calendar-id event-id))))) @@ -1977,7 +1871,7 @@ Returns a ‘deferred’ object that can be used to wait for completion." (etime (org-gcal--param-date end)) (stime-alt (org-gcal--param-date-alt start)) (etime-alt (org-gcal--param-date-alt end)) - (a-token (or a-token (org-gcal--get-access-token)))) + (a-token (or a-token (org-gcal--get-access-token calendar-id)))) (deferred:try (deferred:$ (apply @@ -2041,7 +1935,7 @@ Returns a ‘deferred’ object that can be used to wait for completion." "Received HTTP 401" "OAuth token expired. Now trying to refresh-token") (deferred:$ - (org-gcal--refresh-token) + (org-gcal--refresh-token calendar-id) (deferred:nextc it (lambda (_unused) (org-gcal--post-event start end smry loc source desc calendar-id @@ -2108,7 +2002,7 @@ If ETAG is provided, it is used to retrieve the event data from the server and overwrite the event at MARKER if the event has changed on the server. Returns a ‘deferred’ object that can be used to wait for completion." - (let ((a-token (or a-token (org-gcal--get-access-token)))) + (let ((a-token (or a-token (org-gcal--get-access-token calendar-id)))) (deferred:try (deferred:$ (request-deferred @@ -2148,7 +2042,7 @@ Returns a ‘deferred’ object that can be used to wait for completion." "Received HTTP 401" "OAuth token expired. Now trying to refresh-token") (deferred:$ - (org-gcal--refresh-token) + (org-gcal--refresh-token calendar-id) (deferred:nextc it (lambda (_unused) (org-gcal--delete-event calendar-id event-id @@ -2225,22 +2119,6 @@ Returns a ‘deferred’ object that can be used to wait for completion." (with-eval-after-load 'org-refile (add-hook 'org-after-refile-insert-hook 'org-gcal--refile-post)) -(defun org-gcal--ensure-token () - "Ensure that access, refresh, and sync token variables in expected state." - (unless (org-gcal--sync-tokens-valid) - (persist-load 'org-gcal--sync-tokens) - (unless (org-gcal--sync-tokens-valid) - (org-gcal-sync-tokens-clear))) - (cond - (org-gcal-token-plist t) - ((and (file-exists-p org-gcal-token-file) - (ignore-errors - (setq org-gcal-token-plist - (with-temp-buffer - (insert-file-contents org-gcal-token-file) - (plist-get (read (current-buffer)) :token))))) t) - (t (deferred:sync! (org-gcal-request-token))))) - (defun org-gcal--sync-tokens-valid () "Is ‘org-gcal--sync-tokens’ in a valid format?" (and (listp org-gcal--sync-tokens) @@ -2282,6 +2160,24 @@ non-nil." (plist-get plst :year)))) +(defun org-gcal-reload-client-id-secret () + "Setup OAuth2 authentication after setting client ID and secret." + (interactive) + (add-to-list + 'oauth2-auto-additional-providers-alist + `(org-gcal + (authorize_url . "https://accounts.google.com/o/oauth2/auth") + (token_url . "https://oauth2.googleapis.com/token") + (scope . "https://www.googleapis.com/auth/calendar") + (client_id . ,org-gcal-client-id) + (client_secret . ,org-gcal-client-secret)))) + +(if (and org-gcal-client-id org-gcal-client-secret) + (org-gcal-reload-client-id-secret) + ;; Don’t print warning during tests. + (unless noninteractive + (warn "org-gcal: must set ‘org-gcal-client-id’ and ‘org-gcal-client-secret’ for this package to work. Please run ‘org-gcal-reload-client-id-secret’ after setting these variables."))) + (provide 'org-gcal) ;;; org-gcal.el ends here diff --git a/test/org-gcal-test.el b/test/org-gcal-test.el index e600e55..414e84c 100644 --- a/test/org-gcal-test.el +++ b/test/org-gcal-test.el @@ -26,6 +26,13 @@ ;;; Code: +;; Must set these variables before loading the package, but don’t reset them if +;; they’re already set. +(unless (and (boundp 'org-gcal-client-id) org-gcal-client-id + (boundp 'org-gcal-client-secret) org-gcal-client-secret) + (setq org-gcal-client-id "test_client_id" + org-gcal-client-secret "test_client_secret")) + (require 'org-gcal) (require 'cl-lib) (require 'el-mock) @@ -588,7 +595,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -631,7 +639,8 @@ Original second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (stub request-deferred => (deferred:succeed (make-request-response @@ -697,7 +706,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (y-or-n-p *) => nil) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" @@ -734,7 +744,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -768,7 +779,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (y-or-n-p *) => nil) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" @@ -805,7 +817,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -842,7 +855,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -875,7 +889,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -910,15 +925,16 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) - (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" - "My event summary" "Foobar's desk" - `((url . "https://google.com") (title . "Google")) - "My event description\n\nSecond paragraph" - "foo@foobar.com" - * "opaque" nil nil - * * *)) - (org-gcal-post-at-point))) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) + (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" + "My event summary" "Foobar's desk" + `((url . "https://google.com") (title . "Google")) + "My event description\n\nSecond paragraph" + "foo@foobar.com" + * "opaque" nil nil + * * *)) + (org-gcal-post-at-point))) (org-gcal-test--with-temp-buffer "\ * My event summary @@ -940,15 +956,16 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) - (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" - "My event summary" "Foobar's desk" - `((url . "https://google.com") (title . "Google")) - "My event description\n\nSecond paragraph" - "foo@foobar.com" - * "opaque" nil nil - * * *)) - (org-gcal-post-at-point)))) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) + (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-06T21:00:00Z" + "My event summary" "Foobar's desk" + `((url . "https://google.com") (title . "Google")) + "My event description\n\nSecond paragraph" + "foo@foobar.com" + * "opaque" nil nil + * * *)) + (org-gcal-post-at-point)))) (ert-deftest org-gcal-test--post-at-point-no-properties () "Verify that ‘org-gcal-post-to-point’ fills in entries with no relevant @@ -963,7 +980,8 @@ org-gcal properties with sane default values." (stub read-from-minibuffer => "4:00") (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00+0000" "2019-10-06T21:00:00+0000" "My event summary" nil nil nil @@ -986,7 +1004,8 @@ CLOCK: [2019-06-06 Thu 17:00]--[2019-06-06 Thu 18:00] => 1:00 (stub org-read-date => (encode-time 0 0 17 6 10 2019 nil nil t)) (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (cl-letf (((symbol-function #'read-from-minibuffer) (lambda (_p initial-contents) initial-contents))) @@ -1003,7 +1022,7 @@ CLOCK: [2019-06-06 Thu 17:00]--[2019-06-06 Thu 18:00] => 1:00 an event ID is not." :expected-result :failed (org-gcal-test--with-temp-buffer - "\ + "\ * My event summary :PROPERTIES: :LOCATION: Foobar's desk @@ -1019,12 +1038,13 @@ My event description Second paragraph :END: " - (with-mock - (stub org-gcal--time-zone => '(0 "UTC")) - (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) - (stub request-deferred => (deferred:succeed nil)) - (org-gcal-post-at-point)))) + (with-mock + (stub org-gcal--time-zone => '(0 "UTC")) + (stub org-generic-id-add-location => nil) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) + (stub request-deferred => (deferred:succeed nil)) + (org-gcal-post-at-point)))) (ert-deftest org-gcal-test--post-at-point-time-date-range () "Verify that entry with a time/date range for its timestamp is parsed by @@ -1050,7 +1070,8 @@ Second paragraph (with-mock (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (mock (org-gcal--post-event "2019-10-06T17:00:00Z" "2019-10-07T21:00:00Z" "My event summary" "Foobar's desk" `((url . "https://google.com") (title . "Google")) @@ -1091,7 +1112,8 @@ Second paragraph (let ((deferred:debug t)) (stub org-gcal--time-zone => '(0 "UTC"))) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (stub y-or-n-p => t) (stub alert => t) (stub request-deferred => @@ -1113,7 +1135,8 @@ Second paragraph (let ((deferred:debug t)) (stub org-gcal--time-zone => '(0 "UTC")) (stub org-generic-id-add-location => nil) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (stub y-or-n-p => t) (stub request-deferred => (deferred:succeed @@ -1130,7 +1153,8 @@ Second paragraph (let ((deferred:debug t) (org-gcal-remove-api-cancelled-events t)) (stub org-gcal--time-zone => '(0 "UTC")) - (stub org-gcal-request-token => (deferred:succeed nil)) + (stub org-gcal--get-access-token => "my_access_token") + (stub org-gcal--refresh-token => (deferred:succeed "test_access_token")) (stub y-or-n-p => t) (stub request-deferred => (deferred:succeed