├── README.md ├── requirements.txt └── openconnect-gp-okta /README.md: -------------------------------------------------------------------------------- 1 | This repository is no longer maintained. Consider using a fork, such as 2 | . 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2022.5.18.1 2 | chardet==4.0.0 3 | charset-normalizer==2.0.12 4 | click==8.1.3 5 | idna==3.3 6 | lxml==4.8.0 7 | requests==2.27.1 8 | urllib3==1.26.9 9 | -------------------------------------------------------------------------------- /openconnect-gp-okta: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import base64 4 | import click 5 | import contextlib 6 | import getpass 7 | import json 8 | import lxml.etree 9 | import os 10 | import re 11 | import requests 12 | import signal 13 | import subprocess 14 | import sys 15 | import urllib 16 | 17 | try: 18 | import pyotp 19 | except ImportError: 20 | pyotp = None 21 | 22 | def check(r): 23 | r.raise_for_status() 24 | return r 25 | 26 | def extract_form(html): 27 | form = lxml.etree.fromstring(html, lxml.etree.HTMLParser()).find('.//form') 28 | return (form.attrib['action'], 29 | {inp.attrib['name']: inp.attrib['value'] for inp in form.findall('input')}) 30 | 31 | def prelogin(s, gateway): 32 | r = check(s.post('https://{}/ssl-vpn/prelogin.esp'.format(gateway))) 33 | saml_req_html = base64.b64decode(lxml.etree.fromstring(r.content).find('saml-request').text) 34 | saml_req_url, saml_req_data = extract_form(saml_req_html) 35 | assert 'SAMLRequest' in saml_req_data 36 | return saml_req_url + '?' + urllib.parse.urlencode(saml_req_data) 37 | 38 | def post_json(s, url, data): 39 | r = check(s.post(url, data=json.dumps(data), 40 | headers={'Content-Type': 'application/json'})) 41 | return r.json() 42 | 43 | def okta_auth(s, domain, username, password, factor_priorities, totp_key): 44 | r = post_json(s, 'https://{}/api/v1/authn'.format(domain), 45 | {'username': username, 'password': password}) 46 | 47 | if r['status'] == 'MFA_REQUIRED': 48 | def priority(factor): 49 | return factor_priorities.get(factor['factorType'], 0) 50 | for factor in sorted(r['_embedded']['factors'], key=priority, reverse=True): 51 | if factor['factorType'] == 'push': 52 | url = factor['_links']['verify']['href'] 53 | while True: 54 | r = post_json(s, url, {'stateToken': r['stateToken']}) 55 | if r['status'] != 'MFA_CHALLENGE': 56 | break 57 | assert r['factorResult'] == 'WAITING' 58 | break 59 | if factor['factorType'] == 'sms': 60 | url = factor['_links']['verify']['href'] 61 | r = post_json(s, url, {'stateToken': r['stateToken']}) 62 | assert r['status'] == 'MFA_CHALLENGE' 63 | code = input('SMS code: ') 64 | r = post_json(s, url, {'stateToken': r['stateToken'], 'passCode': code}) 65 | break 66 | if re.match('token(?::|$)', factor['factorType']): 67 | url = factor['_links']['verify']['href'] 68 | if (factor['factorType'] == 'token:software:totp') and (totp_key is not None): 69 | code = pyotp.TOTP(totp_key).now() 70 | else: 71 | code = input('One-time code for {} ({}): '.format(factor['provider'], factor['vendorName'])) 72 | r = post_json(s, url, {'stateToken': r['stateToken'], 'passCode': code}) 73 | break 74 | else: 75 | raise Exception('No supported authentication factors') 76 | 77 | assert r['status'] == 'SUCCESS' 78 | return r['sessionToken'] 79 | 80 | def okta_saml(s, saml_req_url, username, password, factor_priorities, totp_key): 81 | domain = urllib.parse.urlparse(saml_req_url).netloc 82 | 83 | # Just to set DT cookie 84 | check(s.get(saml_req_url)) 85 | 86 | token = okta_auth(s, domain, username, password, factor_priorities, totp_key) 87 | 88 | r = check(s.get('https://{}/login/sessionCookieRedirect'.format(domain), 89 | params={'token': token, 'redirectUrl': saml_req_url})) 90 | saml_resp_url, saml_resp_data = extract_form(r.content) 91 | assert 'SAMLResponse' in saml_resp_data 92 | return saml_resp_url, saml_resp_data 93 | 94 | def complete_saml(s, saml_resp_url, saml_resp_data): 95 | r = check(s.post(saml_resp_url, data=saml_resp_data)) 96 | return r.headers['saml-username'], r.headers['prelogin-cookie'] 97 | 98 | @contextlib.contextmanager 99 | def signal_mask(how, mask): 100 | old_mask = signal.pthread_sigmask(how, mask) 101 | try: 102 | yield old_mask 103 | finally: 104 | signal.pthread_sigmask(signal.SIG_SETMASK, old_mask) 105 | 106 | @contextlib.contextmanager 107 | def signal_handler(num, handler): 108 | old_handler = signal.signal(num, handler) 109 | try: 110 | yield old_handler 111 | finally: 112 | signal.signal(num, old_handler) 113 | 114 | @contextlib.contextmanager 115 | def popen_forward_sigterm(args, *, stdin=None): 116 | with signal_mask(signal.SIG_BLOCK, {signal.SIGTERM}) as old_mask: 117 | with subprocess.Popen(args, stdin=stdin, 118 | preexec_fn=lambda: signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)) as p: 119 | with signal_handler(signal.SIGTERM, lambda *args: p.terminate()): 120 | with signal_mask(signal.SIG_SETMASK, old_mask): 121 | yield p 122 | if p.stdin: 123 | p.stdin.close() 124 | os.waitid(os.P_PID, p.pid, os.WEXITED | os.WNOWAIT) 125 | 126 | @click.command() 127 | @click.argument('gateway') 128 | @click.argument('openconnect-args', nargs=-1) 129 | @click.option('--username') 130 | @click.option('--password') 131 | @click.option('--factor-priority', 'factor_priorities', nargs=2, type=click.Tuple((str, int)), multiple=True) 132 | @click.option('--totp-key') 133 | @click.option('--sudo/--no-sudo', default=False) 134 | def main(gateway, openconnect_args, username, password, factor_priorities, totp_key, sudo): 135 | if (totp_key is not None) and (pyotp is None): 136 | print('--totp-key requires pyotp!', file=sys.stderr) 137 | sys.exit(1) 138 | 139 | if username is None: 140 | username = input('Username: ') 141 | if password is None: 142 | password = getpass.getpass() 143 | 144 | factor_priorities = { 145 | 'token:software:totp': 0 if totp_key is None else 2, 146 | 'push': 1, 147 | **dict(factor_priorities)} 148 | 149 | with requests.Session() as s: 150 | saml_req_url = prelogin(s, gateway) 151 | saml_resp_url, saml_resp_data = okta_saml(s, saml_req_url, username, password, factor_priorities, totp_key) 152 | saml_username, prelogin_cookie = complete_saml(s, saml_resp_url, saml_resp_data) 153 | 154 | subprocess_args = [ 155 | 'openconnect', 156 | gateway, 157 | '--protocol=gp', 158 | '--user=' + saml_username, 159 | '--usergroup=gateway:prelogin-cookie', 160 | '--passwd-on-stdin' 161 | ] + list(openconnect_args) 162 | 163 | if sudo: 164 | subprocess_args = ['sudo'] + subprocess_args 165 | 166 | with popen_forward_sigterm(subprocess_args, stdin=subprocess.PIPE) as p: 167 | p.stdin.write(prelogin_cookie.encode()) 168 | sys.exit(p.returncode) 169 | 170 | main() 171 | --------------------------------------------------------------------------------