diff --git a/.dockerignore b/.dockerignore index bb0246c..35e5b24 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,7 @@ venv # Docker docker-compose.* env.d +!env.d/development/certs # Docs docs diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 1b01cda..f2b3a81 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 86f3cb5..d95515c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ node_modules .pytest_cache db.sqlite3 .mypy_cache +test-results/ # IDEs .idea/ diff --git a/Dockerfile b/Dockerfile index d934a30..fca4bf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index e9c57a7..1fd77ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index e238ebe..7d4d13d 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -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 diff --git a/docker/oidc-test-client/.dockerignore b/docker/oidc-test-client/.dockerignore new file mode 100644 index 0000000..9414382 --- /dev/null +++ b/docker/oidc-test-client/.dockerignore @@ -0,0 +1 @@ +Dockerfile diff --git a/docker/oidc-test-client/Dockerfile b/docker/oidc-test-client/Dockerfile new file mode 100644 index 0000000..6a4b619 --- /dev/null +++ b/docker/oidc-test-client/Dockerfile @@ -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"] diff --git a/docker/oidc-test-client/app.py b/docker/oidc-test-client/app.py new file mode 100644 index 0000000..f529e1e --- /dev/null +++ b/docker/oidc-test-client/app.py @@ -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") + diff --git a/docker/oidc-test-client/requirements.txt b/docker/oidc-test-client/requirements.txt new file mode 100644 index 0000000..7ee47b1 --- /dev/null +++ b/docker/oidc-test-client/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +oic==1.6.1 diff --git a/src/satosa/oidc2fer/frontends/__init__.py b/src/satosa/oidc2fer/frontends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/satosa/oidc2fer/frontends/jwt_userinfo_openid_connect.py b/src/satosa/oidc2fer/frontends/jwt_userinfo_openid_connect.py new file mode 100644 index 0000000..cb68dbc --- /dev/null +++ b/src/satosa/oidc2fer/frontends/jwt_userinfo_openid_connect.py @@ -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 diff --git a/src/satosa/plugins/frontends/openid_connect_frontend.yaml b/src/satosa/plugins/frontends/openid_connect_frontend.yaml index 3342796..6b7b2c7 100644 --- a/src/satosa/plugins/frontends/openid_connect_frontend.yaml +++ b/src/satosa/plugins/frontends/openid_connect_frontend.yaml @@ -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 diff --git a/src/satosa/pyproject.toml b/src/satosa/pyproject.toml index 5216350..2145638 100644 --- a/src/satosa/pyproject.toml +++ b/src/satosa/pyproject.toml @@ -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] diff --git a/src/satosa/pytest.ini b/src/satosa/pytest.ini new file mode 100644 index 0000000..7146b3f --- /dev/null +++ b/src/satosa/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --browser webkit --tracing on --video on --screenshot on diff --git a/src/satosa/tests/test_e2e.py b/src/satosa/tests/test_e2e.py new file mode 100644 index 0000000..2f4867f --- /dev/null +++ b/src/satosa/tests/test_e2e.py @@ -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()