def test_post_success(self):
        self.maxDiff = None
        request = LightRequest(**LIGHT_REQUEST_DICT)
        self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8')

        token, encoded = self.get_token()
        response = self.client.post(self.url, {'test_token': encoded})

        # Context
        self.assertIn('saml_request', response.context)
        self.assertEqual(response.context['identity_provider_endpoint'],
                         'https://test.example.net/identity-provider-endpoint')
        self.assertEqual(response.context['relay_state'], 'relay123')
        self.assertEqual(response.context['error'], None)

        # SAML Request
        saml_request_xml = b64decode(response.context['saml_request'].encode('utf-8')).decode('utf-8')
        self.assertIn(request.id, saml_request_xml)  # light_request.id preserved
        self.assertIn('<saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">'
                      'https://test.example.net/saml/idp.xml</saml2:Issuer>', saml_request_xml)
        self.assertIn('Destination="http://testserver/IdentityProviderResponse"', saml_request_xml)
        self.assertIn('<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>', saml_request_xml)

        # Rendering
        self.assertContains(response, 'Redirect to Identity Provider is in progress')
        self.assertContains(response,
                            '<form class="auto-submit" action="https://test.example.net/identity-provider-endpoint"')
        self.assertContains(response, '<input type="hidden" name="SAMLRequest" value="{}"'.format(
            response.context['saml_request']))
        self.assertContains(response, '<input type="hidden" name="RelayState" value="relay123"/>')
        self.assertNotIn(b'An error occurred', response.content)
예제 #2
0
 def test_load_xml_attributes_unexpected_element(self):
     data = parse_xml(b'<lightRequest><requestedAttributes><myField>data</myField>'
                      b'</requestedAttributes></lightRequest>')
     with self.assertRaisesMessage(ValidationError,
                                   '\'<lightRequest><requestedAttributes><myField>\': '
                                   '"Unexpected element \'myField\'"'):
         LightRequest.load_xml(data)
예제 #3
0
 def test_load_xml_attribute_values_unexpected_element(self):
     data = parse_xml(b'<lightRequest><requestedAttributes><attribute><definition>data</definition><foo/>'
                      b'</attribute></requestedAttributes></lightRequest>')
     with self.assertRaisesMessage(ValidationError,
                                   '\'<lightRequest><requestedAttributes><attribute><foo>\': '
                                   '"Unexpected element \'foo\'"'):
         LightRequest.load_xml(data)
예제 #4
0
 def test_export_xml_minimal_sample(self):
     request = LightRequest(
         citizen_country_code='CA', id='test-light-request-id',
         level_of_assurance=LevelOfAssurance.LOW, requested_attributes={})
     with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f:
         data = f.read()
     self.assertEqual(dump_xml(request.export_xml()), data)
예제 #5
0
    def post(self, request: HttpRequest) -> HttpResponse:
        """Handle a HTTP POST request."""
        try:
            preset = PRESETS[int(request.POST.get('Request', ''))]
        except (ValueError, KeyError):
            return HttpResponseBadRequest()

        light_request = LightRequest(
            id=create_xml_uuid(),
            issuer=CONNECTOR_SETTINGS.service_provider['request_issuer'],
            level_of_assurance=LevelOfAssurance.LOW,
            provider_name="Demo Service Provider",
            sp_type=ServiceProviderType.PUBLIC,
            relay_state=request.POST.get('RelayState') or None,
            origin_country_code='EU',
            citizen_country_code=request.POST.get('Country'),
            name_id_format=preset.id_format,
            requested_attributes={name: [] for name in preset.attributes}
        )
        if not light_request.citizen_country_code:
            # Use a placeholder to get through light request validation.
            light_request.citizen_country_code = COUNTRY_PLACEHOLDER
        self.saml_request = SAMLRequest.from_light_request(light_request, '/dest', datetime.utcnow())
        signature_options = CONNECTOR_SETTINGS.service_provider['response_signature']
        if signature_options and signature_options.get('key_file') and signature_options.get('cert_file'):
            self.saml_request.sign_request(**signature_options)
        return self.get(request)
예제 #6
0
 def test_load_xml_attributes_definition_element(self):
     data = parse_xml(b'<lightRequest><requestedAttributes><attribute>data</attribute>'
                      b'</requestedAttributes></lightRequest>')
     with self.assertRaisesMessage(ValidationError,
                                   "'<lightRequest><requestedAttributes><attribute>': "
                                   "'Missing attribute.definition element.'"):
         LightRequest.load_xml(data)
     data = parse_xml(b'<lightRequest><requestedAttributes><attribute><foo>data</foo>'
                      b'</attribute></requestedAttributes></lightRequest>')
     with self.assertRaisesMessage(ValidationError,
                                   '\'<lightRequest><requestedAttributes><attribute><foo>\': '
                                   '"Unexpected element \'foo\'"'):
         LightRequest.load_xml(data)
 def test_create_light_request_without_extensions(self):
     root = Element(Q_NAMES['saml2p:AuthnRequest'], nsmap=EIDAS_NAMESPACES)
     saml_request = SAMLRequest(ElementTree(root), 'CZ', 'relay123')
     expected = LightRequest(citizen_country_code='CZ',
                             relay_state='relay123',
                             requested_attributes=OrderedDict())
     self.assertEqual(saml_request.create_light_request(), expected)
예제 #8
0
    def test_export_xml_full_sample(self):
        self.maxDiff = None

        with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f:
            data = f.read()
            request = LightRequest.load_xml(parse_xml(data))
        self.assertEqual(dump_xml(request.export_xml()), data)
    def test_get_light_request_success(self):
        orig_light_request = LightRequest(**LIGHT_REQUEST_DICT)
        self.cache_mock.get_and_remove.return_value = dump_xml(orig_light_request.export_xml()).decode('utf-8')
        token, encoded = self.get_token()

        view = ProxyServiceRequestView()
        view.request = self.factory.post(self.url, {'test_token': encoded})
        view.light_token = token
        view.storage = IgniteStorage('test.example.net', 1234, 'test-proxy-service-request-cache', '')

        light_request = view.get_light_request()
        self.assertEqual(light_request, orig_light_request)
        self.maxDiff = None
        self.assertEqual(self.client_mock.mock_calls,
                         [call.connect('test.example.net', 1234),
                          call.get_cache('test-proxy-service-request-cache'),
                          call.get_cache().get_and_remove('request-token-id')])
예제 #10
0
    def create_light_request(self) -> LightRequest:
        """
        Convert SAML Request to Light Request.

        :return: A Light Request.
        :raise ValidationError: If the SAML Request cannot be parsed correctly.
        """
        request = LightRequest(requested_attributes=OrderedDict(),
                               citizen_country_code=self.citizen_country_code,
                               relay_state=self.relay_state)
        root = self.document.getroot()
        if root.tag != Q_NAMES['saml2p:AuthnRequest']:
            raise ValidationError({get_element_path(root): 'Wrong root element: {!r}'.format(root.tag)})

        request.id = root.attrib.get('ID')
        request.provider_name = root.attrib.get('ProviderName')

        request.issuer = self.issuer

        name_id_format = root.find('./{}'.format(Q_NAMES['saml2p:NameIDPolicy']))
        if name_id_format is not None:
            request.name_id_format = NameIdFormat(name_id_format.attrib.get('Format'))

        level_of_assurance = root.find(
            './{}/{}'.format(Q_NAMES['saml2p:RequestedAuthnContext'], Q_NAMES['saml2:AuthnContextClassRef']))
        if level_of_assurance is not None:
            request.level_of_assurance = LevelOfAssurance(level_of_assurance.text)

        extensions = root.find('./{}'.format(Q_NAMES['saml2p:Extensions']))
        if extensions is not None:
            sp_type = extensions.find('./{}'.format(Q_NAMES['eidas:SPType']))
            if sp_type is not None:
                request.sp_type = ServiceProviderType(sp_type.text)

            sp_country = extensions.find('./{}'.format(Q_NAMES['eidas:SPCountry']))
            if sp_country is not None:
                request.origin_country_code = sp_country.text

            requested_attributes = request.requested_attributes
            attributes = extensions.findall(
                './{}/{}'.format(Q_NAMES['eidas:RequestedAttributes'], Q_NAMES['eidas:RequestedAttribute']))
            for attribute in attributes:
                name = attribute.attrib.get('Name')
                if not name:
                    raise ValidationError({
                        get_element_path(attribute): "Missing attribute 'Name'"})
                values = requested_attributes[name] = []
                for value in attribute.findall('./{}'.format(Q_NAMES['eidas:AttributeValue'])):
                    values.append(value.text)

        return request
예제 #11
0
    def test_from_light_request(self):
        self.maxDiff = None
        saml_request = SAMLRequest.from_light_request(
            LightRequest(**LIGHT_REQUEST_DICT), 'test/destination',
            datetime(2017, 12, 11, 14, 12, 5, 148000))

        with cast(TextIO, (DATA_DIR / 'saml_request.xml').open('r')) as f2:
            data = f2.read()
        self.assertXMLEqual(
            dump_xml(saml_request.document).decode('utf-8'), data)
        self.assertEqual(saml_request.relay_state, 'relay123')
        self.assertEqual(saml_request.citizen_country_code, 'CA')
    def test_post_remember_country_codes(self):
        request = LightRequest(**LIGHT_REQUEST_DICT)
        self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8')

        token, encoded = self.get_token()
        response = self.client.post(self.url, {'test_token': encoded})
        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            self.client_mock.mock_calls,
            [
                call.connect('test.example.net', 1234),
                call.get_cache('test-proxy-service-request-cache'),
                call.get_cache().get_and_remove('request-token-id'),
                call.connect('test.example.net', 1234),
                call.get_cache('aux-cache'),
                call.get_cache().put(
                    'aux-test-light-request-id',
                    '{"citizen_country": "CA", "origin_country": "CA"}'
                ),
            ]
        )
    def test_post_remember_name_id_format(self):
        request = LightRequest(**LIGHT_REQUEST_DICT)
        self.cache_mock.get_and_remove.return_value = dump_xml(request.export_xml()).decode('utf-8')

        token, encoded = self.get_token()
        response = self.client.post(self.url, {'test_token': encoded})
        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            self.client_mock.mock_calls,
            [
                call.connect('test.example.net', 1234),
                call.get_cache('test-proxy-service-request-cache'),
                call.get_cache().get_and_remove('request-token-id'),
                call.connect('test.example.net', 1234),
                call.get_cache('aux-cache'),
                call.get_cache().put(
                    'aux-test-light-request-id',
                    '{"name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"}'
                ),
            ]
        )
예제 #14
0
    def test_put_light_request(self):
        with cast(TextIO, (DATA_DIR / 'light_request.xml').open('r')) as f:
            data = f.read()

        request = LightRequest.load_xml(parse_xml(data))
        self.storage.put_light_request('abc', request)
        self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=33)])
        self.assertEqual(self.client_mock.mock_calls, [
            call.connect(self.HOST, self.PORT),
            call.get_cache(self.REQUEST_CACHE_NAME),
            call.get_cache().put('abc', data)
        ])
예제 #15
0
    def test_from_light_request_invalid_id(self):
        self.maxDiff = None

        with cast(BinaryIO,
                  (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f:
            request = LightRequest.load_xml(parse_xml(f))
        request.id = '0day'

        with self.assert_validation_error(
                'id', "Light request id is not a valid XML id: '0day'"):
            SAMLRequest.from_light_request(
                request, 'test/destination',
                datetime(2017, 12, 11, 14, 12, 5, 148000))
예제 #16
0
    def test_pop_light_request_found(self):
        with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f:
            data = f.read()

        self.cache_mock.get_and_remove.return_value = data.decode('utf-8')
        self.assertEqual(LightRequest.load_xml(parse_xml(data)),
                         self.storage.pop_light_request('abc'))
        self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=33)])
        self.assertEqual(self.client_mock.mock_calls, [
            call.connect(self.HOST, self.PORT),
            call.get_cache(self.REQUEST_CACHE_NAME),
            call.get_cache().get_and_remove('abc')
        ])
    def test_create_light_token(self, uuid_mock: MagicMock):
        view = ServiceProviderRequestView()
        light_request_data = LIGHT_REQUEST_DICT.copy()
        view.light_request = LightRequest(**light_request_data)

        token, encoded_token = view.create_light_token('test-token-issuer',
                                                       'sha256', 'test-secret')
        self.assertEqual(token.id, 'T0uuid4')
        self.assertEqual(token.issuer, 'test-token-issuer')
        self.assertEqual(token.created, datetime(2017, 12, 11, 16, 12, 5))
        self.assertEqual(
            token.encode('sha256', 'test-secret').decode('ascii'),
            encoded_token)
        self.assertEqual(uuid_mock.mock_calls, [call()])
    def test_post_remember_country_codes(self, uuid_mock):
        self.maxDiff = None
        saml_request_xml, saml_request_encoded = self.load_saml_request(
            signed=True)
        light_request = LightRequest(**LIGHT_REQUEST_DICT)
        light_request.issuer = 'https://example.net/EidasNode/ConnectorMetadata'
        self.cache_mock.get_and_remove.return_value = dump_xml(
            light_request.export_xml()).decode('utf-8')

        response = self.client.post(
            self.url, {
                'SAMLRequest': saml_request_encoded,
                'RelayState': 'relay123',
                'country_param': 'ca'
            })
        self.assertEqual(response.status_code, 200)
        self.assertSequenceEqual(self.client_mock.mock_calls[-3:], [
            call.connect('test.example.net', 1234),
            call.get_cache('aux-cache'),
            call.get_cache().put(
                'aux-test-saml-request-id',
                '{"citizen_country": "CA", "origin_country": "CA"}'),
        ])
예제 #19
0
    def test_load_xml_minimal_sample(self):
        self.maxDiff = None

        with cast(BinaryIO, (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f:
            request = LightRequest.load_xml(parse_xml(f))

        self.assertEqual(request.citizen_country_code, 'CA')
        self.assertEqual(request.id, 'test-light-request-id')
        self.assertIsNone(request.issuer)
        self.assertEqual(request.level_of_assurance, LevelOfAssurance.LOW)
        self.assertIsNone(request.name_id_format)
        self.assertIsNone(request.provider_name)
        self.assertIsNone(request.sp_type)
        self.assertIsNone(request.relay_state)
        self.assertEqual(request.requested_attributes, {})
    def test_create_saml_request_signed(self):
        light_request = LightRequest(**LIGHT_REQUEST_DICT)
        token, encoded = self.get_token()

        view = ProxyServiceRequestView()
        view.request = self.factory.post(self.url, {'test_token': encoded})
        view.light_token = token
        view.light_request = light_request

        saml_request = view.create_saml_request('https://test.example.net/saml/idp.xml', SIGNATURE_OPTIONS)
        root = saml_request.document.getroot()
        self.assertEqual(root.get('ID'), 'test-light-request-id')
        self.assertEqual(root.get('IssueInstant'), '2017-12-11T14:12:05.000Z')
        self.assertEqual(root.find(".//{}".format(Q_NAMES['saml2:Issuer'])).text,
                         'https://test.example.net/saml/idp.xml')
        self.assertIsNotNone(root.find('./{}'.format(Q_NAMES['ds:Signature'])))
예제 #21
0
    def test_from_light_request_minimal(self):
        self.maxDiff = None

        with cast(BinaryIO,
                  (DATA_DIR / 'light_request_minimal.xml').open('rb')) as f:
            request = LightRequest.load_xml(parse_xml(f))
        request.id = 'test-saml-request-id'

        saml_request = SAMLRequest.from_light_request(
            request, 'test/destination',
            datetime(2017, 12, 11, 14, 12, 5, 148000))

        with cast(TextIO,
                  (DATA_DIR / 'saml_request_minimal.xml').open('r')) as f2:
            data = f2.read()
        self.assertXMLEqual(
            dump_xml(saml_request.document).decode('utf-8'), data)
        self.assertEqual(saml_request.relay_state, None)
        self.assertEqual(saml_request.citizen_country_code, 'CA')
예제 #22
0
 def put_light_request(self, uid: str, request: LightRequest) -> None:
     """Store a LightRequest under a unique id."""
     data = dump_xml(request.export_xml()).decode('utf-8')
     LOGGER.debug('Store Light Request to cache: id=%r, data=%s', uid, data)
     self.get_cache(self.request_cache_name).put(uid, data)
예제 #23
0
 def pop_light_request(self, uid: str) -> Optional[LightRequest]:
     """Look up a LightRequest by a unique id and then remove it."""
     data = self.get_cache(self.request_cache_name).get_and_remove(uid)
     LOGGER.debug('Got Light Request from cache: id=%r, data=%s', uid, data)
     return LightRequest().load_xml(
         parse_xml(data)) if data is not None else None
    def test_post_success(self, uuid_mock: MagicMock):
        self.maxDiff = None
        saml_request_xml, saml_request_encoded = self.load_saml_request(
            signed=True)
        light_request = LightRequest(**LIGHT_REQUEST_DICT)
        light_request.issuer = 'https://example.net/EidasNode/ConnectorMetadata'
        self.cache_mock.get_and_remove.return_value = dump_xml(
            light_request.export_xml()).decode('utf-8')

        response = self.client.post(
            self.url, {
                'SAMLRequest': saml_request_encoded,
                'RelayState': 'relay123',
                'country_param': 'ca'
            })

        # Context
        self.assertIn('token', response.context)
        self.assertEqual(response.context['token_parameter'],
                         'test_request_token')
        self.assertEqual(response.context['eidas_url'],
                         'http://test.example.net/SpecificConnectorRequest')
        self.assertEqual(response.context['error'], None)

        # Token
        encoded_token = response.context['token']
        token = LightToken.decode(encoded_token, 'sha256',
                                  'request-token-secret')
        self.assertEqual(token.id, 'T0uuid4')
        self.assertEqual(token.issuer, 'request-token-issuer')
        self.assertEqual(token.created, datetime(2017, 12, 11, 14, 12, 5))

        # Storing light request
        light_request_data = LIGHT_REQUEST_DICT.copy()
        light_request_data.update({
            'id': 'test-saml-request-id',
            'issuer': 'test-connector-request-issuer',
        })
        light_request = LightRequest(**light_request_data)
        light_request.requested_attributes = light_request.requested_attributes.copy(
        )
        del light_request.requested_attributes[
            'http://eidas.europa.eu/attributes/naturalperson/AdditionalAttribute']
        del light_request.requested_attributes[
            'http://eidas.europa.eu/attributes/legalperson/LegalAdditionalAttribute']
        self.assertEqual(self.client_class_mock.mock_calls, [call(timeout=66)])
        self.assertEqual(self.client_mock.mock_calls, [
            call.connect('test.example.net', 1234),
            call.get_cache('test-connector-request-cache'),
            call.get_cache().put(
                'T0uuid4',
                dump_xml(light_request.export_xml()).decode('utf-8'))
        ])

        # Rendering
        self.assertContains(response, 'Redirect to eIDAS Node is in progress')
        self.assertContains(response, 'eidas_node/connector/formautosubmit.js')
        self.assertContains(
            response, '<form class="auto-submit" '
            'action="http://test.example.net/SpecificConnectorRequest"')
        self.assertContains(
            response,
            '<input type="hidden" name="test_request_token" value="{}"'.format(
                encoded_token))
        self.assertNotIn(b'An error occurred', response.content)
예제 #25
0
 def test_load_xml_unknown_element(self):
     data = parse_xml(b'<lightRequest><myField>data</myField></lightRequest>')
     with self.assertRaisesMessage(ValidationError, '\'<lightRequest><myField>\': "Unknown element \'myField\'."'):
         LightRequest.load_xml(data)
예제 #26
0
 def test_load_xml_wrong_root_element(self):
     data = parse_xml(b'<lightResponse></lightResponse>')
     with self.assertRaisesMessage(ValidationError,
                                   '\'<lightResponse>\': "Invalid root element \'lightResponse\'."'):
         LightRequest.load_xml(data)
예제 #27
0
 def test_load_xml_full_sample(self):
     with cast(BinaryIO, (DATA_DIR / 'light_request.xml').open('rb')) as f:
         request = LightRequest.load_xml(parse_xml(f))
     self.assertEqual(request, LightRequest(**LIGHT_REQUEST_DICT))
예제 #28
0
    def from_light_request(cls: Type[SAMLRequestType],
                           light_request: LightRequest, destination: str,
                           issued: datetime) -> SAMLRequestType:
        """
        Convert Light Request to SAML Request.

        :param light_request: The light request to convert.
        :param destination: A URI reference indicating the address to which this request has been sent.
        :param issued: The UTC time instant of issue of the request.
        :return: A SAML Request.
        """
        light_request.validate()
        if not is_xml_id_valid(light_request.id):
            raise ValidationError({
                'id':
                'Light request id is not a valid XML id: {!r}'.format(
                    light_request.id)
            })

        root_attributes = OrderedDict([
            ('Consent', 'urn:oasis:names:tc:SAML:2.0:consent:unspecified'
             ),  # optional, default 'unspecified'
            ('Destination', destination),
            ('ID', light_request.id),
            ('IssueInstant',
             datetime_iso_format_milliseconds(issued) + 'Z'),  # UTC
            ('Version', '2.0'),
            ('IsPassive', 'false'),  # optional, default false
            ('ForceAuthn', 'true'),  # optional, default false
        ])
        if light_request.provider_name is not None:
            root_attributes['ProviderName'] = light_request.provider_name
        root = etree.Element(Q_NAMES['saml2p:AuthnRequest'],
                             attrib=root_attributes,
                             nsmap=EIDAS_NAMESPACES)

        # 1. RequestAbstractType <saml2:Issuer>:
        if light_request.issuer is not None:
            SubElement(root, Q_NAMES['saml2:Issuer'], {
                'Format':
                'urn:oasis:names:tc:SAML:2.0:nameid-format:entity'
            }).text = light_request.issuer

        # 2. RequestAbstractType <ds:Signature> skipped
        # 3. RequestAbstractType <saml2p:Extensions>:
        extensions = SubElement(root, Q_NAMES['saml2p:Extensions'])
        if light_request.sp_type:
            SubElement(
                extensions,
                Q_NAMES['eidas:SPType']).text = light_request.sp_type.value
        # In patched version 2.3.1 of CEF eIDAS Node (according EID-922), origin_country_code is used
        if light_request.sp_country_code:
            SubElement(extensions, Q_NAMES['eidas:SPCountry']
                       ).text = light_request.sp_country_code
        # In the version 2.4 of CEF eIDAS Node, citizen_country_code is used
        else:
            assert light_request.citizen_country_code  # mandatory field
            SubElement(extensions, Q_NAMES['eidas:SPCountry']
                       ).text = light_request.citizen_country_code
        attributes = SubElement(extensions,
                                Q_NAMES['eidas:RequestedAttributes'])
        for name, values in light_request.requested_attributes.items():
            attribute = SubElement(attributes,
                                   Q_NAMES['eidas:RequestedAttribute'],
                                   create_attribute_elm_attributes(name, True))
            for value in values:
                SubElement(attribute,
                           Q_NAMES['eidas:AttributeValue']).text = value

        # 4. AuthnRequestType <saml2:Subject> skipped
        # 5. AuthnRequestType <saml2p:NameIDPolicy>:
        if light_request.name_id_format:
            SubElement(
                root,
                Q_NAMES['saml2p:NameIDPolicy'],
                {
                    'AllowCreate': 'true',  # optional, default false
                    'Format': light_request.name_id_format.value
                })
        # 6. AuthnRequestType <saml2:Conditions> skipped
        # 7. AuthnRequestType <saml2p:RequestedAuthnContext>:
        SubElement(
            SubElement(root, Q_NAMES['saml2p:RequestedAuthnContext'],
                       {'Comparison': 'minimum'}),
            Q_NAMES['saml2:AuthnContextClassRef']
        ).text = light_request.level_of_assurance.value
        # 8: AuthnRequestType <saml2p:Scoping> skipped
        return cls(ElementTree(root), light_request.citizen_country_code,
                   light_request.relay_state)