diff options
author | Mike Crute <mike@crute.us> | 2020-12-30 00:14:25 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-12-30 00:14:25 -0800 |
commit | a7eb5ac2e19a46e9b70d3af43b0482d061b31623 (patch) | |
tree | ea36c7130acbc1c08c174499f173511fd5ca11b7 /bin | |
parent | d50ff95aa7f5397d3dbac133955294441131dd91 (diff) | |
download | dotfiles-a7eb5ac2e19a46e9b70d3af43b0482d061b31623.tar.bz2 dotfiles-a7eb5ac2e19a46e9b70d3af43b0482d061b31623.tar.xz dotfiles-a7eb5ac2e19a46e9b70d3af43b0482d061b31623.zip |
Add gmail mutt token hook
Diffstat (limited to 'bin')
-rwxr-xr-x | bin/oauth2.py | 348 |
1 files changed, 348 insertions, 0 deletions
diff --git a/bin/oauth2.py b/bin/oauth2.py new file mode 100755 index 0000000..db4ea79 --- /dev/null +++ b/bin/oauth2.py | |||
@@ -0,0 +1,348 @@ | |||
1 | #!/usr/bin/python3 | ||
2 | # | ||
3 | # Copyright 2012 Google Inc. | ||
4 | # | ||
5 | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
6 | # you may not use this file except in compliance with the License. | ||
7 | # You may obtain a copy of the License at | ||
8 | # | ||
9 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
10 | # | ||
11 | # Unless required by applicable law or agreed to in writing, software | ||
12 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
14 | # See the License for the specific language governing permissions and | ||
15 | # limitations under the License. | ||
16 | |||
17 | """Performs client tasks for testing IMAP OAuth2 authentication. | ||
18 | |||
19 | To use this script, you'll need to have registered with Google as an OAuth | ||
20 | application and obtained an OAuth client ID and client secret. | ||
21 | See https://developers.google.com/identity/protocols/OAuth2 for instructions on | ||
22 | registering and for documentation of the APIs invoked by this code. | ||
23 | |||
24 | This script has 3 modes of operation. | ||
25 | |||
26 | 1. The first mode is used to generate and authorize an OAuth2 token, the | ||
27 | first step in logging in via OAuth2. | ||
28 | |||
29 | oauth2 --user=xxx@gmail.com \ | ||
30 | --client_id=1038[...].apps.googleusercontent.com \ | ||
31 | --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \ | ||
32 | --generate_oauth2_token | ||
33 | |||
34 | The script will converse with Google and generate an oauth request | ||
35 | token, then present you with a URL you should visit in your browser to | ||
36 | authorize the token. Once you get the verification code from the Google | ||
37 | website, enter it into the script to get your OAuth access token. The output | ||
38 | from this command will contain the access token, a refresh token, and some | ||
39 | metadata about the tokens. The access token can be used until it expires, and | ||
40 | the refresh token lasts indefinitely, so you should record these values for | ||
41 | reuse. | ||
42 | |||
43 | 2. The script will generate new access tokens using a refresh token. | ||
44 | |||
45 | oauth2 --user=xxx@gmail.com \ | ||
46 | --client_id=1038[...].apps.googleusercontent.com \ | ||
47 | --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \ | ||
48 | --refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA | ||
49 | |||
50 | 3. The script will generate an OAuth2 string that can be fed | ||
51 | directly to IMAP or SMTP. This is triggered with the --generate_oauth2_string | ||
52 | option. | ||
53 | |||
54 | oauth2 --generate_oauth2_string --user=xxx@gmail.com \ | ||
55 | --access_token=ya29.AGy[...]ezLg | ||
56 | |||
57 | The output of this mode will be a base64-encoded string. To use it, connect to a | ||
58 | IMAPFE and pass it as the second argument to the AUTHENTICATE command. | ||
59 | |||
60 | a AUTHENTICATE XOAUTH2 a9sha9sfs[...]9dfja929dk== | ||
61 | """ | ||
62 | |||
63 | import base64 | ||
64 | import imaplib | ||
65 | import json | ||
66 | from optparse import OptionParser | ||
67 | import smtplib | ||
68 | import sys | ||
69 | import urllib.request | ||
70 | import urllib.parse | ||
71 | |||
72 | |||
73 | def SetupOptionParser(): | ||
74 | # Usage message is the module's docstring. | ||
75 | parser = OptionParser(usage=__doc__) | ||
76 | parser.add_option('--generate_oauth2_token', | ||
77 | action='store_true', | ||
78 | dest='generate_oauth2_token', | ||
79 | help='generates an OAuth2 token for testing') | ||
80 | parser.add_option('--generate_oauth2_string', | ||
81 | action='store_true', | ||
82 | dest='generate_oauth2_string', | ||
83 | help='generates an initial client response string for ' | ||
84 | 'OAuth2') | ||
85 | parser.add_option('--client_id', | ||
86 | default=None, | ||
87 | help='Client ID of the application that is authenticating. ' | ||
88 | 'See OAuth2 documentation for details.') | ||
89 | parser.add_option('--client_secret', | ||
90 | default=None, | ||
91 | help='Client secret of the application that is ' | ||
92 | 'authenticating. See OAuth2 documentation for ' | ||
93 | 'details.') | ||
94 | parser.add_option('--access_token', | ||
95 | default=None, | ||
96 | help='OAuth2 access token') | ||
97 | parser.add_option('--refresh_token', | ||
98 | default=None, | ||
99 | help='OAuth2 refresh token') | ||
100 | parser.add_option('--scope', | ||
101 | default='https://mail.google.com/', | ||
102 | help='scope for the access token. Multiple scopes can be ' | ||
103 | 'listed separated by spaces with the whole argument ' | ||
104 | 'quoted.') | ||
105 | parser.add_option('--test_imap_authentication', | ||
106 | action='store_true', | ||
107 | dest='test_imap_authentication', | ||
108 | help='attempts to authenticate to IMAP') | ||
109 | parser.add_option('--test_smtp_authentication', | ||
110 | action='store_true', | ||
111 | dest='test_smtp_authentication', | ||
112 | help='attempts to authenticate to SMTP') | ||
113 | parser.add_option('--user', | ||
114 | default=None, | ||
115 | help='email address of user whose account is being ' | ||
116 | 'accessed') | ||
117 | parser.add_option('--quiet', | ||
118 | action='store_true', | ||
119 | default=False, | ||
120 | dest='quiet', | ||
121 | help='Omit verbose descriptions and only print ' | ||
122 | 'machine-readable outputs.') | ||
123 | return parser | ||
124 | |||
125 | |||
126 | # The URL root for accessing Google Accounts. | ||
127 | GOOGLE_ACCOUNTS_BASE_URL = 'https://accounts.google.com' | ||
128 | |||
129 | |||
130 | # Hardcoded dummy redirect URI for non-web apps. | ||
131 | REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' | ||
132 | |||
133 | |||
134 | def AccountsUrl(command): | ||
135 | """Generates the Google Accounts URL. | ||
136 | |||
137 | Args: | ||
138 | command: The command to execute. | ||
139 | |||
140 | Returns: | ||
141 | A URL for the given command. | ||
142 | """ | ||
143 | return '%s/%s' % (GOOGLE_ACCOUNTS_BASE_URL, command) | ||
144 | |||
145 | |||
146 | def UrlEscape(text): | ||
147 | # See OAUTH 5.1 for a definition of which characters need to be escaped. | ||
148 | return urllib.parse.quote(text, safe='~-._') | ||
149 | |||
150 | |||
151 | def UrlUnescape(text): | ||
152 | # See OAUTH 5.1 for a definition of which characters need to be escaped. | ||
153 | return urllib.parse.unquote(text) | ||
154 | |||
155 | |||
156 | def FormatUrlParams(params): | ||
157 | """Formats parameters into a URL query string. | ||
158 | |||
159 | Args: | ||
160 | params: A key-value map. | ||
161 | |||
162 | Returns: | ||
163 | A URL query string version of the given parameters. | ||
164 | """ | ||
165 | param_fragments = [] | ||
166 | for param in sorted(params.items(), key=lambda x: x[0]): | ||
167 | param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1]))) | ||
168 | return '&'.join(param_fragments) | ||
169 | |||
170 | |||
171 | def GeneratePermissionUrl(client_id, scope='https://mail.google.com/'): | ||
172 | """Generates the URL for authorizing access. | ||
173 | |||
174 | This uses the "OAuth2 for Installed Applications" flow described at | ||
175 | https://developers.google.com/accounts/docs/OAuth2InstalledApp | ||
176 | |||
177 | Args: | ||
178 | client_id: Client ID obtained by registering your app. | ||
179 | scope: scope for access token, e.g. 'https://mail.google.com' | ||
180 | Returns: | ||
181 | A URL that the user should visit in their browser. | ||
182 | """ | ||
183 | params = {} | ||
184 | params['client_id'] = client_id | ||
185 | params['redirect_uri'] = REDIRECT_URI | ||
186 | params['scope'] = scope | ||
187 | params['response_type'] = 'code' | ||
188 | return '%s?%s' % (AccountsUrl('o/oauth2/auth'), | ||
189 | FormatUrlParams(params)) | ||
190 | |||
191 | |||
192 | def AuthorizeTokens(client_id, client_secret, authorization_code): | ||
193 | """Obtains OAuth access token and refresh token. | ||
194 | |||
195 | This uses the application portion of the "OAuth2 for Installed Applications" | ||
196 | flow at https://developers.google.com/accounts/docs/OAuth2InstalledApp#handlingtheresponse | ||
197 | |||
198 | Args: | ||
199 | client_id: Client ID obtained by registering your app. | ||
200 | client_secret: Client secret obtained by registering your app. | ||
201 | authorization_code: code generated by Google Accounts after user grants | ||
202 | permission. | ||
203 | Returns: | ||
204 | The decoded response from the Google Accounts server, as a dict. Expected | ||
205 | fields include 'access_token', 'expires_in', and 'refresh_token'. | ||
206 | """ | ||
207 | params = {} | ||
208 | params['client_id'] = client_id | ||
209 | params['client_secret'] = client_secret | ||
210 | params['code'] = authorization_code | ||
211 | params['redirect_uri'] = REDIRECT_URI | ||
212 | params['grant_type'] = 'authorization_code' | ||
213 | request_url = AccountsUrl('o/oauth2/token') | ||
214 | |||
215 | response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode("utf-8")).read() | ||
216 | return json.loads(response) | ||
217 | |||
218 | |||
219 | def RefreshToken(client_id, client_secret, refresh_token): | ||
220 | """Obtains a new token given a refresh token. | ||
221 | |||
222 | See https://developers.google.com/accounts/docs/OAuth2InstalledApp#refresh | ||
223 | |||
224 | Args: | ||
225 | client_id: Client ID obtained by registering your app. | ||
226 | client_secret: Client secret obtained by registering your app. | ||
227 | refresh_token: A previously-obtained refresh token. | ||
228 | Returns: | ||
229 | The decoded response from the Google Accounts server, as a dict. Expected | ||
230 | fields include 'access_token', 'expires_in', and 'refresh_token'. | ||
231 | """ | ||
232 | params = {} | ||
233 | params['client_id'] = client_id | ||
234 | params['client_secret'] = client_secret | ||
235 | params['refresh_token'] = refresh_token | ||
236 | params['grant_type'] = 'refresh_token' | ||
237 | request_url = AccountsUrl('o/oauth2/token') | ||
238 | |||
239 | response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode("utf-8")).read() | ||
240 | return json.loads(response) | ||
241 | |||
242 | |||
243 | def GenerateOAuth2String(username, access_token, base64_encode=True): | ||
244 | """Generates an IMAP OAuth2 authentication string. | ||
245 | |||
246 | See https://developers.google.com/google-apps/gmail/oauth2_overview | ||
247 | |||
248 | Args: | ||
249 | username: the username (email address) of the account to authenticate | ||
250 | access_token: An OAuth2 access token. | ||
251 | base64_encode: Whether to base64-encode the output. | ||
252 | |||
253 | Returns: | ||
254 | The SASL argument for the OAuth2 mechanism. | ||
255 | """ | ||
256 | auth_string = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token) | ||
257 | if base64_encode: | ||
258 | auth_string = base64.b64encode(auth_string) | ||
259 | return auth_string | ||
260 | |||
261 | |||
262 | def TestImapAuthentication(user, auth_string): | ||
263 | """Authenticates to IMAP with the given auth_string. | ||
264 | |||
265 | Prints a debug trace of the attempted IMAP connection. | ||
266 | |||
267 | Args: | ||
268 | user: The Gmail username (full email address) | ||
269 | auth_string: A valid OAuth2 string, as returned by GenerateOAuth2String. | ||
270 | Must not be base64-encoded, since imaplib does its own base64-encoding. | ||
271 | """ | ||
272 | print() | ||
273 | imap_conn = imaplib.IMAP4_SSL('imap.gmail.com') | ||
274 | imap_conn.debug = 4 | ||
275 | imap_conn.authenticate('XOAUTH2', lambda x: auth_string) | ||
276 | imap_conn.select('INBOX') | ||
277 | |||
278 | |||
279 | def TestSmtpAuthentication(user, auth_string): | ||
280 | """Authenticates to SMTP with the given auth_string. | ||
281 | |||
282 | Args: | ||
283 | user: The Gmail username (full email address) | ||
284 | auth_string: A valid OAuth2 string, not base64-encoded, as returned by | ||
285 | GenerateOAuth2String. | ||
286 | """ | ||
287 | print() | ||
288 | smtp_conn = smtplib.SMTP('smtp.gmail.com', 587) | ||
289 | smtp_conn.set_debuglevel(True) | ||
290 | smtp_conn.ehlo('test') | ||
291 | smtp_conn.starttls() | ||
292 | smtp_conn.docmd('AUTH', 'XOAUTH2 ' + base64.b64encode(auth_string)) | ||
293 | |||
294 | |||
295 | def RequireOptions(options, *args): | ||
296 | missing = [arg for arg in args if getattr(options, arg) is None] | ||
297 | if missing: | ||
298 | print('Missing options: %s' % ' '.join(missing)) | ||
299 | sys.exit(-1) | ||
300 | |||
301 | |||
302 | def main(argv): | ||
303 | options_parser = SetupOptionParser() | ||
304 | (options, args) = options_parser.parse_args() | ||
305 | if options.refresh_token: | ||
306 | RequireOptions(options, 'client_id', 'client_secret') | ||
307 | response = RefreshToken(options.client_id, options.client_secret, | ||
308 | options.refresh_token) | ||
309 | if options.quiet: | ||
310 | print(response['access_token']) | ||
311 | else: | ||
312 | print('Access Token: %s' % response['access_token']) | ||
313 | print('Access Token Expiration Seconds: %s' % response['expires_in']) | ||
314 | elif options.generate_oauth2_string: | ||
315 | RequireOptions(options, 'user', 'access_token') | ||
316 | oauth2_string = GenerateOAuth2String(options.user, options.access_token) | ||
317 | if options.quiet: | ||
318 | print(oauth2_string) | ||
319 | else: | ||
320 | print('OAuth2 argument:\n' + oauth2_string) | ||
321 | elif options.generate_oauth2_token: | ||
322 | RequireOptions(options, 'client_id', 'client_secret') | ||
323 | print('To authorize token, visit this url and follow the directions:') | ||
324 | print(' %s' % GeneratePermissionUrl(options.client_id, options.scope)) | ||
325 | authorization_code = input('Enter verification code: ') | ||
326 | response = AuthorizeTokens(options.client_id, options.client_secret, | ||
327 | authorization_code) | ||
328 | print('Refresh Token: %s' % response['refresh_token']) | ||
329 | print('Access Token: %s' % response['access_token']) | ||
330 | print('Access Token Expiration Seconds: %s' % response['expires_in']) | ||
331 | elif options.test_imap_authentication: | ||
332 | RequireOptions(options, 'user', 'access_token') | ||
333 | TestImapAuthentication(options.user, | ||
334 | GenerateOAuth2String(options.user, options.access_token, | ||
335 | base64_encode=False)) | ||
336 | elif options.test_smtp_authentication: | ||
337 | RequireOptions(options, 'user', 'access_token') | ||
338 | TestSmtpAuthentication(options.user, | ||
339 | GenerateOAuth2String(options.user, options.access_token, | ||
340 | base64_encode=False)) | ||
341 | else: | ||
342 | options_parser.print_help() | ||
343 | print('Nothing to do, exiting.') | ||
344 | return | ||
345 | |||
346 | |||
347 | if __name__ == '__main__': | ||
348 | main(sys.argv) | ||