Skip to content

Commit ffa8bce

Browse files
committed
Add oauth-get-token.py script
.. with a short explanation in README.txt
1 parent 8affc03 commit ffa8bce

File tree

2 files changed

+310
-0
lines changed

2 files changed

+310
-0
lines changed

scripts/README.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,11 @@ Docker/roundup-start - Startup script for roundup in docker.
7979

8080
Docker/docker-compose.yml - Manage two docker containers for roundup
8181
and mysql.
82+
83+
----
84+
85+
oauth-get-token.py
86+
Retrieve necessary credentials from MS cloud for OAuth authentication
87+
with IMAP. This has an extensive help text so you want to call this
88+
script with --help first.
89+

scripts/oauth-get-token.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/python3
2+
3+
import requests
4+
import time
5+
import sys
6+
import webbrowser
7+
import ssl
8+
9+
from urllib.parse import urlparse, urlencode, parse_qs
10+
from argparse import ArgumentParser, RawDescriptionHelpFormatter
11+
from http.server import HTTPServer, BaseHTTPRequestHandler
12+
13+
class Request_Token:
14+
15+
def __init__ (self, args):
16+
self.args = args
17+
self.session = requests.session ()
18+
self.url = '/'.join ((args.url.rstrip ('/'), args.tenant))
19+
self.url = '/'.join ((self.url, 'oauth2/v2.0'))
20+
self.state = None
21+
# end def __init__
22+
23+
def check_err (self, r):
24+
if not 200 <= r.status_code <= 299:
25+
raise RuntimeError \
26+
( 'Invalid result: %s: %s\n %s'
27+
% (r.status_code, r.reason, r.text)
28+
)
29+
# end def check_err
30+
31+
def get_url (self, path, params):
32+
url = ('/'.join ((self.url, path)))
33+
url = url + '?' + urlencode (params)
34+
return url
35+
# end def get_url
36+
37+
def post_or_put (self, method, path, data = None, json = None):
38+
d = {}
39+
if data:
40+
d.update (data = data)
41+
if json:
42+
d.update (json = json)
43+
url = ('/'.join ((self.url, path)))
44+
r = method (url, **d)
45+
self.check_err (r)
46+
return r.json ()
47+
# end def post_or_put
48+
49+
def post (self, path, data = None, json = None):
50+
return self.post_or_put (self.session.post, path, data, json)
51+
# end def post
52+
53+
def authcode_callback (self, handler):
54+
msg = ['']
55+
self.request_received = False
56+
r = urlparse (handler.path)
57+
if r.query:
58+
q = parse_qs (r.query)
59+
if 'state' in q:
60+
state = q ['state'][0]
61+
if state != self.state:
62+
msg.append \
63+
( 'State did not match: expect "%s" got "%s"'
64+
% (self.state, state)
65+
)
66+
elif 'code' not in q:
67+
msg.append ('Got no code')
68+
else:
69+
with open ('oauth/authcode', 'w') as f:
70+
f.write (q ['code'][0])
71+
msg.append ('Wrote code to oauth/authcode')
72+
self.request_received = True
73+
else:
74+
msg.append ('No state and no code')
75+
return 200, '\n'.join (msg).encode ('utf-8')
76+
# end def authcode_callback
77+
78+
def request_authcode (self):
79+
with open ('oauth/client_id', 'r') as f:
80+
client_id = f.read ()
81+
self.state = 'authcode' + str (time.time ())
82+
params = dict \
83+
( client_id = client_id
84+
, response_type = 'code'
85+
, response_mode = 'query'
86+
, state = self.state
87+
, redirect_uri = self.args.redirect_uri
88+
, scope = ' '.join
89+
(( 'https://outlook.office.com/IMAP.AccessAsUser.All'
90+
, 'https://outlook.office.com/User.Read'
91+
, 'offline_access'
92+
))
93+
)
94+
url = self.get_url ('authorize', params)
95+
print (url)
96+
if self.args.webbrowser:
97+
browser = webbrowser.get (self.args.browser)
98+
browser.open_new_tab (url)
99+
if self.args.run_https_server:
100+
self.https_server ()
101+
if self.args.request_tokens:
102+
self.request_token ()
103+
# end def request_authcode
104+
105+
def request_token (self):
106+
with open ('oauth/client_id', 'r') as f:
107+
client_id = f.read ()
108+
with open ('oauth/client_secret', 'r') as f:
109+
client_secret = f.read ().strip ()
110+
with open ('oauth/authcode', 'r') as f:
111+
authcode = f.read ().strip ()
112+
params = dict \
113+
( client_id = client_id
114+
, code = authcode
115+
, client_secret = client_secret
116+
, redirect_uri = self.args.redirect_uri
117+
, grant_type = 'authorization_code'
118+
# Only a single scope parameter is allowed here
119+
, scope = ' '.join
120+
(( 'https://outlook.office.com/User.Read'
121+
,
122+
))
123+
)
124+
result = self.post ('token', data = params)
125+
with open ('oauth/refresh_token', 'w') as f:
126+
f.write (result ['refresh_token'])
127+
with open ('oauth/access_token', 'w') as f:
128+
f.write (result ['access_token'])
129+
# end def request_token
130+
131+
def https_server (self):
132+
self.request_received = False
133+
class RQ_Handler (BaseHTTPRequestHandler):
134+
token_handler = self
135+
136+
def do_GET (self):
137+
self.close_connection = True
138+
code, msg = self.token_handler.authcode_callback (self)
139+
self.send_response (code)
140+
self.send_header ('Content-Type', 'text/plain')
141+
self.end_headers ()
142+
self.wfile.write (msg)
143+
self.wfile.flush ()
144+
145+
port = self.args.https_server_port
146+
httpd = HTTPServer (('localhost', port), RQ_Handler)
147+
148+
httpd.socket = ssl.wrap_socket \
149+
( httpd.socket
150+
, keyfile = "/etc/ssl/private/ssl-cert-snakeoil.key"
151+
, certfile = "/etc/ssl/certs/ssl-cert-snakeoil.pem"
152+
, server_side = True
153+
)
154+
155+
while not self.request_received:
156+
httpd.handle_request ()
157+
# end def https_server
158+
159+
# end class Request_Token
160+
161+
epilog = """\
162+
Retrieving the necessary refresh_token and access_token credentials
163+
using this script. This asumes you have an email account (plus the
164+
password) to be used for mail retrieval. And you have registered an
165+
application in the cloud for this process. The registering of an
166+
application will give you an application id (also called client id) and
167+
a tenant in UUID format.
168+
169+
First define the necessary TENANT variable:
170+
171+
TENANT=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
172+
173+
You need to create a directory named 'oauth' (if not yet existing) and
174+
put the client id (also called application id) into the file
175+
'oauth/client_id' and the corresponding secret into the file
176+
'oauth/client_secret'.
177+
178+
By default calling the script with no arguments, the whole process is
179+
automatic, but you may want to specify the tenant explicitly using:
180+
181+
./oauth-get-token.py -t $TENANT
182+
183+
Specifying the tenant explicitly will select the customized company
184+
login form directly.
185+
186+
The automatic process works as follows:
187+
- First the authorization URL is constructed and pushed to a local
188+
browser. By default the default browser on that machine is used, you
189+
can specify a different browser with the -b/--browser option.
190+
This will show a login form where you should be able to select the
191+
user to log in with. Log in with the username (the email address) and
192+
password for that user.
193+
- A web-server is started on the given port. When you fill out the
194+
authentication form pushed to the browser, the last step is a redirect
195+
to an URL that calls back to this webserver. The necessary
196+
authentication code is transmitted in a query parameter. The code is
197+
stored into the file 'oauth/authcode'. Using the authcode, the
198+
refresh_token and access_token are requested and stored in the oauth
199+
directory.
200+
201+
These steps can be broken down into individual steps by options
202+
disabling one of the steps:
203+
- The push to the webserver can be disabled with the option
204+
-w/--dont-push-to-webbrowser -- in that case the URL is printed on
205+
standard output and must be pasted into the URL input field of a
206+
browser. It is typically a good idea to use a browser that is
207+
currently not logged into the company network.
208+
- The start of the webserver can be disabled with the option
209+
-s/--dont-run-https-server -- when called with that option no
210+
webserver is started. You get a redirect to a non-existing page. The
211+
error-message is something like:
212+
213+
This site can’t be reached
214+
215+
Copy the URL from the browser into the file 'oauth/authcode'. The URL
216+
has paramters. We're interested in the 'code' parameter, a very long
217+
string. Edit the file so that only that string (without the 'code='
218+
part) is in the file.
219+
- Requesting the tokens can be disabled with the option
220+
-n/--dont-request-tokens -- if this option is given, after receiving
221+
the redirect from the webserver the authentication code is written to
222+
the file 'oauth/authcode' but no token request is started.
223+
224+
If you have either disabled the webserver or the token request, the
225+
token can be requested (using the file 'oauth/authcode' constructed by
226+
hand as described above or written by the webserver) with the
227+
-T/--request-token option:
228+
229+
./oauth-get-token.py [-t $TENANT] -T
230+
231+
If successful this will create the 'oauth/access_token' and
232+
'oauth/refresh_token' files. Note that the authentication code has a
233+
limited lifetime.
234+
235+
"""
236+
237+
def main ():
238+
cmd = ArgumentParser \
239+
(epilog=epilog, formatter_class=RawDescriptionHelpFormatter)
240+
cmd.add_argument \
241+
( '-T', '--request-token'
242+
, help = "Run only the token-request step"
243+
, action = 'store_true'
244+
)
245+
cmd.add_argument \
246+
( '-b', '--browser'
247+
, help = "Use non-default browser"
248+
)
249+
cmd.add_argument \
250+
( '-n', '--dont-request-tokens'
251+
, dest = 'request_tokens'
252+
, help = "Do not request tokens, just write authcode"
253+
, action = 'store_false'
254+
, default = True
255+
)
256+
cmd.add_argument \
257+
( '-p', '--https-server-port'
258+
, type = int
259+
, help = "Port for https server to listen, default=%(default)s"
260+
" see also -r option, ports must (usually) match."
261+
, default = 8181
262+
)
263+
cmd.add_argument \
264+
( '-r', '--redirect-uri'
265+
, help = "Redirect URI, default=%(default)s"
266+
, default = 'https://localhost:8181'
267+
)
268+
cmd.add_argument \
269+
( '-s', '--dont-run-https-server'
270+
, dest = 'run_https_server'
271+
, help = "Run https server to wait for connection of browser "
272+
"to transmit auth code via GET request"
273+
, action = 'store_false'
274+
, default = True
275+
)
276+
cmd.add_argument \
277+
( '-t', '--tenant'
278+
, help = "Tenant part of url, default=%(default)s"
279+
, default = 'organizations'
280+
)
281+
cmd.add_argument \
282+
( '-u', '--url'
283+
, help = "Base url for requests, default=%(default)s"
284+
, default = 'https://login.microsoftonline.com'
285+
)
286+
cmd.add_argument \
287+
( '-w', '--dont-push-to-webbrowser'
288+
, dest = 'webbrowser'
289+
, help = "Do not push authcode url into the browser"
290+
, action = 'store_false'
291+
, default = True
292+
)
293+
args = cmd.parse_args ()
294+
rt = Request_Token (args)
295+
if args.request_token:
296+
rt.request_token ()
297+
else:
298+
rt.request_authcode ()
299+
# end def main
300+
301+
if __name__ == '__main__':
302+
main ()

0 commit comments

Comments
 (0)