|
| 1 | +import base64 |
| 2 | +import json |
| 3 | +import logging |
| 4 | +import urllib.parse |
| 5 | + |
| 6 | +import httpx |
| 7 | + |
| 8 | +import oauthlib.common |
| 9 | +import oauthlib.oauth2 |
| 10 | + |
| 11 | +from . import redirect |
| 12 | + |
| 13 | + |
| 14 | +class AuthorizationCodeAuthenticator(redirect.RedirectAuthenticator): |
| 15 | + """ |
| 16 | + Authenticator that uses the OIDC authorization flow to obtain a token. |
| 17 | +
|
| 18 | + The OAuth2 flow is implemented here rather than using an ingress auth callout with |
| 19 | + oauth2-proxy primarily because we need to be able to have some requests that are |
| 20 | + "optionally authenticated". |
| 21 | +
|
| 22 | + This makes it possible to do things like render the Azimuth homepage to unauthenticated |
| 23 | + users so that they can discover docs on how to register while also understanding when a |
| 24 | + user is authenticated, and to present nicer messages to API consumers when unauthenticated |
| 25 | + requests are made. |
| 26 | +
|
| 27 | + This kind of optional authentication is extremely difficult to configure with the ingress |
| 28 | + callout as implemented by the NGINX ingress controller. |
| 29 | +
|
| 30 | + Potential issues with writing our own authentication code are mitigated by using the |
| 31 | + oauthlib library to generate OAuth2 request URIs/bodies and to parse the responses. |
| 32 | + """ |
| 33 | + authenticator_type = "oidc_authcode" |
| 34 | + |
| 35 | + def __init__( |
| 36 | + self, |
| 37 | + authorization_url, |
| 38 | + token_url, |
| 39 | + client_id, |
| 40 | + client_secret, |
| 41 | + scope, |
| 42 | + state_session_key, |
| 43 | + verify_ssl |
| 44 | + ): |
| 45 | + self.authorization_url = authorization_url |
| 46 | + self.token_url = token_url |
| 47 | + self.client_id = client_id |
| 48 | + self.client_secret = client_secret |
| 49 | + self.scope = scope |
| 50 | + self.state_session_key = state_session_key |
| 51 | + self.verify_ssl = verify_ssl |
| 52 | + self.logger = logging.getLogger(__name__) |
| 53 | + |
| 54 | + def prepare_redirect_url(self, redirect_url): |
| 55 | + # Strip parameters from the URL |
| 56 | + components = urllib.parse.urlsplit(redirect_url) |
| 57 | + components = components._replace(query = "", fragment = "") |
| 58 | + return urllib.parse.urlunsplit(components) |
| 59 | + |
| 60 | + def get_redirect_to(self, request, auth_complete_url, selected_option = None): |
| 61 | + client = oauthlib.oauth2.WebApplicationClient(self.client_id) |
| 62 | + # Generate new state parameter and stash it in the session |
| 63 | + state = request.session[self.state_session_key] = oauthlib.common.generate_token() |
| 64 | + # Generate the full authorization URL with parameters |
| 65 | + return client.prepare_request_uri( |
| 66 | + self.authorization_url, |
| 67 | + redirect_uri = self.prepare_redirect_url(auth_complete_url), |
| 68 | + scope = self.scope, |
| 69 | + state = state |
| 70 | + ) |
| 71 | + |
| 72 | + def auth_complete(self, request, selected_option = None): |
| 73 | + client = oauthlib.oauth2.WebApplicationClient(self.client_id) |
| 74 | + # Pull the state from the session |
| 75 | + # If it fails, log the error and try again |
| 76 | + try: |
| 77 | + state = request.session.pop(self.state_session_key) |
| 78 | + except KeyError: |
| 79 | + self.logger.warning("no OIDC state in session") |
| 80 | + return None |
| 81 | + # Extract the code from the URL |
| 82 | + # If it fails, log the error and try again |
| 83 | + request_uri = request.build_absolute_uri() |
| 84 | + try: |
| 85 | + code = client.parse_request_uri_response(request_uri, state)["code"] |
| 86 | + except (oauthlib.oauth2.OAuth2Error, KeyError): |
| 87 | + self.logger.exception("error extracting authcode") |
| 88 | + return None |
| 89 | + # Make the token request |
| 90 | + # If it fails, log the error and try again |
| 91 | + try: |
| 92 | + response = httpx.post( |
| 93 | + self.token_url, |
| 94 | + data = dict( |
| 95 | + oauthlib.common.urldecode( |
| 96 | + client.prepare_request_body( |
| 97 | + code, |
| 98 | + self.prepare_redirect_url(request_uri), |
| 99 | + include_client_id = True, |
| 100 | + client_secret = self.client_secret |
| 101 | + ) |
| 102 | + ) |
| 103 | + ), |
| 104 | + headers = { |
| 105 | + "Accept": "application/json", |
| 106 | + "Content-Type": "application/x-www-form-urlencoded", |
| 107 | + }, |
| 108 | + verify = self.verify_ssl |
| 109 | + ) |
| 110 | + except httpx.RequestError: |
| 111 | + self.logger.exception("error fetching token") |
| 112 | + return None |
| 113 | + if not response.is_success: |
| 114 | + self.logger.error( |
| 115 | + f"error fetching token " |
| 116 | + f"\"{response.status_code} {response.reason_phrase}\" " |
| 117 | + f"{response.text}" |
| 118 | + ) |
| 119 | + return None |
| 120 | + # Parse the token from the response |
| 121 | + # If it fails, log the error and try again |
| 122 | + try: |
| 123 | + token_data = client.parse_request_body_response(response.text, scope = self.scope) |
| 124 | + except oauthlib.oauth2.OAuth2Error: |
| 125 | + self.logger.exception("error extracting token from response") |
| 126 | + return None |
| 127 | + # The token that we return is a base64-encoded JSON dump of the token data |
| 128 | + # This means that the OIDC session can consume both the access and refresh tokens |
| 129 | + return base64.b64encode(json.dumps(token_data).encode()).decode() |
0 commit comments