Mercurial > p > roundup > code
comparison scripts/oauth-get-token.py @ 7084:8d9a6063cb22
Add oauth-get-token.py script
.. with a short explanation in README.txt
| author | Ralf Schlatterbeck <rsc@runtux.com> |
|---|---|
| date | Tue, 29 Nov 2022 13:15:19 +0100 |
| parents | |
| children | 8cda8e05c9a0 |
comparison
equal
deleted
inserted
replaced
| 7083:259f1e571470 | 7084:8d9a6063cb22 |
|---|---|
| 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 () |
