-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 89c3fda
Showing
10 changed files
with
465 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
fastapi | ||
httpx | ||
uvicorn |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Unit Tests.""" |
Oops, something went wrong.