Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
teemukataja committed Apr 11, 2022
0 parents commit 89c3fda
Show file tree
Hide file tree
Showing 10 changed files with 465 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.python-version
venv
__pycache__
tests/__pycache__
.pytest_cache
.mypy_cache
*.json
.tox
.coverage
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions deploy/app.sh
Original file line number Diff line number Diff line change
@@ -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
264 changes: 264 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions requirements.dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fastapi
httpx
uvicorn
16 changes: 16 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit Tests."""
Loading

0 comments on commit 89c3fda

Please sign in to comment.