Skip to content

Commit cc30a99

Browse files
committed
Interactive client supports OIDC
1 parent 99bd06d commit cc30a99

File tree

1 file changed

+180
-18
lines changed

1 file changed

+180
-18
lines changed

mrmat_python_api_flask/client.py

Lines changed: 180 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
# copies of the Software, and to permit persons to whom the Software is
1010
# furnished to do so, subject to the following conditions:
1111
#
12-
# The above copyright notice and this permission notice shall be included in all
13-
# copies or substantial portions of the Software.
12+
# The above copyright notice and this permission notice shall be included in
13+
# all copies or substantial portions of the Software.
1414
#
1515
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@@ -20,32 +20,194 @@
2020
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
# SOFTWARE.
2222

23+
import os.path
2324
import sys
24-
import argparse
25-
from oauthlib.oauth2 import MobileApplicationClient
26-
from requests_oauthlib import OAuth2Session
25+
import json
26+
from time import sleep
27+
from argparse import ArgumentParser, Namespace
28+
from typing import List, Optional, Dict
29+
import cli_ui
30+
from halo import Halo
31+
import requests
32+
import oauthlib.oauth2
33+
import requests_oauthlib
2734

2835
from mrmat_python_api_flask import __version__
2936

3037

31-
def main() -> int:
38+
class ClientException(Exception):
39+
"""A simple exception subclass
40+
41+
Just so we can distinguish ourselves from whatever else may be thrown and capture an ultimate exit code and
42+
error message
3243
"""
33-
Main entry point
34-
Returns: Exit code
44+
exit_code: int
45+
msg: str
46+
47+
def __init__(self, exit_code: int = 1, msg: str = 'Unknown Exception'):
48+
self.exit_code = exit_code
49+
self.msg = msg
50+
51+
52+
def oidc_discovery(config: Dict) -> Dict:
53+
resp = requests.get(config['discovery_url'])
54+
if resp.status_code != 200:
55+
raise ClientException(exit_code=1, msg=f'Unexpected response {resp.status_code} from discovery endpoint')
56+
try:
57+
data = resp.json()
58+
except ValueError:
59+
raise ClientException(exit_code=1, msg=f'Unable to parse response from discovery endpoint into JSON')
60+
return data
61+
62+
63+
def oidc_device_auth(config: Dict, discovery: Dict) -> Dict:
64+
resp = requests.post(url=discovery['device_authorization_endpoint'],
65+
data={'client_id': config['client_id'],
66+
'client_secret': config['client_secret'],
67+
'scope': ['openid', 'profile']})
68+
if resp.status_code != 200:
69+
raise ClientException(exit_code=1, msg=f'Unexpected response {resp.status_code} from device endpoint')
70+
try:
71+
data = resp.json()
72+
except ValueError:
73+
raise ClientException(exit_code=1, msg=f'Unable to parse response from device endpoint into JSON')
74+
return data
75+
76+
77+
@Halo(text='Checking authentication')
78+
def oidc_check_auth(config: Dict, discovery: Dict, device_auth: Dict):
79+
wait = 5
80+
stop = False
81+
while not stop:
82+
resp = requests.post(url=discovery['token_endpoint'],
83+
data={'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
84+
'device_code': device_auth['device_code'],
85+
'client_id': config['client_id'],
86+
'client_secret': config['client_secret']}) # client_secret only for keycloak
87+
if resp.status_code == 400:
88+
body = resp.json()
89+
if body['error'] == 'authorization_pending':
90+
continue
91+
elif body['error'] == 'slow_down':
92+
wait += 5
93+
continue
94+
elif body['error'] == 'access_denied':
95+
raise ClientException(msg='Access denied')
96+
elif body['error'] == 'expired_token':
97+
raise ClientException(msg='Token expired')
98+
else:
99+
raise ClientException(msg=body['error_description'])
100+
elif resp.status_code == 200:
101+
return resp.json()
102+
sleep(wait)
103+
104+
105+
def parse_args(argv: List[str]) -> Optional[Namespace]:
106+
"""A dedicated function to parse the command line arguments.
107+
108+
Makes it a lot easier to test CLI parameters.
109+
110+
Args:
111+
argv: The command line arguments, minus the name of the script
112+
113+
Returns:
114+
The Namespace object as defined by the argparse module built-in to Python
35115
"""
36-
parser = argparse.ArgumentParser(description=f'mrmat-python-api-flask-client - {__version__}')
116+
parser = ArgumentParser(description=f'mrmat-python-api-flask-client - {__version__}')
117+
parser.add_argument('-q', '--quiet', action='store_true', dest='quiet', help='Silent Operation')
37118
parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='Debug')
38119

39-
args = parser.parse_args()
120+
config_group_file = parser.add_argument_group(title='File Configuration',
121+
description='Configure the client via a config file')
122+
config_group_file.add_argument('--config', '-c',
123+
dest='config',
124+
required=False,
125+
default=os.path.join(os.environ['HOME'], 'etc',
126+
'mrmat-python-api-flask-client.json'),
127+
help='Path to the configuration file for the flask client')
128+
129+
config_group_manual = parser.add_argument_group(title='Manual Configuration',
130+
description='Configure the client manually')
131+
config_group_manual.add_argument('--client-id',
132+
dest='client_id',
133+
required=False,
134+
help='The client_id of this CLI itself (not yours!)')
135+
config_group_manual.add_argument('--client-secret',
136+
dest='client_secret',
137+
required=False,
138+
help='The client_secret of the CLI itself. Not required for AAD, required for '
139+
'Keycloak')
140+
config_group_manual.add_argument('--discovery-url',
141+
dest='discovery_url',
142+
required=False,
143+
help='Discovery of endpoints in the authentication platform')
144+
return parser.parse_args(argv)
145+
146+
147+
def main(argv=None) -> int:
148+
"""Main entry point for the CLI
149+
150+
Args:
151+
argv: The command line arguments. These default to None and if so, the function will fall back to use the
152+
command line arguments without the application name used to invoke (i.e. sys.argv[1:])
153+
154+
Returns:
155+
Exit code 0 for success. Any other integer is a failure.
156+
"""
157+
args = parse_args(argv if argv is not None else sys.argv[1:])
158+
if args is None:
159+
return 0
160+
cli_ui.setup(verbose=args.debug, quiet=args.quiet, timestamp=False)
161+
162+
#
163+
# Read from the config file by default, but allow overrides via the CLI
164+
165+
config = {}
166+
if os.path.exists(os.path.expanduser(args.config)):
167+
with open(os.path.expanduser(args.config)) as C:
168+
config = json.load(C)
169+
config_override = vars(args)
170+
for key in config_override.keys() & config_override.keys():
171+
if config_override[key] is not None:
172+
config[key] = config_override[key]
173+
174+
try:
175+
discovery = oidc_discovery(config)
176+
if 'device_authorization_endpoint' not in discovery:
177+
raise ClientException(msg='No device_authorization_endpoint in discovery')
178+
if 'token_endpoint' not in discovery:
179+
raise ClientException(msg='No token_endpoint in discovery')
180+
181+
device_auth = oidc_device_auth(config, discovery)
182+
if 'device_code' not in device_auth:
183+
raise ClientException(msg='No device_code in device auth')
184+
if 'user_code' not in device_auth:
185+
raise ClientException(msg='No user_code in device auth')
186+
if 'verification_uri' not in device_auth:
187+
raise ClientException(msg='No verification_uri in device_auth')
188+
if 'expires_in' not in device_auth:
189+
raise ClientException(msg='No expires_in in device_auth')
190+
191+
# Adding the user code to the URL is convenient, but not as secure as it could be
192+
cli_ui.info(f'Please visit {device_auth["verification_uri"]} within {device_auth["expires_in"]} seconds and '
193+
f'enter code {device_auth["user_code"]}. Or just visit {device_auth["verification_uri_complete"]}')
194+
195+
auth = oidc_check_auth(config, discovery, device_auth)
196+
cli_ui.info('Authenticated')
197+
198+
#
199+
# We're using requests directly here because requests_oauthlib doesn't support device code flow directly
200+
201+
resp = requests.get('http://127.0.0.1:5000/api/greeting/v3/',
202+
headers={'Authorization': f'Bearer {auth["id_token"]}'})
203+
cli_ui.info(f'Status Code: {resp.status_code}')
204+
cli_ui.info(resp.content)
40205

41-
oauth = OAuth2Session(
42-
client=MobileApplicationClient(client_id='mrmat-python-api-flask-client', scope=['openid']))
43-
authorization_url, state = oauth.authorization_url('https://keycloak.mrmat.org/auth/realms/master/protocol/openid-connect/auth')
44-
response = oauth.get(authorization_url)
45-
token = oauth.token_from_fragment(response.url)
46-
pass
47-
return 0
206+
return 0
207+
except ClientException as ce:
208+
cli_ui.error(ce.msg)
209+
return ce.exit_code
48210

49211

50212
if __name__ == '__main__':
51-
sys.exit(main())
213+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)