Compare commits

...

14 Commits

Author SHA1 Message Date
Jonathan Perret 877f2f896b 🔧(config) add healthcheck to docker-compose
This makes docker-compose up --wait work.
2025-11-21 11:48:41 +01:00
Jonathan Perret 5884e28292 🔧(cnb) temporarily hardcode email
Our test account currently does not have an email
address in the IdP.
2025-11-21 11:46:50 +01:00
Jonathan Perret b62fae53ec 🔧(cnb) request signed assertions or response
At least in the "recette" environment responses are not signed, but
assertions are.
2025-11-21 11:46:49 +01:00
Jonathan Perret 4a4a180ef1 🔧(cnb) change supported name_id_format
To comply with the CNB IdP we need to support
urn:oasis:names:tc:SAML:2.0:nameid-format:transient.
2025-11-21 11:46:48 +01:00
Jonathan Perret 138bc431fd 📌(pysaml2) pin pysaml2 to avoid double-signing of AuthNRequests
The current version of pysaml2 (7.5.2) has an issue where AuthNRequests
are both signed in the XML and with an extra `Signature` queryparam.
This was reported initially in 2021:
    https://github.com/IdentityPython/pysaml2/issues/819
And it was fixed by a changed in SATOSA:
    https://github.com/IdentityPython/SATOSA/pull/380
But it reappeared apparently and the original reporter has a PR open
against pysaml2 that is supposed to fix it:
    https://github.com/IdentityPython/pysaml2/pull/973
They report that the regression was introduced in pysaml2 by
   https://github.com/IdentityPython/pysaml2/pull/834
We try here to pin pysaml2 to the last version before this PR was
merged. Unfortunately this is quite an old version, but from basic
testing it seems to still be compatible with the current SATOSA
version.
This in turn forces us to also pin xmlschema to avoid
    https://github.com/IdentityPython/pysaml2/issues/947

Hopefully this can be temporary.
2025-11-21 11:46:47 +01:00
Jonathan Perret fbb190b455 🧑‍💻(oidc-test-client) configure OIDC client on each request
Downloading the discovery document from the OIDC provider on
each request rather than on startup lets the oidc-test-client be
independent of the availability of the OIDC provider, which is useful
in development scenarios.
2025-11-21 11:46:46 +01:00
Jonathan Perret 2cb64a42a8 🚀(cnb) create cnb-dev environment
For local validation of Helm chart using Tilt.
2025-11-21 11:46:44 +01:00
Jonathan Perret 917313bb5a 🚀(cnb) create cnb-staging environment
Test deployment for CNB.
2025-11-21 11:45:33 +01:00
Jonathan Perret 769342494f 🔧(config) create config for CNB
First attempt at a configuration that may work for interoperation
with the CNB IdP.
2025-11-21 11:43:42 +01:00
Jonathan Perret 8ee0bacee0 🔧(config) create instance-specific configuration directories
The Docker image will now expect the working directory to be set,
for example to /app/config/fer for the current (RENATER) configuration.
2025-11-21 11:42:25 +01:00
Jonathan Perret dda5bb918d 🔧(config) create instance-specific configuration directories
The Docker image will now expect the working directory to be set,
for example to /app/config/fer for the current (RENATER) configuration.
2025-11-21 11:42:23 +01:00
Jonathan Perret 46c7a7eea7 🔨(dockerfile) fix development image to work with a different working dir
Because the `pip install --editable` command was run from an empty
directory, the `oidc2fer` directory that was later copied from the sources
did not show up as an importable module. This worked before changing
the working directory because the import falled back on looking in the
current directory.
2025-11-21 11:40:21 +01:00
Jonathan Perret 5a2d3bb35b 🔧(secrets) update secrets to current main
No change for oidc2fer secrets.
2025-11-21 11:40:20 +01:00
Jonathan Perret 6dc94af598 📌(helm) move to bitnamilegacy image for local Redis
Bitnami moved their images…
2025-11-21 11:40:19 +01:00
41 changed files with 435 additions and 26 deletions
+3
View File
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
- add alternate configuration for connection to CNB (#33)
## [1.0.10] - 2025-11-21
- rename helmfile.yaml for compatibility with helmfile 1.x
+11 -3
View File
@@ -37,6 +37,9 @@ CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/satosa.py"]
# ---- Development image ----
FROM common AS development
# Install curl (for healthchecks)
RUN apt-get update && apt-get install -qy curl
# Playwright browsers
ENV PLAYWRIGHT_BROWSERS_PATH=/pw-browsers
RUN pip install playwright
@@ -51,10 +54,15 @@ 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]
# Create empty module directory to please pip install --editable
# (without this, the oidc2fer module will not be importable because
# pip --editable scans the directory to create its "links")
RUN mkdir -p /app/oidc2fer
# Copy oidc2fer application (see .dockerignore)
# Install oidc2fer in editable mode along with development dependencies
RUN pip install --editable .[dev]
# Copy oidc2fer sources (see .dockerignore)
COPY ./src/satosa /app/
# Switch to unprivileged user
+1 -1
View File
@@ -15,4 +15,4 @@ docker_build(
watch_file('src/helm')
k8s_yaml(local('cd src/helm && helmfile -n oidc2fer -e dev template .'))
k8s_yaml(local('cd src/helm && helmfile -n oidc2fer -e %s template .' % os.getenv('TILT_ENV', 'dev')))
+8 -5
View File
@@ -38,24 +38,27 @@ services:
volumes:
- ./src/satosa:/app
- ./docker/files/usr/local/etc/gunicorn/satosa.py:/usr/local/etc/gunicorn/satosa.py
working_dir: /app/config/fer
depends_on:
- redis
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/ping"]
interval: 60s
timeout: 5s
retries: 3
start_period: 30s
start_interval: 1s
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 '
cat /usr/local/share/ca-certificates/mkcert-root-ca.crt >> $(python -m certifi)
sleep 1
cd /app
exec python app.py
'
+1
View File
@@ -38,6 +38,7 @@ printenv SAML2_BACKEND_CERT > /tmp/backend.crt
printenv SAML2_BACKEND_KEY > /tmp/backend.key
printenv OIDC_FRONTEND_KEY > /tmp/frontend.key
printenv CLIENT_DB_JSON > /tmp/client_db.json
printenv SAML2_IDP_METADATA > /tmp/idp_metadata.xml
# Redis database number must be specified
if [[ ! $OIDC_DB_URI =~ /[0-9]+$ ]]; then
+34 -13
View File
@@ -5,6 +5,7 @@ from oic import rndstr
from oic.oic.message import Claims, ClaimsRequest, RegistrationResponse
from oic.utils.http_util import Redirect
from oic.oic.message import AuthorizationResponse
from werkzeug.exceptions import InternalServerError
import secrets
import logging
import os
@@ -23,21 +24,27 @@ app.config.update(
}
)
client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
provider_info = client.provider_config(os.environ["OIDC_PROVIDER"])
def create_client() -> Client:
client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
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)
client.provider_config(os.environ["OIDC_PROVIDER"])
info = {
"client_id": os.environ["OIDC_CLIENT_ID"],
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
}
client.store_registration_info(RegistrationResponse(**info))
client.redirect_uris = [f"{os.environ['OIDC_ROOT_URL']}/redirect_uri"]
return client
@app.route("/")
def index():
client = create_client()
session["state"] = rndstr()
session["nonce"] = rndstr()
@@ -47,7 +54,7 @@ def index():
"response_type": "code",
"scope": os.environ["OIDC_SCOPES"].split(","),
"nonce": session["nonce"],
"redirect_uri": client.registration_response["redirect_uris"][0],
"redirect_uri": client.redirect_uris[0],
"state": session["state"],
"claims": ClaimsRequest(id_token=Claims(acr=None, amr=None)),
}
@@ -59,6 +66,8 @@ def index():
@app.route("/redirect_uri")
def oidc_callback():
client = create_client()
response = request.query_string.decode("utf-8")
aresp = client.parse_response(
AuthorizationResponse, info=response, sformat="urlencoded"
@@ -71,7 +80,6 @@ def oidc_callback():
error_response=aresp.to_dict(),
)
code = aresp["code"]
logging.info("got auth code=%s", code)
@@ -90,7 +98,20 @@ def oidc_callback():
logging.info("got userinfo=%s", userinfo)
return jsonify(
access_token_response=access_token_response.to_dict(), userinfo=userinfo.to_dict()
access_token_response=access_token_response.to_dict(),
userinfo=userinfo.to_dict(),
)
@app.errorhandler(InternalServerError)
def handle_server_exception(e):
exc = e.original_exception
import traceback
return (
f"""<h1>Internal Server Error</h1>
<pre>{"".join(traceback.format_exception(exc))}</pre>""",
500,
)
@@ -98,6 +119,6 @@ def oidc_callback():
def health():
return "OK"
if __name__ == "__main__":
app.run(host="0.0.0.0")
+2 -1
View File
@@ -1,2 +1,3 @@
# Python
PYTHONPATH=/app
LOG_LEVEL=DEBUG
LOG_LEVELS='{ "satosa.backends.saml2": "DEBUG" }'
+1 -1
Submodule secrets updated: b0d485544d...4e6030feee
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
image:
repository: localhost:5001/oidc2fer
pullPolicy: Always
tag: "latest"
satosa:
replicas: 1
workingDir: /app/config/cnb
envVars:
BASE_URL: https://oidc2fer.127.0.0.1.nip.io
GUNICORN_CMD_ARGS: --workers=3
LOG_LEVEL: debug
SAML2_ENTITY_ID: https://oidc2fer.127.0.0.1.nip.io/Saml2/proxy_saml2_backend.xml
SAML2_IDP_METADATA: { secretKeyRef: { name: oidc2fer, key: SAML2_IDP_METADATA } }
STATE_ENCRYPTION_KEY: { secretKeyRef: { name: oidc2fer, key: STATE_ENCRYPTION_KEY } }
SAML2_BACKEND_CERT: { secretKeyRef: { name: oidc2fer, key: SAML2_BACKEND_CERT } }
SAML2_BACKEND_KEY: { secretKeyRef: { name: oidc2fer, key: SAML2_BACKEND_KEY } }
OIDC_FRONTEND_KEY: { secretKeyRef: { name: oidc2fer, key: OIDC_FRONTEND_KEY } }
OIDC_DB_URI: "redis://:pass@redis-master:6379"
CLIENT_DB_JSON: |-
{
"oidc-test-client": {
"response_types": [
"id_token",
"code"
],
"redirect_uris": [
"https://oidc-test-client.traefik.me/redirect_uri",
"https://oidc-test-client.traefik.me:8443/redirect_uri"
],
"client_secret": "oidc-test-secret",
"token_endpoint_auth_method": "client_secret_post"
}
}
ingress:
enabled: true
host: oidc2fer.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
+1
View File
@@ -0,0 +1 @@
../../../../secrets/numerique-gouv/oidc2fer/env/cnb-staging/secrets.enc.yaml
@@ -0,0 +1,34 @@
image:
repository: ghcr.io/proconnect-gouv/oidc2fer
pullPolicy: Always
tag: "main"
satosa:
replicas: 2
workingDir: /app/config/cnb
envVars:
BASE_URL: https://oidc2cnb-staging.beta.numerique.gouv.fr
GUNICORN_CMD_ARGS: --workers=3
LOG_LEVEL: DEBUG
LOG_LEVELS: '{ "satosa.backends.saml2": "DEBUG" }'
SAML2_IDP_METADATA: { secretKeyRef: { name: oidc2fer, key: SAML2_IDP_METADATA } }
SAML2_ENTITY_ID: https://oidc2cnb-staging.beta.numerique.gouv.fr/Saml2/proxy_saml2_backend.xml
STATE_ENCRYPTION_KEY: { secretKeyRef: { name: oidc2fer, key: STATE_ENCRYPTION_KEY } }
SAML2_BACKEND_CERT: { secretKeyRef: { name: oidc2fer, key: SAML2_BACKEND_CERT } }
SAML2_BACKEND_KEY: { secretKeyRef: { name: oidc2fer, key: SAML2_BACKEND_KEY } }
OIDC_FRONTEND_KEY: { secretKeyRef: { name: oidc2fer, key: OIDC_FRONTEND_KEY } }
CLIENT_DB_JSON: { secretKeyRef: { name: oidc2fer, key: CLIENT_DB_JSON } }
OIDC_DB_URI: { secretKeyRef: { name: redis.redis.libre.sh, key: url } }
ingress:
enabled: true
host: oidc2cnb-staging.beta.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
@@ -5,6 +5,7 @@ image:
satosa:
replicas: 1
workingDir: /app/config/fer
envVars:
BASE_URL: https://oidc2fer.127.0.0.1.nip.io
GUNICORN_CMD_ARGS: --workers=3
@@ -5,6 +5,7 @@ image:
satosa:
replicas: 2
workingDir: /app/config/fer
envVars:
BASE_URL: https://oidc2fer-staging.beta.numerique.gouv.fr
GUNICORN_CMD_ARGS: --workers=3
+14 -2
View File
@@ -4,11 +4,21 @@ environments:
- version: 0.0.1
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
cnb-dev:
values:
- version: 0.0.1
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
staging:
values:
- version: 0.0.1
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
cnb-staging:
values:
- version: 0.0.1
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
outscale-production:
values:
- version: 0.0.1
@@ -22,7 +32,7 @@ repositories:
releases:
- name: redis
installed: {{ eq .Environment.Name "dev" | toYaml }}
installed: {{ or (eq .Environment.Name "dev") (eq .Environment.Name "cnb-dev") | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/redis
version: 18.19.2
@@ -30,9 +40,11 @@ releases:
- auth:
password: pass
architecture: standalone
image:
repository: bitnamilegacy/redis
- name: extra
installed: {{ ne .Environment.Name "dev" | toYaml }}
installed: {{ and (ne .Environment.Name "dev") (ne .Environment.Name "cnb-dev") | toYaml }}
namespace: {{ .Namespace }}
chart: ./extra
secrets:
@@ -38,6 +38,9 @@ spec:
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.satosa.workingDir }}
workingDir: {{ . | quote }}
{{- end }}
{{- with .Values.satosa.args }}
args:
{{- toYaml . | nindent 12 }}
+1
View File
@@ -5,6 +5,7 @@ metadata:
namespace: {{ .Release.Namespace | quote }}
stringData:
STATE_ENCRYPTION_KEY: {{ .Values.STATE_ENCRYPTION_KEY | quote }}
SAML2_IDP_METADATA: {{ .Values.SAML2_IDP_METADATA | toYaml | indent 8 }}
SAML2_BACKEND_CERT: {{ .Values.SAML2_BACKEND_CERT | toYaml | indent 8 }}
SAML2_BACKEND_KEY: {{ .Values.SAML2_BACKEND_KEY | toYaml | indent 8 }}
OIDC_FRONTEND_KEY: {{ .Values.OIDC_FRONTEND_KEY | toYaml | indent 8 }}
@@ -0,0 +1,24 @@
attributes:
givenname:
openid:
- given_name
saml:
- cnb_prenom
email:
openid:
- email
saml:
- cnb_email
usual_name:
openid:
- usual_name
saml:
- cnb_nom
acr:
openid:
- acr
uid:
openid:
- uid
saml:
- cnb_id
@@ -0,0 +1,49 @@
module: satosa.backends.saml2.SAMLBackend
name: Saml2
config:
entityid_endpoint: true
mirror_force_authn: false
memorize_idp: false
use_memorized_idp_when_force_authn: false
send_requester_id: false
enable_metadata_reload: false
acs_selection_strategy: prefer_matching_host
sp_config:
name: Passerelle ProConnect vers CNB
description: Passerelle ProConnect vers CNB
key_file: /tmp/backend.key
cert_file: /tmp/backend.crt
organization:
name: DINUM
display_name: DINUM
url: https://beta.gouv.fr/incubateurs/dinum_produits_interministeriels.html
metadata:
local:
- /tmp/idp_metadata.xml
entityid: !ENV SAML2_ENTITY_ID
accepted_time_diff: 60
allow_unknown_attributes: true
service:
sp:
ui_info:
display_name:
- lang: fr
text: Passerelle ProConnect vers CNB
logo:
text: https://beta.gouv.fr/img/incubators/logo_dinum.png
width: '200'
height: '200'
authn_requests_signed: true
want_response_signed: false
want_assertions_signed: false
want_assertions_or_response_signed: true
signing_algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
allow_unsolicited: true
name_id_format:
- urn:oasis:names:tc:SAML:2.0:nameid-format:transient
endpoints:
assertion_consumer_service:
- - <base_url>/<name>/acs/post
- 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
name_id_format_allow_create: true
@@ -0,0 +1,58 @@
module: oidc2fer.frontends.jwt_userinfo_openid_connect.JWTUserInfoOpenIDConnectFrontend
name: OIDC
config:
signing_key_path: /tmp/frontend.key
signing_key_id: frontend.key1
# Defines the database connection URI for the databases:
# - authz_code_db
# - access_token_db
# - refresh_token_db
# - sub_db
# - user_db
#
# supported storage backends:
# - In-memory dictionary
# - MongoDB (e.g. mongodb://db.example.com)
# - Redis (e.g. redis://example/0)
# - Stateless (eg. stateless://user:encryptionkey?alg=aes256)
#
# This configuration is optional.
# By default, the in-memory storage is used.
db_uri: !ENV OIDC_DB_URI
# Where to store clients.
#
# If client_db_uri is set, the database connection is used.
# Otherwise, if client_db_path is set, the JSON file is used.
# By default, an in-memory dictionary is used.
# client_db_uri: mongodb://db.example.com
client_db_path: /tmp/client_db.json
# if not specified, it is randomly generated on every startup
sub_hash_salt: randomSALTvalue
sub_mirror_public: yes
provider:
client_registration_supported: no
response_types_supported: ["code", "id_token token"]
subject_types_supported: ["public"]
scopes_supported:
- openid
- email
- uid
- given_name
- usual_name
# Set code/token lifetimes as short as possible
authorization_code_lifetime: 60
access_token_lifetime: 60
id_token_lifetime: 60
extra_scopes:
uid:
- uid
given_name:
- given_name
usual_name:
- usual_name
@@ -0,0 +1,12 @@
module: satosa.micro_services.attribute_processor.AttributeProcessor
name: AttributeProcessor
config:
process:
# Internally SATOSA represents most attributes as lists of strings.
# The SATOSA OIDC frontend automatically flattens standard OIDC attributes
# (like 'given_name' or 'email') to single strings when possible, but
# doesn't do that for custom attributes (like 'usual_name').
- attribute: usual_name
processors:
- module: oidc2fer.attribute_processors.flattening_processor
name: FlatteningProcessor
@@ -0,0 +1,22 @@
module: satosa.micro_services.primary_identifier.PrimaryIdentifier
name: PrimaryIdentifier
config:
# The ordered identifier candidates are searched in order
# to find a candidate primary identifier. The search ends
# when the first candidate is found. The identifier or attribute
# names are the internal SATOSA names for the attributes as
# defined in internal_attributes.yaml.
ordered_identifier_candidates:
- attribute_names: [uid]
# The internal SATOSA attribute into which to place the primary
# identifier value once found from the above configured ordered
# candidates.
primary_identifier: uid
# Whether or not to clear the input attributes after setting the
# primary identifier value.
clear_input_attributes: no
# Whether to replace subject_id with the constructed primary identifier
replace_subject_id: yes
@@ -0,0 +1,7 @@
module: satosa.micro_services.attribute_modifications.AddStaticAttributes
name: AddAttributes
config:
static_attributes:
acr: eidas1
# FIXME temporary hardcoded email for testing
email: ["test@cnb.avocat.fr"] # SATOSA wants this to be an array
+22
View File
@@ -0,0 +1,22 @@
BASE: !ENV BASE_URL
COOKIE_STATE_NAME: SATOSA_STATE
CONTEXT_STATE_DELETE: 'yes'
STATE_ENCRYPTION_KEY: !ENV STATE_ENCRYPTION_KEY
cookies_samesite_compat:
- - SATOSA_STATE
- SATOSA_STATE_LEGACY
INTERNAL_ATTRIBUTES: internal_attributes.yaml
BACKEND_MODULES:
- plugins/backends/saml2_backend.yaml
FRONTEND_MODULES:
- plugins/frontends/openid_connect_frontend.yaml
- plugins/frontends/ping_frontend.yaml
MICRO_SERVICES:
- plugins/microservices/primary_identifier.yaml
- plugins/microservices/static_attributes.yaml
- plugins/microservices/attribute_processor.yaml
LOGGING:
# All the logging configuration is done in the Gunicorn config, this is just
# here to avoid overwriting it with the default SATOSA config
version: 1
incremental: true

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="1; url=https://proconnect.gouv.fr/">
</head>
<body>
<h1>Passerelle ProConnect vers CNB. Redirection en cours…</h1>
</body>
</html>
@@ -0,0 +1,3 @@
module: satosa.frontends.ping.PingFrontend
name: ping
config: null
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

+7
View File
@@ -28,6 +28,13 @@ dependencies = [
"redis==5.0.4",
"JSON-log-formatter==1.0",
"WhiteNoise==6.7.0",
# Use the most recent pysaml2 that doesn't have the recurrence of
# https://github.com/IdentityPython/pysaml2/issues/819
# (AuthNRequests signed twice)
"pysaml2==7.1.0",
# Pin xmlschema like pysaml2 did in release 7.5.1, see
# https://github.com/IdentityPython/pysaml2/issues/947#issuecomment-1916767026
"xmlschema==2.5.1",
]
[project.urls]