From 6ca64d5f1a0a125a88371ebd640f9bf10aed36c7 Mon Sep 17 00:00:00 2001 From: stephenlf Date: Sat, 21 Oct 2023 15:29:08 +0000 Subject: [PATCH] Example demonstrating a Logout implementation with Google provider and NGINX --- examples/google-logout/README.md | 143 +++++++++++++++++++++++ examples/google-logout/auth_server.conf | 48 ++++++++ examples/google-logout/config.yml | 30 +++++ examples/google-logout/logout.py | 73 ++++++++++++ examples/google-logout/logout_py.service | 13 +++ 5 files changed, 307 insertions(+) create mode 100644 examples/google-logout/README.md create mode 100644 examples/google-logout/auth_server.conf create mode 100644 examples/google-logout/config.yml create mode 100644 examples/google-logout/logout.py create mode 100644 examples/google-logout/logout_py.service diff --git a/examples/google-logout/README.md b/examples/google-logout/README.md new file mode 100644 index 00000000..269de004 --- /dev/null +++ b/examples/google-logout/README.md @@ -0,0 +1,143 @@ +# Simple Logout Solution with the Google Provider, NGINX, and Python + +If you're using the [Google Identity](https://developers.google.com/identity) IdP on your app, you'll likely want to implement some sort of logout button as well. In Oauth speak, this means submitting the Google **access token** to Google's **revocation endpoint**, then clearing the Vouch session token. + +## A note on tokens + +There are several auth tokens used in the Vouch auth flow, and it's easy to get them mixed up. + +- **Google Identity Token**: A signed [JWT](https://jwt.io/) with identity information about the user. +- **Google Access Token**: An opaque token which can be used to access Google APIs on behalf of the user. Vouch uses the user's **access token** to call Google's [OpenID Connect](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect) API, which serves the user's **identity token**. +- **Vouch Session Token**: A JWT-formatted session cookie which allows Vouch to continue to authenticate signed-in users without making repeated calls to Google's APIs. + +## Implementation + +### Revoking the access token + +We can revoke the Google **Access Token** by invoking Google's [revocation endpoint](https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke). Let's create a simple Python function that accepts an access token and submits it to the revocation endpoint. + +```python +import requests + +def revoke(token: str) -> requests.Response: + return requests.post('https://oauth2.googleapis.com/revoke', + params={'token': token}, + headers = {'content-type': 'application/x-www-form-urlencoded'}) +``` + +Let's then wrap this function in a simple HTTP server so that we can call it from NGINX. We'll pass the **access token** via a custom `X-Access-Token` header. + +```python +import requests +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Define HTTP server +class S(BaseHTTPRequestHandler): + # Send access token to Google's revocation endpoint + def _revoke(self, token: str) -> requests.Response: + return requests.post('https://oauth2.googleapis.com/revoke', + params={'token': token}, + headers = {'content-type': 'application/x-www-form-urlencoded'}) + + def do_GET(self): + token = self.headers.get("X-Ems-Access-Token") + revoke_response = self._revoke(token) + pass + +# Startup server and set shutdown conditions +def run(server_class=HTTPServer, handler_class=S, port=8080): + server_address = ('', port) + httpd = server_class(server_address, handler_class) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + httpd.server_close() +``` + +### Invalidating the session token + +The `do_GET` function calls the `_revoke` function, which sends the user's access token to the Google revocation endpoint. This is good, but we still need to invalidate the user's Vouch **session cookie**. We do this by redirecting the user to `https://vouch.xxxxxxxxxxxxxxxx.com/logout`. + +The `/logout` endpoint accepts a single URL parameter: `url`, which specifies where the user should be redirected after being signed off. Let's set the `Location` header and status code of our `do_GET` function to redirect our users. + +```python +# ... + def do_GET(self): + token = self.headers.get("X-Ems-Access-Token") + revoke_response = self._revoke(token) + self.send_response(302) + self.send_header("Location", f"https://vouch.xxxxxxxxxxxxxxxx.xxx/logout?url={redirect_url}") + self.end_headers() + return +``` + +All we need to do now is run our `run` function! + +```python +# ... +if __name__ == '__main__': + from sys import argv + + if len(argv) == 1: + # Set default port to 8080. + port = 8080 + else: + port=int(argv[1]) + + run(port=port) +``` + +For a complete implementation with error handling and logging, check out the `logout.py` file. + +## Integrating Our Logout Server + +To integrate our logout server, we'll first need to run our Python script with a daemon. I prefer **systemd** on Ubuntu. Run the following commands to get that going. + +This assumes you already have Python3 installed. + +```bash +# Make a logout_py user for our daemon to run as +# Ignore the "missing or non-executable shell" warning +sudo useradd -M -s /bin/nologin logout_py + +# Make a server directory +sudo mkdir -p /opt/logout_py +sudo chown logout_py:logout_py /opt/logout_py + +# Set up virtual environment +sudo python3 -m venv /opt/logout_py/venv +sudo su -c "source /opt/logout_py/venv/bin/activate && pip install requests" + +# Move script into position +sudo cp ./logout.py /opt/logout_py/logout.py + +# Move systemd service definition into position +# Make sure you edit the `PORT` variable as necessary! +sudo cp ./logout_py.service /etc/systemd/system/logout_py.service + +# Start logout.py daemon +sudo systemctl daemon-reload +sudo systemctl start logout_py.service +sudo systemctl status logout_py.service +``` + +With that set up, we then need to create a `/logout` location in our NGINX server that forwards the appropriate headers to our **logout.py** server. See `./auth_server.conf` for variable declarations for $sub, $access_token, and $http_x_forwarded_host. + +``` +location /logout { + proxy_pass http://127.0.0.1:8080; + + proxy_set_header X-Access-Token $access_token; + + # For logging + proxy_set_header X-Google-Sub $sub; + + # For redirection + proxy_set_header X-Forwarded-Host $http_host; + # You may need to forward the host if using nested proxies + # proxy_set_header X-Forwarded-Host $http_x_forwarded_host; +} +``` + +And that's it! All you need to do on the frontend is to link to `/logout`. After a quick refresh, you should see that your users need to sign in again before accessing the site. \ No newline at end of file diff --git a/examples/google-logout/auth_server.conf b/examples/google-logout/auth_server.conf new file mode 100644 index 00000000..0fafe075 --- /dev/null +++ b/examples/google-logout/auth_server.conf @@ -0,0 +1,48 @@ +# NGINX server declaration +server { + server_name xxx.xxxxxxxxxxxxxxxx.xxx/; + listen 443; + + # ---------SSL Cert Stuff Goes Here--------------- + + auth_request /validate; + + # We need to pass the access token to our logout server + # Make sure that Vouch's config.yml includes the declaration + # vouch.headers.accesstoken: X-Vouch-IdP-AccessToken + auth_request_set $access_token $upstream_http_x_vouch_idp_accesstoken; + auth_request_set $sub $upstream_http_x_vouch_idp_claims_sub; + + location = /validate { + proxy_pass http://vouch_backend/validate; + proxy_set_header Host $http_x_forwarded_host; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + error_page 401 = @error401; + + location @error401 { + return 302 https://vouch.xxxxxxxxxxxxxxxxxxxxxxxx.xxx/login?url=https://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # ---------------------Google Token Revocation--------------------- + location /logout { + proxy_pass http://127.0.0.1:8080; + + proxy_set_header X-Access-Token $access_token; + + # For logging + proxy_set_header X-Google-Sub $sub; + + # For redirection + # You may need to forward the host if using nested proxies + proxy_set_header X-Forwarded-Host $http_host; + # proxy_set_header X-Forwarded-Host $http_x_forwarded_host; + } + # =====================END AUTHENTICATION===================== +} + + diff --git a/examples/google-logout/config.yml b/examples/google-logout/config.yml new file mode 100644 index 00000000..2782a005 --- /dev/null +++ b/examples/google-logout/config.yml @@ -0,0 +1,30 @@ +vouch: + domains: + - xxxxxxxxxxxxxxxxxxxxxxxx.xxx + + headers: + # We need this token to pass along to our logout server. + # This unfortunately makes our Vouch cookie big, but we need it. + accesstoken: X-Vouch-IdP-AccessToken + + claims: + - sub + + cookie: + secure: true + + post_logout_redirect_uris: + - "https://xxx.xxxxxxxxxxxx.xxx/" + +oauth: + provider: google + client_id: xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com + client_secret: xxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxx + callback_urls: + - https://vouch.xxxxxxxxxxxxxxxxxxxxx.xxx/auth + preferredDomain: xxxxxxxxxxxxxxxxxxxxxxxx.xxx + + scopes: + - openid + - profile + - email \ No newline at end of file diff --git a/examples/google-logout/logout.py b/examples/google-logout/logout.py new file mode 100644 index 00000000..435150b7 --- /dev/null +++ b/examples/google-logout/logout.py @@ -0,0 +1,73 @@ +import logging +import requests + +from http.server import BaseHTTPRequestHandler, HTTPServer + +class S(BaseHTTPRequestHandler): + # Send access token to Google's revocation endpoint + def _revoke(self, token: str) -> requests.Response: + return requests.post('https://oauth2.googleapis.com/revoke', + params={'token': token}, + headers = {'content-type': 'application/x-www-form-urlencoded'}) + + def do_GET(self): + token = self.headers.get("X-Access-Token") + sub = self.headers.get("X-Google-Sub") + host = self.headers.get("X-Forwarded-Host") + + if not host: + host = self.headers.get("Host") + host = host.split('/')[0] + + if not sub: + sub = "Unknown User" + + if not token: + + logging.warning("No token header supplied.") + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + + self.wfile.write(f'

Whoops! Something went wrong. Return home

'.encode()) + return + + revoke_response = self._revoke(token) + if revoke_response.status_code >= 200 and revoke_response.status_code <= 299: + logging.info(f"Revoked access token {sub}") + else: + logging.warning(f"Failed to revoke access token {sub}") + + self.send_response(302) + self.send_header("Location", f"https://vouch.enrollmentmanagementservices.com/logout?url=https://{host}/") + self.end_headers() + return + +def run(server_class=HTTPServer, handler_class=S, port=8020): + logging.info(f"Attempting to serve logout.py on port {port}") + server_address = ('', port) + httpd = server_class(server_address, handler_class) + logging.info(f'Serving on port {port}') + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + httpd.server_close() + logging.info('Stopping logout.py\n') + +if __name__ == '__main__': + from sys import argv + + #logging.basicConfig(level=logging.INFO) + + logging.basicConfig(level=logging.INFO) + logging.info("Starting Logout.py") + + if len(argv) == 1: + logging.warning("Port not specified. Starting on the default 8080.") + logging.warning("To choose a different port, rerun this script with the desired port as the first CLI argument.") + port = 8080 + else: + port=int(argv[1]) + + run(port=port) diff --git a/examples/google-logout/logout_py.service b/examples/google-logout/logout_py.service new file mode 100644 index 00000000..63cc95d6 --- /dev/null +++ b/examples/google-logout/logout_py.service @@ -0,0 +1,13 @@ +[Unit] +Description=Logout.py +After=network.target + +[Service] +User=logout_py +Environment="PORT=8080" +ExecStart=/opt/logout_py/venv/bin/python3 /opt/logout_py/logout.py ${PORT} +RestartSec=5 +Restart=on-success + +[Install] +WantedBy=multi-user.target \ No newline at end of file