🔒️(oidc) return JWT from userinfo endpoint
AgentConnect apparently requires the userinfo endpoint to return a signed JWT.
This commit is contained in:
@@ -13,6 +13,7 @@ venv
|
||||
# Docker
|
||||
docker-compose.*
|
||||
env.d
|
||||
!env.d/development/certs
|
||||
|
||||
# Docs
|
||||
docs
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -64,6 +64,7 @@ node_modules
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
test-results/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
|
||||
+48
-58
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
addopts = --browser webkit --tracing on --video on --screenshot on
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user