Skip to content

Commit a6e1f09

Browse files
Merge pull request #427 from melanger/patch-6
reuse the generic OpenID-Connect backend for the Apple backend
2 parents 193665d + 0d6615a commit a6e1f09

File tree

3 files changed

+70
-207
lines changed

3 files changed

+70
-207
lines changed

src/satosa/attribute_mapping.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from collections import defaultdict
33
from itertools import chain
4+
from typing import Mapping
45

56
from mako.template import Template
67

@@ -97,8 +98,9 @@ def to_internal(self, attribute_profile, external_dict):
9798
continue
9899

99100
external_attribute_name = mapping[attribute_profile]
100-
attribute_values = self._collate_attribute_values_by_priority_order(external_attribute_name,
101-
external_dict)
101+
attribute_values = self._collate_attribute_values_by_priority_order(
102+
external_attribute_name, external_dict
103+
)
102104
if attribute_values: # Only insert key if it has some values
103105
logline = "backend attribute {external} mapped to {internal} ({value})".format(
104106
external=external_attribute_name, internal=internal_attribute_name, value=attribute_values
@@ -157,6 +159,8 @@ def _get_nested_attribute_value(self, nested_key, data):
157159

158160
d = data
159161
for key in keys:
162+
if not isinstance(d, Mapping):
163+
return None
160164
d = d.get(key)
161165
if d is None:
162166
return None

src/satosa/backends/apple.py

Lines changed: 14 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -2,135 +2,22 @@
22
Apple backend module.
33
"""
44
import logging
5-
from datetime import datetime
6-
from urllib.parse import urlparse
7-
5+
from .openid_connect import OpenIDConnectBackend, STATE_KEY
86
from oic.oauth2.message import Message
9-
from oic import oic
10-
from oic import rndstr
117
from oic.oic.message import AuthorizationResponse
12-
from oic.oic.message import ProviderConfigurationResponse
13-
from oic.oic.message import RegistrationRequest
14-
from oic.utils.authn.authn_context import UNSPECIFIED
15-
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
16-
178
import satosa.logging_util as lu
18-
from satosa.internal import AuthenticationInformation
19-
from satosa.internal import InternalData
20-
from .base import BackendModule
21-
from .oauth import get_metadata_desc_for_oauth_backend
22-
from ..exception import SATOSAAuthenticationError, SATOSAError
23-
from ..response import Redirect
24-
9+
from ..exception import SATOSAAuthenticationError
2510
import json
2611
import requests
2712

2813

2914
logger = logging.getLogger(__name__)
3015

31-
NONCE_KEY = "oidc_nonce"
32-
STATE_KEY = "oidc_state"
3316

3417
# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
35-
class AppleBackend(BackendModule):
18+
class AppleBackend(OpenIDConnectBackend):
3619
"""Sign in with Apple backend"""
3720

38-
def __init__(self, auth_callback_func, internal_attributes, config, base_url, name):
39-
"""
40-
Sign in with Apple backend module.
41-
:param auth_callback_func: Callback should be called by the module after the authorization
42-
in the backend is done.
43-
:param internal_attributes: Mapping dictionary between SATOSA internal attribute names and
44-
the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and
45-
RP's expects namevice.
46-
:param config: Configuration parameters for the module.
47-
:param base_url: base url of the service
48-
:param name: name of the plugin
49-
50-
:type auth_callback_func:
51-
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
52-
:type internal_attributes: dict[string, dict[str, str | list[str]]]
53-
:type config: dict[str, dict[str, str] | list[str]]
54-
:type base_url: str
55-
:type name: str
56-
"""
57-
super().__init__(auth_callback_func, internal_attributes, base_url, name)
58-
self.auth_callback_func = auth_callback_func
59-
self.config = config
60-
self.client = _create_client(
61-
config["provider_metadata"],
62-
config["client"]["client_metadata"],
63-
config["client"].get("verify_ssl", True),
64-
)
65-
if "scope" not in config["client"]["auth_req_params"]:
66-
config["auth_req_params"]["scope"] = "openid"
67-
if "response_type" not in config["client"]["auth_req_params"]:
68-
config["auth_req_params"]["response_type"] = "code"
69-
70-
def start_auth(self, context, request_info):
71-
"""
72-
See super class method satosa.backends.base#start_auth
73-
:type context: satosa.context.Context
74-
:type request_info: satosa.internal.InternalData
75-
"""
76-
oidc_nonce = rndstr()
77-
oidc_state = rndstr()
78-
state_data = {NONCE_KEY: oidc_nonce, STATE_KEY: oidc_state}
79-
context.state[self.name] = state_data
80-
81-
args = {
82-
"scope": self.config["client"]["auth_req_params"]["scope"],
83-
"response_type": self.config["client"]["auth_req_params"]["response_type"],
84-
"client_id": self.client.client_id,
85-
"redirect_uri": self.client.registration_response["redirect_uris"][0],
86-
"state": oidc_state,
87-
"nonce": oidc_nonce,
88-
}
89-
args.update(self.config["client"]["auth_req_params"])
90-
auth_req = self.client.construct_AuthorizationRequest(request_args=args)
91-
login_url = auth_req.request(self.client.authorization_endpoint)
92-
return Redirect(login_url)
93-
94-
def register_endpoints(self):
95-
"""
96-
Creates a list of all the endpoints this backend module needs to listen to. In this case
97-
it's the authentication response from the underlying OP that is redirected from the OP to
98-
the proxy.
99-
:rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]]
100-
:return: A list that can be used to map the request to SATOSA to this endpoint.
101-
"""
102-
url_map = []
103-
redirect_path = urlparse(
104-
self.config["client"]["client_metadata"]["redirect_uris"][0]
105-
).path
106-
if not redirect_path:
107-
raise SATOSAError("Missing path in redirect uri")
108-
109-
url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint))
110-
return url_map
111-
112-
def _verify_nonce(self, nonce, context):
113-
"""
114-
Verify the received OIDC 'nonce' from the ID Token.
115-
:param nonce: OIDC nonce
116-
:type nonce: str
117-
:param context: current request context
118-
:type context: satosa.context.Context
119-
:raise SATOSAAuthenticationError: if the nonce is incorrect
120-
"""
121-
backend_state = context.state[self.name]
122-
if nonce != backend_state[NONCE_KEY]:
123-
msg = "Missing or invalid nonce in authn response for state: {}".format(
124-
backend_state
125-
)
126-
logline = lu.LOG_FMT.format(
127-
id=lu.get_session_id(context.state), message=msg
128-
)
129-
logger.debug(logline)
130-
raise SATOSAAuthenticationError(
131-
context.state, "Missing or invalid nonce in authn response"
132-
)
133-
13421
def _get_tokens(self, authn_response, context):
13522
"""
13623
:param authn_response: authentication response from OP
@@ -169,25 +56,6 @@ def _get_tokens(self, authn_response, context):
16956

17057
return authn_response.get("access_token"), authn_response.get("id_token")
17158

172-
def _check_error_response(self, response, context):
173-
"""
174-
Check if the response is an OAuth error response.
175-
:param response: the OIDC response
176-
:type response: oic.oic.message
177-
:raise SATOSAAuthenticationError: if the response is an OAuth error response
178-
"""
179-
if "error" in response:
180-
msg = "{name} error: {error} {description}".format(
181-
name=type(response).__name__,
182-
error=response["error"],
183-
description=response.get("error_description", ""),
184-
)
185-
logline = lu.LOG_FMT.format(
186-
id=lu.get_session_id(context.state), message=msg
187-
)
188-
logger.debug(logline)
189-
raise SATOSAAuthenticationError(context.state, "Access denied")
190-
19159
def response_endpoint(self, context, *args):
19260
"""
19361
Handles the authentication response from the OP.
@@ -209,8 +77,8 @@ def response_endpoint(self, context, *args):
20977
# - https://developer.apple.com/documentation/sign_in_with_apple/namei
21078
try:
21179
userdata = context.request.get("user", "{}")
212-
userinfo = json.load(userdata)
213-
except Exception:
80+
userinfo = json.loads(userdata)
81+
except json.JSONDecodeError:
21482
userinfo = {}
21583

21684
authn_resp = self.client.parse_response(
@@ -242,78 +110,19 @@ def response_endpoint(self, context, *args):
242110
raise SATOSAAuthenticationError(context.state, "No user info available.")
243111

244112
all_user_claims = dict(list(userinfo.items()) + list(id_token_claims.items()))
113+
114+
# convert "string or Boolean" claims to actual booleans
115+
for bool_claim_name in ["email_verified", "is_private_email"]:
116+
userinfo[bool_claim_name] = (
117+
True
118+
if userinfo[bool_claim_name] == "true"
119+
else False
120+
)
121+
245122
msg = "UserInfo: {}".format(all_user_claims)
246123
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
247124
logger.debug(logline)
248125
internal_resp = self._translate_response(
249126
all_user_claims, self.client.authorization_endpoint
250127
)
251128
return self.auth_callback_func(context, internal_resp)
252-
253-
def _translate_response(self, response, issuer):
254-
"""
255-
Translates oidc response to SATOSA internal response.
256-
:type response: dict[str, str]
257-
:type issuer: str
258-
:type subject_type: str
259-
:rtype: InternalData
260-
261-
:param response: Dictioary with attribute name as key.
262-
:param issuer: The oidc op that gave the repsonse.
263-
:param subject_type: public or pairwise according to oidc standard.
264-
:return: A SATOSA internal response.
265-
"""
266-
auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer)
267-
internal_resp = InternalData(auth_info=auth_info)
268-
internal_resp.attributes = self.converter.to_internal("openid", response)
269-
internal_resp.subject_id = response["sub"]
270-
return internal_resp
271-
272-
def get_metadata_desc(self):
273-
"""
274-
See satosa.backends.oauth.get_metadata_desc
275-
:rtype: satosa.metadata_creation.description.MetadataDescription
276-
"""
277-
return get_metadata_desc_for_oauth_backend(
278-
self.config["provider_metadata"]["issuer"], self.config
279-
)
280-
281-
282-
def _create_client(provider_metadata, client_metadata, verify_ssl=True):
283-
"""
284-
Create a pyoidc client instance.
285-
:param provider_metadata: provider configuration information
286-
:type provider_metadata: Mapping[str, Union[str, Sequence[str]]]
287-
:param client_metadata: client metadata
288-
:type client_metadata: Mapping[str, Union[str, Sequence[str]]]
289-
:return: client instance to use for communicating with the configured provider
290-
:rtype: oic.oic.Client
291-
"""
292-
client = oic.Client(client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl)
293-
294-
# Provider configuration information
295-
if "authorization_endpoint" in provider_metadata:
296-
# no dynamic discovery necessary
297-
client.handle_provider_config(
298-
ProviderConfigurationResponse(**provider_metadata),
299-
provider_metadata["issuer"],
300-
)
301-
else:
302-
# do dynamic discovery
303-
client.provider_config(provider_metadata["issuer"])
304-
305-
# Client information
306-
if "client_id" in client_metadata:
307-
# static client info provided
308-
client.store_registration_info(RegistrationRequest(**client_metadata))
309-
else:
310-
# do dynamic registration
311-
client.register(
312-
client.provider_info["registration_endpoint"], **client_metadata
313-
)
314-
315-
client.subject_type = (
316-
client.registration_response.get("subject_type")
317-
or client.provider_info["subject_types_supported"][0]
318-
)
319-
return client

tests/satosa/test_attribute_mapping.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,56 @@
55
from satosa.attribute_mapping import AttributeMapper
66

77

8+
class TestAttributeMapperNestedDataDifferentAttrProfile:
9+
def test_nested_mapping_nested_data_to_internal(self):
10+
mapping = {
11+
"attributes": {
12+
"name": {
13+
"openid": ["name"]
14+
},
15+
"givenname": {
16+
"openid": ["given_name", "name.firstName"]
17+
},
18+
},
19+
}
20+
21+
data = {
22+
"name": {
23+
"firstName": "value-first",
24+
"lastName": "value-last",
25+
},
26+
"email": "[email protected]",
27+
}
28+
29+
converter = AttributeMapper(mapping)
30+
internal_repr = converter.to_internal("openid", data)
31+
assert internal_repr["name"] == [data["name"]]
32+
assert internal_repr["givenname"] == [data["name"]["firstName"]]
33+
34+
35+
def test_nested_mapping_simple_data_to_internal(self):
36+
mapping = {
37+
"attributes": {
38+
"name": {
39+
"openid": ["name"]
40+
},
41+
"givenname": {
42+
"openid": ["given_name", "name.firstName"]
43+
},
44+
},
45+
}
46+
47+
data = {
48+
"name": "value-first",
49+
"email": "[email protected]",
50+
}
51+
52+
converter = AttributeMapper(mapping)
53+
internal_repr = converter.to_internal("openid", data)
54+
assert internal_repr["name"] == [data["name"]]
55+
assert internal_repr.get("givenname") is None
56+
57+
858
class TestAttributeMapper:
959
def test_nested_attribute_to_internal(self):
1060
mapping = {

0 commit comments

Comments
 (0)