Compare commits
25 Commits
v1.0.4
...
refacot-ci
| Author | SHA1 | Date | |
|---|---|---|---|
| f5e13bdb75 | |||
| c3204ef282 | |||
| ae2d4a573b | |||
| 5be05fe43e | |||
| f90df5c2c6 | |||
| 6bbcd95069 | |||
| b839c32b50 | |||
| 9b062aa27d | |||
| 4a2ece217b | |||
| a7577ff85e | |||
| 4e81e1e750 | |||
| c27ebfe6ae | |||
| d84f2b32d5 | |||
| 9cb67bfb87 | |||
| ba1774bdbf | |||
| cb8ff5d878 | |||
| a6e01c071e | |||
| 5a0f2da202 | |||
| 3b2d50d657 | |||
| 52d7de305c | |||
| 64a0d4dd3d | |||
| ffbd161e29 | |||
| ba1c14291d | |||
| 4c4ccc88ba | |||
| c1b33453c3 |
@@ -65,29 +65,9 @@ jobs:
|
||||
needs:
|
||||
- build-and-push
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "oidc2fer,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/oidc2fer/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
deployment_repo_path: "${{ github.repository }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:v0.171.0
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "oidc2fer,secrets"
|
||||
@@ -6,6 +6,14 @@ 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).
|
||||
|
||||
## [1.0.6] - 2025-03-11
|
||||
- upgrade to SATOSA 8.5.1
|
||||
- allow faculty, staff, employee, researcher, teacher affiliations
|
||||
|
||||
## [1.0.5] - 2025-03-06
|
||||
- add allowlist for URL paths to nginx ingress
|
||||
- allow customizing log levels per logger
|
||||
|
||||
## [1.0.4] - 2024-09-11
|
||||
- serve static files
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@ number and date.
|
||||
3. Commit and push to `main`.
|
||||
4. Update the `production` tag and push it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| variable | usage |
|
||||
| --- | --- |
|
||||
| `LOG_LEVEL` | Sets the log level for the root logger, i.e. the default. Defaults to `INFO`. |
|
||||
| `LOG_LEVELS` | A JSON object that can be used to set log levels for specific loggers, e.g. `{"satosa.backends.saml2": "DEBUG"}`. Defaults to `{}`. |
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ services:
|
||||
BASE_URL: https://satosa.traefik.me
|
||||
GUNICORN_CMD_ARGS: --workers=2
|
||||
TEST_E2E: 1
|
||||
#TEST_E2E_AC: 1
|
||||
#TEST_E2E_PC: 1
|
||||
OIDC_DB_URI: redis://redis/0
|
||||
CLIENT_DB_JSON: |
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -29,6 +30,8 @@ class RequestJSONFormatter(json_log_formatter.JSONFormatter):
|
||||
url += f"?{record.args['q']}"
|
||||
|
||||
return {
|
||||
"logger": record.name,
|
||||
"level": record.levelname,
|
||||
"remote_ip": record.args["h"],
|
||||
"method": record.args["m"],
|
||||
"path": url,
|
||||
@@ -37,7 +40,7 @@ class RequestJSONFormatter(json_log_formatter.JSONFormatter):
|
||||
"user_agent": record.args["a"],
|
||||
"referer": record.args["f"],
|
||||
"duration_in_ms": record.args["M"],
|
||||
"poppid": record.process,
|
||||
"pid": record.process,
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +58,23 @@ class DefaultJSONFormatter(json_log_formatter.JSONFormatter):
|
||||
payload: dict[str, str | int | float] = super().json_record(
|
||||
message, extra, record
|
||||
)
|
||||
payload["logger"] = record.name
|
||||
payload["level"] = record.levelname
|
||||
payload["pid"] = record.process
|
||||
return payload
|
||||
|
||||
|
||||
class NoPingFilter(logging.Filter):
|
||||
"""
|
||||
Filters out /ping requests from the access log.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return not( record.args.get("m", "") == "GET"
|
||||
and record.args.get("U", "") == "/ping"
|
||||
and str(record.args.get("s", "")) == "200" )
|
||||
|
||||
|
||||
bind = ["0.0.0.0:8000"]
|
||||
name = "satosa"
|
||||
python_path = "/app"
|
||||
@@ -76,6 +91,9 @@ accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
loglevels_json = os.environ.get("LOG_LEVELS", "{}")
|
||||
loglevels = json.loads(loglevels_json)
|
||||
|
||||
logconfig_dict = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": True,
|
||||
@@ -95,7 +113,24 @@ logconfig_dict = {
|
||||
"handlers": ["json_request"],
|
||||
"propagate": False,
|
||||
"qualname": "gunicorn.access",
|
||||
"filters": [NoPingFilter()],
|
||||
},
|
||||
"satosa.base": {
|
||||
"level": "INFO",
|
||||
},
|
||||
"satosa.state": {
|
||||
"level": "INFO",
|
||||
},
|
||||
"satosa.proxy_server": {
|
||||
"level": "INFO",
|
||||
},
|
||||
"satosa.routing": {
|
||||
"level": "INFO",
|
||||
},
|
||||
"satosa.frontends.ping": {
|
||||
"level": "INFO",
|
||||
},
|
||||
**{k: {"level": v.upper()} for k, v in loglevels.items()},
|
||||
},
|
||||
"formatters": {
|
||||
"json_request": {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Principe de fonctionnement de la passerelle ProConnect vers RENATER (`OIDC2FER`)
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce premier schéma donne un aperçu des échanges entre :
|
||||
* un Fournisseur de Service ProConnecté (ex. https://pad.numerique.gouv.fr) ;
|
||||
* le service ProConnect (hébergé sur `https://auth.agentconnect.gouv.fr`) ;
|
||||
* la passerelle `OIDC2FER` (hébergé sur `https://renater.agentconnect.gouv.fr`) ;
|
||||
* le service de _discovery_ ou _Where Are You From (WAYF)_ RENATER qui permet à l'utilisateur de choisir son établissement de rattachement, hébergé sur https://discovery.renater.fr/agentconnect/ ;
|
||||
* le serveur d'identité SAML de l'établissement sélectionné (par exemple, https://cas.inria.fr).
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
participant FS as Fournisseur<br>de Service
|
||||
participant ProConnect
|
||||
box LightYellow
|
||||
participant OIDC2FER
|
||||
end
|
||||
participant WAYF as WAYF<br>RENATER
|
||||
participant IdP as IdP SAML
|
||||
|
||||
FS->>ProConnect: OIDC auth request
|
||||
ProConnect->>OIDC2FER: OIDC auth request
|
||||
OIDC2FER->>WAYF: discovery request
|
||||
WAYF->>OIDC2FER: IdP entityID
|
||||
OIDC2FER->>IdP: SAML AuthnRequest
|
||||
IdP->>OIDC2FER: SAML Assertion
|
||||
OIDC2FER->>ProConnect: OIDC tokens+userinfo
|
||||
ProConnect->>FS: OIDC tokens+userinfo
|
||||
```
|
||||
|
||||
## Détail des échanges
|
||||
|
||||
Noter que le schéma précédent ne reflète pas le détail des échanges entre l'utilisateur, son navigateur (qu'il est utile de distinguer pour illustrer que plusieurs échanges se font sans intervention humaine), et les différents services mentionnés. En voici une version exhaustive :
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor Utilisateur
|
||||
participant Navigateur
|
||||
participant FS as Fournisseur<br>de Service
|
||||
participant ProConnect
|
||||
box LightYellow
|
||||
participant OIDC2FER
|
||||
end
|
||||
participant WAYF as WAYF<br>RENATER
|
||||
participant IdP SAML
|
||||
|
||||
Utilisateur->>Navigateur: Saisie/clic URL FS
|
||||
Navigateur->>FS: Requête d'une page du FS
|
||||
opt si pas de session FS ouverte
|
||||
FS->>Utilisateur: Présentation page d'accueil avec bouton ProConnect
|
||||
Utilisateur->>FS: Clic bouton ProConnect
|
||||
FS->>Navigateur: Redirection OIDC vers ProConnect avec state, nonce
|
||||
Navigateur->>ProConnect: Requête GET avec state, nonce
|
||||
opt si pas de session ProConnect ouverte
|
||||
ProConnect->>Utilisateur: Présentation mire ProConnect
|
||||
Utilisateur->>ProConnect: Clic bouton RENATER, ou saisie "robert@univ-exemple.fr"
|
||||
ProConnect->>Navigateur: Redirection OIDC vers OIDC2FER avec state, nonce
|
||||
Navigateur->>OIDC2FER: Requête GET avec state, nonce
|
||||
OIDC2FER->>Navigateur: Redirection vers WAYF
|
||||
Navigateur->>WAYF: Requête GET
|
||||
opt si pas de préselection enregistrée dans le WAYF
|
||||
WAYF->>Utilisateur: Présentation liste d'établissements autorisés
|
||||
Utilisateur->>WAYF: Choix d'un établissement
|
||||
end
|
||||
WAYF->>Navigateur: Redirection vers OIDC2FER avec entityID IdP
|
||||
Navigateur->>OIDC2FER: Requête GET avec entityID IdP
|
||||
OIDC2FER->>Navigateur: Redirection vers IdP avec AuthnRequest SAML dans l'URL
|
||||
Navigateur->>IdP SAML: Requête GET avec AuthnRequest SAML dans l'URL
|
||||
opt si pas de session IdP ouverte
|
||||
IdP SAML->>Utilisateur: Présentation mire de connexion IdP
|
||||
Utilisateur->>IdP SAML: Saisie identifiants de connexion
|
||||
end
|
||||
IdP SAML->>Navigateur: Page avec formulaire contenant assertion SAML
|
||||
Navigateur->>OIDC2FER: Requête POST avec assertion SAML
|
||||
Note over OIDC2FER: Validation<br>eduPersonAffiliation
|
||||
Note over OIDC2FER: Conversion attributs<br>SAML vers OIDC
|
||||
OIDC2FER->>Navigateur: Redirection vers callback OIDC ProConnect avec authorization_code
|
||||
Navigateur->>ProConnect: Requête GET avec authorization_code
|
||||
ProConnect->>OIDC2FER: Demande tokens avec authorization_code
|
||||
OIDC2FER->>ProConnect: Tokens OIDC
|
||||
ProConnect->>OIDC2FER: Demande userinfo avec accessToken
|
||||
OIDC2FER->>ProConnect: userinfo
|
||||
end
|
||||
ProConnect->>Navigateur: Redirection vers callback OIDC FS avec authorization_code
|
||||
Navigateur->>FS: Requête GET avec authorization_code
|
||||
FS->>ProConnect: Demande tokens avec authorization_code
|
||||
ProConnect->>FS: Tokens OIDC
|
||||
FS->>ProConnect: Demande userinfo avec accessToken
|
||||
ProConnect->>FS: userinfo
|
||||
end
|
||||
FS->>Utilisateur: Contenu du service
|
||||
```
|
||||
+1
-1
Submodule secrets updated: 647b88689c...b0d485544d
@@ -1,7 +1,7 @@
|
||||
image:
|
||||
repository: lasuite/oidc2fer
|
||||
pullPolicy: Always
|
||||
tag: "v1.0.3"
|
||||
tag: "v1.0.6"
|
||||
|
||||
satosa:
|
||||
replicas: 2
|
||||
@@ -9,7 +9,8 @@ satosa:
|
||||
BASE_URL: https://renater.agentconnect.gouv.fr
|
||||
GUNICORN_CMD_ARGS: --workers=3
|
||||
|
||||
LOG_LEVEL: info
|
||||
LOG_LEVEL: INFO
|
||||
LOG_LEVELS: '{ "satosa.backends.saml2": "DEBUG" }'
|
||||
|
||||
SAML2_DISCOVERY_URL: https://discovery.renater.fr/agentconnect/
|
||||
SAML2_METADATA_URL: https://pub.federation.renater.fr/metadata/renater/main/main-idps-renater-metadata.xml
|
||||
|
||||
@@ -9,7 +9,8 @@ satosa:
|
||||
BASE_URL: https://oidc2fer-staging.beta.numerique.gouv.fr
|
||||
GUNICORN_CMD_ARGS: --workers=3
|
||||
|
||||
LOG_LEVEL: debug
|
||||
LOG_LEVEL: DEBUG
|
||||
LOG_LEVELS: '{ "satosa.backends.saml2": "DEBUG" }'
|
||||
|
||||
SAML2_DISCOVERY_URL: https://discovery.renater.fr/test/
|
||||
SAML2_METADATA_URL: https://pub.federation.renater.fr/metadata/test/preview/preview-idps-test-metadata.xml
|
||||
@@ -28,5 +29,5 @@ ingress:
|
||||
host: oidc2fer-staging.beta.numerique.gouv.fr
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
cert-manager.io/cluster-issuer: letsencrypt
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "oidc2fer.fullname" . -}}
|
||||
{{- $port := .Values.satosa.service.port -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -33,13 +34,15 @@ spec:
|
||||
- host: {{ .Values.ingress.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
{{- range .Values.ingress.paths }}
|
||||
- path: {{ .path | quote }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "oidc2fer.satosa.fullname" . }}
|
||||
name: {{ $fullName }}-satosa
|
||||
port:
|
||||
number: {{ .Values.satosa.service.port }}
|
||||
number: {{ $port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -82,3 +82,16 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
{{ if .Values.satosa.pdb.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
maxUnavailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "oidc2fer.common.selectorLabels" (list . $component) | nindent 6 }}
|
||||
{{ end }}
|
||||
|
||||
@@ -33,7 +33,9 @@ ingress:
|
||||
enabled: false
|
||||
className: null
|
||||
host: oidc2fer.example.com
|
||||
path: /
|
||||
paths:
|
||||
- path: '/$|^/\.well-known/openid-configuration$|^/images/|^/Saml2/|^/OIDC/|^/ping$'
|
||||
pathType: ImplementationSpecific # enables regex matching
|
||||
## @param ingress.hosts Additional host to configure for the Ingress
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
@@ -48,10 +50,17 @@ ingress:
|
||||
## @param ingress.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
|
||||
## @section satosa
|
||||
|
||||
satosa:
|
||||
|
||||
## @param satosa.pdb.enabled Enable pdb on backend
|
||||
pdb:
|
||||
enabled: true
|
||||
|
||||
## @param satosa.command Override the satosa container command
|
||||
command: []
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Saml2
|
||||
config:
|
||||
disco_srv: !ENV SAML2_DISCOVERY_URL
|
||||
entityid_endpoint: true
|
||||
mirror_force_authn: 'no'
|
||||
memorize_idp: 'no'
|
||||
use_memorized_idp_when_force_authn: 'no'
|
||||
send_requester_id: 'no'
|
||||
enable_metadata_reload: 'no'
|
||||
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 OIDC vers FER
|
||||
|
||||
@@ -13,4 +13,8 @@ config:
|
||||
# later in the PrimaryIdentifier processor.
|
||||
- ".+"
|
||||
eduPersonAffiliation:
|
||||
- "^faculty$"
|
||||
- "^staff$"
|
||||
- "^employee$"
|
||||
- "^researcher$"
|
||||
- "^teacher$"
|
||||
|
||||
@@ -23,7 +23,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"SATOSA==8.4.0",
|
||||
"SATOSA==8.5.1",
|
||||
"gunicorn==22.0.0",
|
||||
"redis==5.0.4",
|
||||
"JSON-log-formatter==1.0",
|
||||
|
||||
@@ -75,26 +75,26 @@ def test_oidc_to_renater_student_not_allowed(page: Page):
|
||||
expect(page.locator("pre")).to_contain_text('"error":"access_denied"')
|
||||
|
||||
|
||||
def agent_connect_login(page: Page, email):
|
||||
def pro_connect_login(page: Page, email):
|
||||
page.goto("https://fsa1v2.integ01.dev-agentconnect.fr/")
|
||||
page.get_by_label("Connexion à AgentConnect").click()
|
||||
page.get_by_role("button", name="S’identifier avec ProConnect").click()
|
||||
page.get_by_label("Email professionnel").fill(email)
|
||||
page.get_by_test_id("interaction-connection-button").click()
|
||||
|
||||
|
||||
def agent_connect_to_renater(
|
||||
def pro_connect_to_renater(
|
||||
page: Page,
|
||||
login="enseignant1",
|
||||
expected_email="georges.grospieds@formation.renater.fr",
|
||||
expected_given_name="Georges",
|
||||
expected_usual_name="Grospieds",
|
||||
):
|
||||
agent_connect_login(page, email=expected_email)
|
||||
pro_connect_login(page, email=expected_email)
|
||||
renater_wayf(page)
|
||||
renater_test_idp(page, login=login)
|
||||
|
||||
expect(page.locator("body")).to_contain_text(expected_email)
|
||||
text = page.inner_text("#json")
|
||||
text = page.inner_text("#userinfo")
|
||||
result = json.loads(text)
|
||||
assert {
|
||||
"uid": f"{login}@test-renater.fr",
|
||||
@@ -106,22 +106,22 @@ def agent_connect_to_renater(
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"TEST_E2E_AC" not in os.environ, reason="Depends on staging deployment"
|
||||
"TEST_E2E_PC" not in os.environ, reason="Depends on staging deployment"
|
||||
)
|
||||
def test_agent_connect_to_renater_keeps_sub(browser: Browser):
|
||||
def test_pro_connect_to_renater_keeps_sub(browser: Browser):
|
||||
with browser.new_context().new_page() as page:
|
||||
result1 = agent_connect_to_renater(page)
|
||||
result1 = pro_connect_to_renater(page)
|
||||
with browser.new_context().new_page() as page:
|
||||
result2 = agent_connect_to_renater(page)
|
||||
result2 = pro_connect_to_renater(page)
|
||||
|
||||
assert result1["sub"] == result2["sub"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"TEST_E2E_AC" not in os.environ, reason="Depends on staging deployment"
|
||||
"TEST_E2E_PC" not in os.environ, reason="Depends on staging deployment"
|
||||
)
|
||||
def test_agent_connect_to_renater_student_not_allowed(page: Page):
|
||||
agent_connect_login(page, email="jean.dupont@formation.renater.fr")
|
||||
def test_pro_connect_to_renater_student_not_allowed(page: Page):
|
||||
pro_connect_login(page, email="jean.dupont@formation.renater.fr")
|
||||
renater_wayf(page)
|
||||
renater_test_idp(page, login="etudiant1")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user