Projet

Général

Profil

0002-wip-add-franceconnect-connector.patch

Benjamin Dauvergne, 17 mai 2021 13:44

Télécharger (26,5 ko)

Voir les différences:

Subject: [PATCH 2/2] wip: add franceconnect connector

* follow the OAuth2 danse to get FranceConnect identite_pivot
* with ?mode=dgfip, also request an access_token to call DGFIP IR
  web-service
* call the IR web-service with two access tokens :
 * one from DGFIP
 * one from FC
 passerelle/apps/franceconnect/__init__.py     |   0
 passerelle/apps/franceconnect/fc.py           | 196 ++++++++++++++++
 .../franceconnect/migrations/0001_initial.py  |  83 +++++++
 .../apps/franceconnect/migrations/__init__.py |   0
 passerelle/apps/franceconnect/models.py       | 222 ++++++++++++++++++
 .../templates/franceconnect/demo.html         |  48 ++++
 .../franceconnect/resource_detail.html        |   8 +
 .../templates/franceconnect/test.html         |  38 +++
 passerelle/settings.py                        |   1 +
 9 files changed, 596 insertions(+)
 create mode 100644 passerelle/apps/franceconnect/__init__.py
 create mode 100644 passerelle/apps/franceconnect/fc.py
 create mode 100644 passerelle/apps/franceconnect/migrations/0001_initial.py
 create mode 100644 passerelle/apps/franceconnect/migrations/__init__.py
 create mode 100644 passerelle/apps/franceconnect/models.py
 create mode 100644 passerelle/apps/franceconnect/templates/franceconnect/demo.html
 create mode 100644 passerelle/apps/franceconnect/templates/franceconnect/resource_detail.html
 create mode 100644 passerelle/apps/franceconnect/templates/franceconnect/test.html
passerelle/apps/franceconnect/fc.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import base64
18
import json
19
import urllib.parse
20
import uuid
21

  
22
import requests
23
from django.utils.translation import ugettext_lazy as _
24

  
25

  
26
class FranceConnectError(Exception):
27
    def __init__(self, message, **kwargs):
28
        self.data = tuple(kwargs.items())
29
        super().__init__(message)
30

  
31

  
32
class Test:
33
    slug = 'test'
34
    name = _('Testing')
35
    authorize_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize'
36
    token_endpoint_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/token'
37
    user_info_endpoint_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo'
38
    logout_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/logout'
39

  
40

  
41
class Prod:
42
    slug = 'prod'
43
    name = _('Production')
44
    authorize_url = 'https://app.franceconnect.gouv.fr/api/v1/authorize'
45
    token_endpoint_url = 'https://app.franceconnect.gouv.fr/api/v1/token'
46
    user_info_endpoint_url = 'https://app.franceconnect.gouv.fr/api/v1/userinfo'
47
    logout_url = 'https://app.franceconnect.gouv.fr/api/v1/logout'
48

  
49

  
50
PLATFORMS = [Test, Prod]
51
PLATFORMS_BY_SLUG = {platform.slug: platform for platform in PLATFORMS}
52

  
53

  
54
def base64url_decode(input):
55
    rem = len(input) % 4
56
    if rem > 0:
57
        input += b'=' * (4 - rem)
58
    return base64.urlsafe_b64decode(input)
59

  
60

  
61
class FranceConnect:
62
    def __init__(self, session, logger):
63
        self.session = session
64
        self.logger = logger
65
        self.items = []
66
        self.correlation_id = str(uuid.uuid4())
67

  
68
    def authorization_request(self, platform, client_id, scopes, redirect_uri, acr_values='eidas1'):
69
        '''Launch an authorization request to FranceConnect'''
70
        qs = urllib.parse.urlencode(
71
            {
72
                'response_type': 'code',
73
                'client_id': client_id,
74
                'redirect_uri': redirect_uri,
75
                'scope': 'openid ' + scopes,
76
                'state': str(uuid.uuid4()),
77
                'nonce': str(uuid.uuid4()),
78
                'acr_values': acr_values,
79
            }
80
        )
81
        return '%s?%s' % (platform.authorize_url, qs)
82

  
83
    def handle_authorization_response(
84
        self, platform, client_id, client_secret, redirect_uri, code, error, error_description
85
    ):
86
        if error:
87
            raise FranceConnectError(
88
                'No authorization code', error=error, error_description=error_description
89
            )
90

  
91
        data = {
92
            'grant_type': 'authorization_code',
93
            'redirect_uri': redirect_uri,
94
            'client_id': client_id,
95
            'client_secret': client_secret,
96
            'code': code,
97
        }
98

  
99
        response_content = self.request('token endpoint', 'POST', platform.token_endpoint_url, data=data)
100

  
101
        try:
102
            self.add('fc_token_endpoint_response', response_content)
103
            self.add('fc_access_token', response_content['access_token'])
104
            self.add('fc_id_token', response_content['id_token'])
105
            header, payload, signature = self.fc_id_token.split('.')
106
            self.add('fc_id_token_payload', json.loads(base64url_decode(payload.encode())))
107
        except Exception as e:
108
            raise FranceConnectError('Error in token endpoint response', sub_exception=e)
109

  
110
        fc_user_info = self.request(
111
            'user_info endpoint',
112
            'GET',
113
            platform.user_info_endpoint_url,
114
            headers={'Authorization': 'Bearer %s' % self.fc_access_token},
115
        )
116
        self.add('fc_user_info', fc_user_info)
117

  
118
    def request_dgfip_access_token(self, dgfip_username, dgfip_password, scope=None):
119
        data = {
120
            'grant_type': 'client_credentials',
121
        }
122
        if scope:
123
            data['scope'] = scope
124
        dgfip_response = self.request(
125
            'dgfip token endpoint',
126
            'POST',
127
            'https://gwfc.impots.gouv.fr/token',
128
            data=data,
129
            auth=(dgfip_username, dgfip_password),
130
        )
131

  
132
        self.add('dgfip_token_endpoint_response', dgfip_response)
133

  
134
        try:
135
            dgfip_access_token = dgfip_response['access_token']
136
        except (TypeError, KeyError) as e:
137
            raise FranceConnectError('dgfip token endpoint error %s' % e, response=dgfip_response)
138
        self.add('dgfip_access_token', dgfip_access_token)
139

  
140
    def request_dgfip_ir(self, annrev, id_teleservice=None):
141
        headers = {
142
            'Authorization': 'Bearer %s' % self.dgfip_access_token,
143
            'X-FranceConnect-OAuth': self.fc_access_token,
144
            'X-Correlation-ID': str(uuid.uuid4()),
145
            'Accept': 'application/prs.dgfip.part.situations.ir.assiettes.v1+json',
146
        }
147
        if id_teleservice:
148
            headers['ID_Teleservice'] = id_teleservice
149

  
150
        try:
151
            dgfip_ressource_ir_response = self.request(
152
                'ressource IR endpoint',
153
                'GET',
154
                'https://gwfc.impots.gouv.fr/impotparticulier/1.0/situations/ir/assiettes/annrev/%s' % annrev,
155
                headers=headers,
156
            )
157
        except FranceConnectError as e:
158
            dgfip_ressource_ir_response = {'error_desc': str(e), 'error': e.data}
159

  
160
        # accumulate data
161
        try:
162
            data = self.dgfip_ressource_ir_response
163
        except AttributeError:
164
            data = {}
165
        data[annrev] = dgfip_ressource_ir_response
166
        self.add('dgfip_ressource_ir_response', data)
167

  
168
    def __getattr__(self, name):
169
        try:
170
            return dict(self.items)[name]
171
        except KeyError:
172
            raise AttributeError(name)
173

  
174
    def add(self, key, value):
175
        self.items.append((key, value))
176

  
177
    def request(self, label, method, url, *args, **kwargs):
178
        self.logger.debug('request %s %s args:%s kwargs:%s', label, method, args, kwargs)
179
        self.add(label.replace(' ', '_') + '_request', [method, url, args, kwargs])
180
        try:
181
            response = getattr(self.session, method.lower())(url, *args, **kwargs)
182
            try:
183
                response_content = response.json()
184
            except ValueError:
185
                response_content = response.text[:1024]
186
                response.raise_for_status()
187
                raise
188
            else:
189
                response.raise_for_status()
190
        except requests.HTTPError as e:
191
            raise FranceConnectError('%s error %s' % (label, e), response=response_content)
192
        except requests.RequestException as e:
193
            raise FranceConnectError('%s error %s' % (label, e))
194
        except ValueError as e:
195
            raise FranceConnectError('%s error %s' % (label, e), response=response_content)
196
        return response_content
passerelle/apps/franceconnect/migrations/0001_initial.py
1
# Generated by Django 2.2.19 on 2021-05-17 11:43
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    initial = True
9

  
10
    dependencies = [
11
        ('base', '0029_auto_20210202_1627'),
12
    ]
13

  
14
    operations = [
15
        migrations.CreateModel(
16
            name='Resource',
17
            fields=[
18
                (
19
                    'id',
20
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
21
                ),
22
                ('title', models.CharField(max_length=50, verbose_name='Title')),
23
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
24
                ('description', models.TextField(verbose_name='Description')),
25
                (
26
                    'fc_platform_slug',
27
                    models.CharField(
28
                        choices=[('test', 'Testing'), ('prod', 'Production')],
29
                        max_length=4,
30
                        verbose_name='FranceConnect platform',
31
                    ),
32
                ),
33
                ('fc_client_id', models.CharField(max_length=64, verbose_name='FranceConnect client_id')),
34
                (
35
                    'fc_client_secret',
36
                    models.CharField(max_length=64, verbose_name='FranceConnect client_secret'),
37
                ),
38
                (
39
                    'fc_scopes',
40
                    models.TextField(default='identite_pivot', verbose_name='FranceConnect scopes'),
41
                ),
42
                (
43
                    'fc_text_template',
44
                    models.TextField(
45
                        default="{{ given_name }} {{ family_name }} {% if gender == 'male' %}né{% else %}née{% endif %} le {{ birthdate }} à {{ birthplace }}",
46
                        verbose_name='FranceConnect text template',
47
                    ),
48
                ),
49
                (
50
                    'dgfip_username',
51
                    models.CharField(
52
                        blank=True, max_length=64, null=True, verbose_name='api.impots.gouv.fr username'
53
                    ),
54
                ),
55
                (
56
                    'dgfip_password',
57
                    models.CharField(
58
                        blank=True, max_length=64, null=True, verbose_name='api.impots.gouv.fr password'
59
                    ),
60
                ),
61
                (
62
                    'dgfip_scopes',
63
                    models.TextField(blank=True, null=True, verbose_name='api.impots.gouv.fr scopes'),
64
                ),
65
                (
66
                    'dgfip_id_teleservice',
67
                    models.TextField(blank=True, null=True, verbose_name='api.impots.gouv.fr ID_Teleservice'),
68
                ),
69
                (
70
                    'users',
71
                    models.ManyToManyField(
72
                        blank=True,
73
                        related_name='_resource_users_+',
74
                        related_query_name='+',
75
                        to='base.ApiUser',
76
                    ),
77
                ),
78
            ],
79
            options={
80
                'verbose_name': 'FranceConnect',
81
            },
82
        ),
83
    ]
passerelle/apps/franceconnect/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import uuid
18

  
19
from django.core.cache import cache
20
from django.db import models
21
from django.http import HttpResponseBadRequest, HttpResponseRedirect
22
from django.template import Context, Template
23
from django.template.response import TemplateResponse
24
from django.urls import reverse
25
from django.utils.http import urlencode
26
from django.utils.timezone import now
27
from django.utils.translation import ugettext_lazy as _
28

  
29
from passerelle.base.models import BaseResource
30
from passerelle.utils import get_trusted_services
31
from passerelle.utils.api import endpoint
32
from passerelle.utils.origin import is_same_origin
33

  
34
from . import fc
35

  
36
# from passerelle.utils.jsonresponse import APIError
37

  
38

  
39
class Resource(BaseResource):
40
    category = _('Business Process Connectors')
41

  
42
    fc_platform_slug = models.CharField(
43
        _('FranceConnect platform'),
44
        max_length=4,
45
        choices=[(platform.slug, platform.name) for platform in fc.PLATFORMS],
46
    )
47

  
48
    fc_client_id = models.CharField(_('FranceConnect client_id'), max_length=64)
49

  
50
    fc_client_secret = models.CharField(_('FranceConnect client_secret'), max_length=64)
51

  
52
    fc_scopes = models.TextField(_('FranceConnect scopes'), default='identite_pivot')
53

  
54
    fc_text_template = models.TextField(
55
        _('FranceConnect text template'),
56
        default=(
57
            '''{{ given_name }} {{ family_name }} '''
58
            '''{% if gender == 'male' %}né{% else %}née{% endif %} le {{ birthdate }} '''
59
            '''à {{ birthplace }}'''
60
        ),
61
    )
62

  
63
    dgfip_username = models.CharField(_('api.impots.gouv.fr username'), max_length=64, blank=True, null=True)
64

  
65
    dgfip_password = models.CharField(_('api.impots.gouv.fr password'), max_length=64, blank=True, null=True)
66

  
67
    dgfip_scopes = models.TextField(_('api.impots.gouv.fr scopes'), blank=True, null=True)
68

  
69
    dgfip_id_teleservice = models.TextField(_('api.impots.gouv.fr ID_Teleservice'), blank=True, null=True)
70

  
71
    log_requests_errors = False
72

  
73
    class Meta:
74
        verbose_name = _('FranceConnect')
75

  
76
    @property
77
    def fc_platform(self):
78
        return fc.PLATFORMS_BY_SLUG[self.fc_platform_slug]
79

  
80
    def build_callback_url(self, request, **kwargs):
81
        redirect_uri = request.build_absolute_uri(
82
            reverse(
83
                'generic-endpoint',
84
                kwargs={'slug': self.slug, 'connector': self.get_connector_slug(), 'endpoint': 'callback'},
85
            )
86
        )
87
        if kwargs:
88
            redirect_uri += '?' + urlencode(
89
                {key: value for key, value in kwargs.items() if value is not None}
90
            )
91
        return redirect_uri
92

  
93
    def is_trusted_origin(self, request, origin):
94
        for service in get_trusted_services():
95
            if is_same_origin(origin, service['url']):
96
                return True
97

  
98
        if is_same_origin(request.build_absolute_uri(), origin):
99
            return True
100

  
101
        return False
102

  
103
    @endpoint(
104
        description=_('Init request'),
105
        parameters={
106
            'mode': {
107
                'description': _('What to retrieve, default to FranceConnect identity, can be "dgfip"'),
108
            },
109
            'origin': {
110
                'description': _('Origin for returning results through window.postMessage'),
111
            },
112
            'test': {
113
                'description': _('If set to one, activate the test callback view.'),
114
            },
115
        },
116
    )
117
    def init_request(self, request, origin, mode=None, test=None):
118
        if not request.user.is_superuser and not self.is_trusted_origin(request, origin):
119
            return HttpResponseBadRequest('Missing or invalid origin')
120

  
121
        redirect_uri = self.build_callback_url(request, origin=origin, mode=mode, test=test)
122
        franceconnect = fc.FranceConnect(session=self.requests, logger=self.logger)
123
        return HttpResponseRedirect(
124
            franceconnect.authorization_request(
125
                platform=self.fc_platform,
126
                client_id=self.fc_client_id,
127
                scopes=self.fc_scopes,
128
                redirect_uri=redirect_uri,
129
            )
130
        )
131

  
132
    @endpoint(
133
        description=_('Test page'),
134
        parameters={
135
            'mode': {
136
                'description': _('Mode'),
137
            }
138
        },
139
    )
140
    def callback(self, request, origin, mode=None, test=None, **kwargs):
141
        if not request.user.is_superuser and not self.is_trusted_origin(request, origin):
142
            return HttpResponseBadRequest('Missing or invalid origin')
143

  
144
        franceconnect = fc.FranceConnect(session=self.requests, logger=self.logger)
145
        redirect_uri = self.build_callback_url(request, origin=origin, mode=mode, test=test)
146
        context = {
147
            'origin': origin,
148
            'franceconnect': franceconnect,
149
            'redirect_uri': redirect_uri,
150
            'test': test,
151
        }
152
        try:
153
            franceconnect.handle_authorization_response(
154
                platform=self.fc_platform,
155
                client_id=self.fc_client_id,
156
                client_secret=self.fc_client_secret,
157
                redirect_uri=redirect_uri,
158
                code=request.GET.get('code'),
159
                error=request.GET.get('error'),
160
                error_description=request.GET.get('error_description'),
161
            )
162
            token = {'franceconnect': franceconnect.fc_user_info}
163
            if mode == 'dgfip':
164
                franceconnect.request_dgfip_access_token(
165
                    self.dgfip_username, self.dgfip_password, scope=self.dgfip_scopes
166
                )
167
                current_year = now().year
168
                for year in range(current_year - 3, current_year):
169
                    franceconnect.request_dgfip_ir(str(year), id_teleservice=self.dgfip_id_teleservice)
170
                token['dgfip_ir'] = franceconnect.dgfip_ressource_ir_response
171
            context['data'] = {'id': self.store(token)}
172
            try:
173
                template = Template(self.fc_text_template)
174
                context['data']['text'] = template.render(Context(franceconnect.fc_user_info))
175
            except Exception:
176
                pass
177
        except fc.FranceConnectError as e:
178
            context['error'] = e
179
        return TemplateResponse(request, 'franceconnect/test.html', context=context)
180

  
181
    @endpoint(
182
        description=_('Demo page'),
183
    )
184
    def demo(self, request, **kwargs):
185
        return TemplateResponse(
186
            request, 'franceconnect/demo.html', context={'origin': request.build_absolute_uri('/')}
187
        )
188

  
189
    @endpoint(
190
        description=_('Data source'),
191
    )
192
    def data_source(self, request, id=None, **kwargs):
193
        if id:
194
            return {
195
                'data': dict(self.retrieve(id), id=id),
196
            }
197
        return {
198
            'data': [
199
                {
200
                    'id': '',
201
                    'text': '',
202
                    'init_request_url': request.build_absolute_uri(
203
                        reverse(
204
                            'generic-endpoint',
205
                            kwargs={
206
                                'slug': self.slug,
207
                                'connector': self.get_connector_slug(),
208
                                'endpoint': 'init_request',
209
                            },
210
                        )
211
                    ),
212
                }
213
            ]
214
        }
215

  
216
    def store(self, data):
217
        ref = str(uuid.uuid4().hex)
218
        cache.set(ref, data)
219
        return ref
220

  
221
    def retrieve(self, ref):
222
        return cache.get(ref)
passerelle/apps/franceconnect/templates/franceconnect/demo.html
1
{% extends "passerelle/manage.html" %}
2

  
3
{% block content %}
4
<button id="start">Start</button>
5
        <script>
6
            var popup = null;
7
const popupCenter = function(url, title, w, h) {
8
    // Fixes dual-screen position                             Most browsers      Firefox
9
    const dualScreenLeft = window.screenLeft !==  undefined ? window.screenLeft : window.screenX;
10
    const dualScreenTop = window.screenTop !==  undefined   ? window.screenTop  : window.screenY;
11

  
12
    const width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width;
13
    const height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height;
14

  
15
    const systemZoom = width / window.screen.availWidth;
16
    const left = (width - w) / 2 / systemZoom + dualScreenLeft
17
    const top = (height - h) / 2 / systemZoom + dualScreenTop
18
    popup = window.open(url, title,
19
      `
20
      scrollbars=yes,
21
      width=${w / systemZoom},
22
      height=${h / systemZoom},
23
      top=${top},
24
      left=${left}
25
      `
26
    )
27

  
28
    if (window.focus) popup.focus();
29
};
30

  
31
            $('#start').on('click', function () {
32
                $('#user-info').hide();
33
                if (popup) { popup.close(); popup = null; }
34
                popupCenter('init_request?mode=dgfip&test=1&origin={{ origin }}', 'FranceConnect', 1000, 670);
35
            });
36
            $(window).on('message', function(event) {
37
                var data = event.originalEvent.data;
38
                $.getJSON("data_source?id=" + data.id, function(result) {
39
                    $('#user-info').show();
40
                    $('#user-info-preview').text("ref: " + data.id+ "\n" + JSON.stringify(result, null, 2));
41
                });
42
                popup.close();
43
            });
44
        </script>
45
        <div id="user-info" style="display: none">User-Info
46
            <pre id="user-info-preview"></pre>
47
        </div>
48
{% endblock %}
passerelle/apps/franceconnect/templates/franceconnect/resource_detail.html
1
{% extends "passerelle/manage/service_view.html" %}
2
{% load i18n passerelle %}
3

  
4
{% block description %}
5
{{ block.super }}
6
{% url "generic-endpoint" connector="franceconnect" slug=object.slug endpoint="callback"  as callback_url %}
7
<p>URL de callback pour FranceConnect: <a href="{{ request.scheme }}://{{ request.get_host }}{{ callback_url }}">{{ request.scheme }}://{{ request.get_host }}{{ callback_url }}</a></p>
8
{% endblock %}
passerelle/apps/franceconnect/templates/franceconnect/test.html
1
<html>
2
    <head>
3
    </head>
4
    <body>
5
        <p><a href="?">Retry</a></p>
6
        <button id="continue">Continue</button>
7
        {{ data|json_script:"data" }}
8
        <p>redirect_uri: <pre>{{ redirect_uri|pprint }}</pre></p>
9
        <p>correlation_id: <pre>{{ franceconnect.correlation_id }}</pre></p>
10
        {% if error %}
11
            <p>{{ error }}<p>
12
            {% if error.data %}
13
                <dl>
14
                    {% for key, value in error.data %}
15
                        <dt>{{ key }}</td>
16
                        <dd><pre>{{ value|pprint }}</pre></dd>
17
                    {% endfor %}
18
                </dl>
19
            {% endif %}
20
        {% endif %}
21
        <ul>
22
        {% for key, value in franceconnect.items reversed %}
23
            <li>{{ key }}&nbsp;: <pre>{{ value|pprint }}</pre></li>
24
        {% endfor %}
25
        <script>
26
            (function () {
27
                const data = JSON.parse(document.getElementById('data').textContent);
28
                const continue_button = document.getElementById('continue');
29
                const post_message = function () {
30
                    window.opener.postMessage(data, "{{ origin }}");
31
                }
32
                {% if test %}continue_button.addEventListener('click', function () { post_message() });
33
                {% else %}post_message(){% endif %}
34
            })();
35
        </script>
36
    </body>
37
</html>
38

  
passerelle/settings.py
141 141
    'passerelle.apps.esirius',
142 142
    'passerelle.apps.family',
143 143
    'passerelle.apps.feeds',
144
    'passerelle.apps.franceconnect',
144 145
    'passerelle.apps.gdc',
145 146
    'passerelle.apps.gesbac',
146 147
    'passerelle.apps.jsondatastore',
147
-