From 89c3fda4e0194aa57197f46616a1fc77dd934583 Mon Sep 17 00:00:00 2001 From: "teemu.kataja" Date: Mon, 11 Apr 2022 10:47:40 +0300 Subject: [PATCH] initial commit --- .gitignore | 9 ++ Dockerfile | 36 ++++++ README.md | 52 +++++++++ deploy/app.sh | 7 ++ main.py | 264 +++++++++++++++++++++++++++++++++++++++++++ requirements.dev.txt | 3 + requirements.txt | 16 +++ tests/__init__.py | 1 + tests/test_main.py | 42 +++++++ tox.ini | 35 ++++++ 10 files changed, 465 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 deploy/app.sh create mode 100644 main.py create mode 100644 requirements.dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_main.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa205e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.python-version +venv +__pycache__ +tests/__pycache__ +.pytest_cache +.mypy_cache +*.json +.tox +.coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd1d87f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.8-alpine3.13 as BUILD + +RUN apk add --update \ + && apk add --no-cache --virtual build-base libressl-dev libffi-dev gcc musl-dev python3-dev \ + && rm -rf /var/cache/apk/* + +COPY requirements.txt /root/requirements.txt + +RUN pip install --upgrade pip && \ + pip install -r /root/requirements.txt + +FROM python:3.8-alpine3.13 + +RUN apk add --no-cache --update bash + +COPY --from=BUILD /usr/local/lib/python3.8/ /usr/local/lib/python3.8/ + +COPY --from=BUILD /usr/local/bin/uvicorn /usr/local/bin/ + +RUN mkdir -p /app + +WORKDIR /app + +COPY ./deploy/app.sh /app/app.sh + +COPY ./main.py /app/main.py + +COPY ./config.json /app/config.json + +RUN chmod +x /app/app.sh + +RUN addgroup -g 1001 app && \ + adduser -D -u 1001 --disabled-password \ + --no-create-home -G app app + +ENTRYPOINT ["/bin/sh", "-c", "/app/app.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5946f3 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Tiny RP +Tiny RP is a small OpenID Connect Relying Party client that authenticates the user at the configured OpenID provider and saves the user's `id_token` and `access_token` to cookies. + +## Installation +- Developed with `python 3.8.5` +``` +pip install --upgrade pip +pip install -r requirements.txt +``` +Dependencies are listed in `requirements.dev.txt`, while versions are set for production in `requirements.txt`. + +## Configuration +Configuration variables are set in [config.json](config.json), which resides at the root of the directory. +``` +{ + "client_id": "", + "client_secret": "", + "url_oidc": "https://openid-provider.org/oidc/.well-known/openid-configuration", + "url_callback": "http://localhost:8080/callback", + "url_redirect": "http://localhost:8080/frontend", + "scope": "openid", + "cookie_domain": "" +} +``` +The app contacts `url_oidc` on startup and retrieves the `authorization_endpoint`, `token_endpoint` and `revocation_endpoint` values, which are used at `/login`, `/callback` and `/logout` respectively. + + +### Environment Variables +- `CONFIG_FILE=config.json` change location of configuration file +- `DEBUG=True` enable debug logging +- `APP_HOST=localhost` app hostname that can be passed to container +- `APP_PORT=8080` app port that can be passed to container + +## Run +### For Development +``` +uvicorn main:app --reload +``` +### For Deployment +Build image +``` +docker build -t cscfi/tiny-rp . +``` +Run container +``` +docker run -p 8080:8080 cscfi/tiny-rp +``` + +## Usage +- Navigate to http://localhost:8080/login +- `id_token` and `access_token` are saved to cookies at http://localhost:8080/callback after authentication at OpenID provider +- If a redirect address is configured `url_redirect` (e.g. a UI) the user is redirected there along with the cookies. If left empty, the tokens are instead displayed in JSON. diff --git a/deploy/app.sh b/deploy/app.sh new file mode 100755 index 0000000..312bb9c --- /dev/null +++ b/deploy/app.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +HOST=${APP_HOST:="0.0.0.0"} +PORT=${APP_PORT:="8080"} + +echo 'start tiny-rp' +exec uvicorn main:app --host $HOST --port $PORT diff --git a/main.py b/main.py new file mode 100644 index 0000000..112de44 --- /dev/null +++ b/main.py @@ -0,0 +1,264 @@ +"""Tiny OpenID Connect Relaying Party Client.""" + +import os +import sys +import json +import secrets +import logging + +from urllib.parse import urlencode +from distutils.util import strtobool +from typing import Tuple + +import httpx + +from fastapi import FastAPI, Cookie +from fastapi.exceptions import HTTPException +from fastapi.responses import PlainTextResponse, RedirectResponse, JSONResponse + +# the web app +app = FastAPI() + +# logging +formatting = '[%(asctime)s][%(name)s][%(process)d %(processName)s][%(levelname)-8s] (L:%(lineno)s) %(module)s | %(funcName)s: %(message)s' +logging.basicConfig(level=logging.DEBUG if bool(strtobool(os.environ.get('DEBUG', 'False'))) else logging.INFO, format=formatting) +LOG = logging.getLogger("tiny-rp") + +# configuration +config_file = os.environ.get("CONFIG_FILE", "config.json") +CONFIG = {} +try: + with open(config_file, "r") as f: + LOG.info(f"loading configuration file {config_file}") + CONFIG = json.loads(f.read()) + LOG.info("configuration loaded") + LOG.debug(CONFIG) +except Exception as e: + LOG.error(f"failed to load configuration file {config_file}, {e}") + sys.exit(e) + + +@app.on_event("startup") +async def startup_event(): + """Request OpenID configuration from OpenID provider.""" + async with httpx.AsyncClient(verify=False) as client: + # request OpenID provider endpoints from their configuration + LOG.debug(f"requesting OpenID configuration from {CONFIG['url_oidc']}") + response = await client.get(CONFIG["url_oidc"]) + if response.status_code == 200: + # store URLs for later use + LOG.debug("OpenID configuration received") + data = response.json() + CONFIG["url_auth"] = data.get("authorization_endpoint", "") + CONFIG["url_token"] = data.get("token_endpoint", "") + CONFIG["url_revoke"] = data.get("revocation_endpoint", "") + LOG.debug(f"new config: {CONFIG}") + else: + # we can't proceed without these URLs + LOG.error(f"failed to request OpenID configuration: {response.status_code}") + sys.exit(f"failed to retrieve OIDC configuration: {response.status_code}") + + +@app.get("/") +async def index_endpoint(): + """Index can be used as a health check endpoint.""" + LOG.debug("request to index") + return PlainTextResponse("tiny-rp") + + +@app.get("/login/") +async def login_endpoint(): + """Redirect the user to sign in at OpenID provider.""" + LOG.debug("request to login") + + # create parameters for authorisation request + LOG.debug("generating state for authorisation request") + state = secrets.token_hex() + LOG.debug(f"state: {state}") + params = { + "client_id": CONFIG["client_id"], + "response_type": "code", + "state": state, + "redirect_uri": CONFIG["url_callback"], + "scope": CONFIG["scope"] + } + + # prepare the redirection response + url = CONFIG["url_auth"] + "?" + urlencode(params) + LOG.debug(f"authorisation URL: {url}") + response = RedirectResponse(url) + + # store state cookie for callback verification + response.set_cookie(key="oidc_state", + value=state, + max_age=300, + httponly=True, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + + # redirect user to sign in at OpenID provider + LOG.debug("redirecting to OpenID provider") + return response + + +@app.get("/callback/") +async def callback_endpoint(oidc_state: str = Cookie(""), state: str = "", code: str = ""): + """Receive the user back from OpenID provider and then retrieves tokens.""" + LOG.debug("request to callback") + + # check that state is set to cookies + if oidc_state == "": + LOG.error("'oidc_state' cookie is missing") + raise HTTPException(401, "uninitialised session") + LOG.debug(f"cookie: oidc_state={oidc_state}") + + # check that state was received from OpenID provider + if state == "": + LOG.error("'state' query param is missing") + raise HTTPException(400, "missing required query parameter 'state'") + LOG.debug(f"query param: state={state}") + + # check that authorisation code was received from OpenID provider + if code == "": + LOG.error("'code' query param is missing") + raise HTTPException(400, "missing required query parameter 'code'") + LOG.debug(f"query param: code={code}") + + # verify that states match + if not secrets.compare_digest(oidc_state, state): + LOG.error(f"cookie state and query param state don't match: {oidc_state}!={state}") + raise HTTPException(403, "state mismatch") + LOG.debug("cookie state and query param state matched") + + # get tokens using the code received after authentication + LOG.debug("get tokens") + id_token, access_token = await request_tokens(code) + LOG.debug(f"id_token={id_token}, access_token={access_token}") + + if CONFIG["url_redirect"] == "": + # display tokens + LOG.debug("redirect address is not set, display tokens in JSON") + return {"id_token": id_token, "access_token": access_token} + else: + # save tokens to cookies and redirect + LOG.debug(f"save tokens to cookies and redirect user to {CONFIG['url_redirect']}") + + # prepare the redirection response + response = RedirectResponse(CONFIG["url_redirect"]) + + # store tokens to cookies + response.set_cookie(key="id_token", + value=id_token, + max_age=3600, + httponly=True, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + response.set_cookie(key="access_token", + value=access_token, + max_age=3600, + httponly=True, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + response.set_cookie(key="logged_in", + value="True", + max_age=3600, + httponly=False, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + + # redirect user + LOG.debug(f"redirecting to {CONFIG['url_redirect']}") + return response + + +@app.get("/logout") +async def logout_endpoint(id_token: str = Cookie(""), access_token: str = Cookie("")): + LOG.debug("request to logout") + + # revoke tokens at issuer + await revoke_token(id_token) + await revoke_token(access_token) + + # prepare the redirection response + response = RedirectResponse(CONFIG["url_redirect"]) + + # overwrite cookies with instantly expiring ones + response.set_cookie(key="id_token", + value="", + max_age=0, + httponly=True, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + response.set_cookie(key="access_token", + value="", + max_age=0, + httponly=True, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + response.set_cookie(key="logged_in", + value="", + max_age=0, + httponly=False, + secure=True, + domain=CONFIG.get("cookie_domain", None)) + + # redirect user + LOG.debug(f"redirecting to {CONFIG['url_redirect']}") + return response + + +async def request_tokens(code: str) -> Tuple[str, str]: + """Request tokens from OpenID provider.""" + LOG.debug(f"set up token request using code: {code}") + + # set up basic auth and payload + auth = httpx.BasicAuth(username=CONFIG["client_id"], password=CONFIG["client_secret"]) + LOG.debug("basic auth is set") + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": CONFIG["url_callback"] + } + LOG.debug(f"post payload: {data}") + + async with httpx.AsyncClient(auth=auth, verify=False) as client: + # request tokens + LOG.debug("requesting tokens") + response = await client.post(CONFIG["url_token"], data=data) + if response.status_code == 200: + # return token strings + LOG.debug("received tokens") + r = response.json() + return r["id_token"], r["access_token"] + else: + # if something went wrong on the provider side, we need to abort + LOG.error(f"didn't receive tokens from OpenID provider: {response.status_code}") + raise HTTPException(500, f"failed to retrieve tokens from provider: {response.status_code}") + + +async def revoke_token(token: str) -> None: + """Request token revocation at AAI.""" + LOG.debug("revoking token") + + auth = httpx.BasicAuth(username=CONFIG["client_id"], password=CONFIG["client_secret"]) + params = {"token": token} + + async with httpx.AsyncClient(auth=auth, verify=False) as client: + # send request to AAI + response = await client.get(CONFIG["url_revoke"] + "?" + urlencode(params)) + if response.status_code == 200: + LOG.debug("tokens revoked successfully") + else: + LOG.error(f"failed to revoke tokens {response.status_code}, remove cookies in any case and redirect") + + +@app.get("/token") +async def token_endpoint(id_token: str = Cookie(""), access_token: str = Cookie("")): + LOG.debug("display token from cookies in JSON response") + + response = { + "id_token": id_token, + "access_token": access_token, + } + + return JSONResponse(response) diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..c937678 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,3 @@ +fastapi +httpx +uvicorn diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04cec73 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +anyio==3.5.0 +asgiref==3.5.0 +certifi==2021.10.8 +charset-normalizer==2.0.12 +click==8.1.2 +fastapi==0.75.1 +h11==0.12.0 +httpcore==0.14.7 +httpx==0.22.0 +idna==3.3 +pydantic==1.9.0 +rfc3986==1.5.0 +sniffio==1.2.0 +starlette==0.17.1 +typing_extensions==4.1.1 +uvicorn==0.17.6 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8eef0e8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit Tests.""" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..718f601 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,42 @@ +import pytest + +from asynctest.mock import patch +from httpx import AsyncClient + +from main import app, login_endpoint + +MOCK_CONFIG = { + "client_id": "public", + "client_secret": "secret", + "url_oidc": "https://openid-provider.org/oidc/.well-known/openid-configuration", + "url_auth": "https://openid-provider.org/oidc/authorize", + "url_token": "https://openid-provider.org/oidc/token", + "url_callback": "http://localhost:8080/callback", + "url_redirect": "", + "scope": "openid", + "cookie_domain": "" +} + + +@pytest.mark.asyncio +async def test_index(): + async with AsyncClient(app=app, base_url="http://localhost:8080") as ac: + response = await ac.get("/") + assert response.status_code == 200 + assert response.text == "tiny-rp" + + +@pytest.mark.asyncio +@patch("main.CONFIG", return_value=MOCK_CONFIG) +async def test_login_endpoint(m): + async with AsyncClient(app=app, base_url="http://localhost:8080") as ac: + response = await ac.get("/login", allow_redirects=False) + assert response.status_code == 307 + + +@pytest.mark.asyncio +@patch("main.CONFIG", return_value=MOCK_CONFIG) +async def test_callback_endpoint(m): + async with AsyncClient(app=app, base_url="http://localhost:8080") as ac: + response = await ac.get("/callback", allow_redirects=False) + assert response.status_code == 307 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..11846db --- /dev/null +++ b/tox.ini @@ -0,0 +1,35 @@ +[tox] +envlist = flake8,mypy,bandit,unit_tests +skipsdist = True + +; python code style validation +[flake8] +skip_install = true +deps = + flake8 + flake8-docstrings +commands = flake8 main.py + +; type validation +[mypy] +skip_install = true +deps = mypy +commands = mypy --ignore-missing-imports main.py + +; find common code vulnerabilities statically +[bandit] +skip_install = true +deps = bandit +commands = bandit main.py + +; unit tests +[unit_tests] +deps = + -rrequirements.txt + pytest + pytest-asyncio +commands = pytest tests + +[gh-actions] +python = + 3.8: flake8,mypy,bandit,unit_tests