🔒️(oidc) return JWT from userinfo endpoint

AgentConnect apparently requires the userinfo endpoint to return a
signed JWT.
This commit is contained in:
Jonathan Perret
2024-04-26 00:55:42 +02:00
parent 94cdbc9497
commit b50126675a
16 changed files with 287 additions and 67 deletions
+1
View File
@@ -13,6 +13,7 @@ venv
# Docker
docker-compose.*
env.d
!env.d/development/certs
# Docs
docs
+1
View File
@@ -41,6 +41,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
target: production
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
+1
View File
@@ -64,6 +64,7 @@ node_modules
.pytest_cache
db.sqlite3
.mypy_cache
test-results/
# IDEs
.idea/
+48 -58
View File
@@ -1,7 +1,7 @@
# ---- base image to inherit from ----
FROM python:3.11-slim-bookworm as base
FROM python:3.11-slim-bookworm as common
# Install Install xmlsec1 dependencies required for xmlsec (for SAML)
# Install xmlsec1 dependencies required for xmlsec (for SAML)
# Needs to be kept before the `pip install`
RUN apt-get update && \
apt-get -y upgrade && \
@@ -17,74 +17,64 @@ RUN apt-get update && \
# We want the most up-to-date stable pip release
RUN pip install --upgrade pip
# ---- back-end builder image ----
FROM base as back-builder
WORKDIR /builder
# Copy required python dependencies
COPY ./src/satosa /builder
RUN mkdir /install && \
pip install --prefix=/install .
# ---- Core application image ----
FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# Give the "root" group the same permissions as the "root" user on /etc/passwd
# to allow a user belonging to the root group to add new users; typically the
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# Un-privileged user running the application
ARG DOCKER_USER
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/satosa.py /usr/local/etc/gunicorn/satosa.py
# The default command runs gunicorn WSGI server in satosa's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/satosa.py", "satosa.wsgi:app"]
# ---- Development image ----
FROM common as development
# Playwright browsers
ENV PLAYWRIGHT_BROWSERS_PATH=/pw-browsers
RUN pip install playwright
RUN playwright install --with-deps webkit
# Test root CA
COPY env.d/development/certs/mkcert-root-ca.pem /usr/local/share/ca-certificates/mkcert-root-ca.crt
RUN update-ca-certificates
WORKDIR /app
# Copy project file to list dependencies
COPY ./src/satosa/pyproject.toml /app/
# Install oidc2fer in editable mode along with development dependencies
RUN pip install -e .[dev]
# Copy oidc2fer application (see .dockerignore)
COPY ./src/satosa /app/
# Switch to unprivileged user
USER ${DOCKER_USER}
# ---- Production image (keep last so it is the default target) ----
FROM common as production
# Copy oidc2fer application (see .dockerignore)
COPY ./src/satosa /app/
WORKDIR /app
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
RUN pip install .
# ---- Production image ----
FROM core as production
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/satosa.py /usr/local/etc/gunicorn/satosa.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# The default command runs gunicorn WSGI server in satosa's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/satosa.py", "satosa.wsgi:app"]
# ---- Development image ----
FROM production as development
# Switch back to the root user to install development dependencies
USER root:root
# Uninstall oidc2fer and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y oidc2fer
RUN pip install -e .[dev]
# Restore the un-privileged user running the application
ARG DOCKER_USER
# Switch to unprivileged user
USER ${DOCKER_USER}
+13 -7
View File
@@ -18,9 +18,10 @@ services:
"code"
],
"redirect_uris": [
"https://oidc-test-client.traefik.me/auth/callback"
"https://oidc-test-client.traefik.me/redirect_uri"
],
"client_secret": "oidc-test-secret"
"client_secret": "oidc-test-secret",
"token_endpoint_auth_method": "client_secret_post"
}
}
env_file:
@@ -30,24 +31,29 @@ services:
- ./src/satosa:/app
oidc-test-client:
image: ghcr.io/beryju/oidc-test-client
build: docker/oidc-test-client
depends_on:
- nginx # nginx must be up to override satosa.traefik.me resolution
- app-dev
stop_signal: SIGKILL
volumes:
- ./docker/oidc-test-client:/app
- ./env.d/development/certs/mkcert-root-ca.pem:/usr/local/share/ca-certificates/mkcert-root-ca.crt
# - run update-ca-certificates to make mkcert certificates valid
# - add startup delay because IDP must be available on boot
entrypoint: /bin/bash -c 'update-ca-certificates && sleep 1; exec /oidc-test-client'
entrypoint: |
/bin/bash -c '
cat /usr/local/share/ca-certificates/mkcert-root-ca.crt >> $(python -m certifi)
sleep 1
cd /app
exec python app.py
'
environment:
OIDC_ROOT_URL: https://oidc-test-client.traefik.me
OIDC_PROVIDER: https://satosa.traefik.me
#OIDC_PROVIDER: https://oidc2fer-staging.beta.numerique.gouv.fr
OIDC_CLIENT_ID: oidc-test-client
OIDC_CLIENT_SECRET: oidc-test-secret
OIDC_DO_INTROSPECTION: false
OIDC_DO_REFRESH: false
OIDC_DO_USER_INFO: true
OIDC_SCOPES: openid,uid,given_name,usual_name,email
nginx:
+1 -1
View File
@@ -35,7 +35,7 @@ server {
ssl_certificate_key /etc/nginx/certs/key.pem;
location / {
set $backend "http://oidc-test-client:9009";
set $backend "http://oidc-test-client:5000";
proxy_pass $backend; # using a variable to force late name resolution
# Set headers so downstream app will know what URL the client is using
+1
View File
@@ -0,0 +1 @@
Dockerfile
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.11
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
HEALTHCHECK --start-period=30s --start-interval=1s CMD curl --fail http://localhost:5000/healthz
CMD ["python", "app.py"]
+99
View File
@@ -0,0 +1,99 @@
from flask import Flask, jsonify, request, session
from oic.oic import Client
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
from oic import rndstr
from oic.oic.message import RegistrationResponse
from oic.utils.http_util import Redirect
from oic.oic.message import AuthorizationResponse
import secrets
import webbrowser
import threading
import time
import logging
import os
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
app = Flask(__name__)
app.config.update(
{
"SECRET_KEY": secrets.token_hex(
10
), # random key so that sessions don't survive restarts
}
)
client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
provider_info = client.provider_config("https://satosa.traefik.me")
info = {
"client_id": os.environ["OIDC_CLIENT_ID"],
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
"redirect_uris": [f"{os.environ['OIDC_ROOT_URL']}/redirect_uri"],
}
client_reg = RegistrationResponse(**info)
client.store_registration_info(client_reg)
@app.route("/")
def index():
session["state"] = rndstr()
session["nonce"] = rndstr()
auth_req = client.construct_AuthorizationRequest(
request_args={
"client_id": client.client_id,
"response_type": "code",
"scope": os.environ["OIDC_SCOPES"].split(","),
"nonce": session["nonce"],
"redirect_uri": client.registration_response["redirect_uris"][0],
"state": session["state"],
}
)
login_url = auth_req.request(client.authorization_endpoint)
return Redirect(login_url)
@app.route("/redirect_uri")
def oidc_callback():
response = request.query_string.decode("utf-8")
aresp = client.parse_response(
AuthorizationResponse, info=response, sformat="urlencoded"
)
assert aresp["state"] == session["state"]
code = aresp["code"]
logging.info("got auth code=%s", code)
access_token_response = client.do_access_token_request(
state=aresp["state"],
request_args={"code": aresp["code"]},
authn_method="client_secret_post",
)
access_token = access_token_response["access_token"]
logging.info("got access_token=%s", access_token)
# userinfo = client.do_user_info_request(state=aresp["state"])
userinfo = client.do_user_info_request(access_token=access_token)
logging.info("got userinfo=%s", userinfo)
return jsonify(
access_token_response=access_token_response.to_dict(), userinfo=userinfo.to_dict()
)
@app.route("/healthz")
def health():
return "OK"
if __name__ == "__main__":
app.run(host="0.0.0.0")
+2
View File
@@ -0,0 +1,2 @@
Flask==3.0.3
oic==1.6.1
@@ -0,0 +1,35 @@
import logging
from urllib.parse import urlencode
from oic.oic.message import UserInfoErrorResponse
from pyop.access_token import AccessToken
from pyop.exceptions import BearerTokenError, InvalidAccessToken
from satosa.frontends.openid_connect import OpenIDConnectFrontend
from satosa.response import Response, Unauthorized
logger = logging.getLogger(__name__)
class JWTUserInfoOpenIDConnectFrontend(OpenIDConnectFrontend):
def userinfo_endpoint(self, context):
headers = {"Authorization": context.request_authorization}
try:
response = self.provider.handle_userinfo_request(
request=urlencode(context.request),
http_headers=headers,
)
return Response(
response.to_jwt([self.signing_key], self.signing_key.alg),
content="application/jwt",
)
except (BearerTokenError, InvalidAccessToken) as e:
error_resp = UserInfoErrorResponse(
error="invalid_token", error_description=str(e)
)
response = Unauthorized(
error_resp.to_json(),
headers=[("WWW-Authenticate", AccessToken.BEARER_TOKEN_TYPE)],
content="application/json",
)
return response
@@ -1,4 +1,4 @@
module: satosa.frontends.openid_connect.OpenIDConnectFrontend
module: oidc2fer.frontends.jwt_userinfo_openid_connect.JWTUserInfoOpenIDConnectFrontend
name: OIDC
config:
signing_key_path: /tmp/frontend.key
+1
View File
@@ -39,6 +39,7 @@ dev = [
"pytest-cov==4.1.0",
"pytest==8.0.2",
"ruff==0.2.2",
"pytest-playwright==0.4.4",
]
[tool.setuptools]
+2
View File
@@ -0,0 +1,2 @@
[pytest]
addopts = --browser webkit --tracing on --video on --screenshot on
+70
View File
@@ -0,0 +1,70 @@
import json
import os
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session")
def browser_context_args():
return {"locale": "fr-FR"}
def renater_test_idp(page):
page.get_by_label("Nom d'utilisateur").fill("etudiant1")
page.get_by_label("Mot de passe").fill("etudiant1")
page.get_by_role("button", name="Connexion").click()
def renater_wayf(page):
page.get_by_text("Veuillez sélectionner").click()
page.get_by_role("option", name="GIP RENATER - IdP de test", exact=True).click()
page.get_by_role("button", name="Sélection").click()
@pytest.mark.skipif(
"TEST_E2E" not in os.environ, reason="Depends on app running locally"
)
def test_oidc_to_renater(page: Page):
page.goto("https://oidc-test-client.traefik.me")
renater_wayf(page)
renater_test_idp(page)
expect(page.locator("pre")).to_contain_text('"usual_name":"Dupont"')
text = page.inner_text("pre")
result = json.loads(text)
id_token = result["access_token_response"]["id_token"]
assert {"acr": "eidas1"}.items() <= id_token.items()
userinfo = result["userinfo"]
assert {
"email": "jean.dupont@formation.renater.fr",
"given_name": "Jean",
"uid": "etudiant1@test-renater.fr",
"usual_name": "Dupont",
}.items() <= userinfo.items()
def agent_connect_login(page: Page):
page.goto("https://fsa1v2.integ01.dev-agentconnect.fr/")
page.get_by_label("Connexion à AgentConnect").click()
page.get_by_label("Email professionnel").fill("jean.dupont@formation.renater.fr")
page.get_by_test_id("interaction-connection-button").click()
@pytest.mark.skipif(
"TEST_E2E_AC" not in os.environ, reason="Depends on staging deployment"
)
def test_agent_connect_to_renater(page: Page):
agent_connect_login(page)
renater_wayf(page)
renater_test_idp(page)
expect(page.locator("body")).to_contain_text("jean.dupont@formation.renater.fr")
text = page.inner_text("#json")
result = json.loads(text)
assert {
"email": "jean.dupont@formation.renater.fr",
"given_name": "Jean",
"uid": "etudiant1@test-renater.fr",
"usual_name": "Dupont",
}.items() <= result.items()