Skip to content

Commit 8b95e0e

Browse files
Merge pull request #339 from melanger/apple_backend
Add backend to support sign in with Apple ID
2 parents 5e21991 + 1c4e316 commit 8b95e0e

File tree

2 files changed

+314
-0
lines changed

2 files changed

+314
-0
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
module: satosa.backends.apple.AppleBackend
2+
name: apple
3+
config:
4+
provider_metadata:
5+
issuer: https://appleid.apple.com
6+
client:
7+
verify_ssl: yes
8+
auth_req_params:
9+
response_type: code
10+
scope: [openid, email, name]
11+
response_mode: form_post
12+
token_endpoint_auth_method: client_secret_post
13+
client_metadata:
14+
application_name: Sign in with Apple
15+
application_type: web
16+
client_id: 'CLIENT_ID_HERE'
17+
client_secret: 'CLIENT_SECRET_HERE'
18+
redirect_uris: [<base_url>/<name>]
19+
subject_type: pairwise
20+
entity_info:
21+
organization:
22+
display_name:
23+
- ['Apple', 'en']
24+
name:
25+
- ['Apple Inc.', 'en']
26+
ui_info:
27+
display_name:
28+
- lang: en
29+
text: 'Sign in with Apple'

src/satosa/backends/apple.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""
2+
Apple backend module.
3+
"""
4+
import logging
5+
from datetime import datetime
6+
from urllib.parse import urlparse
7+
8+
from oic.oauth2.message import Message
9+
from oic import oic
10+
from oic import rndstr
11+
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+
17+
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+
25+
import base64
26+
import json
27+
import requests
28+
29+
30+
logger = logging.getLogger(__name__)
31+
32+
NONCE_KEY = "oidc_nonce"
33+
STATE_KEY = "oidc_state"
34+
35+
# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
36+
class AppleBackend(BackendModule):
37+
"""Sign in with Apple backend"""
38+
39+
def __init__(self, auth_callback_func, internal_attributes, config, base_url, name):
40+
"""
41+
Sign in with Apple backend module.
42+
:param auth_callback_func: Callback should be called by the module after the authorization
43+
in the backend is done.
44+
:param internal_attributes: Mapping dictionary between SATOSA internal attribute names and
45+
the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and
46+
RP's expects namevice.
47+
:param config: Configuration parameters for the module.
48+
:param base_url: base url of the service
49+
:param name: name of the plugin
50+
51+
:type auth_callback_func:
52+
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
53+
:type internal_attributes: dict[string, dict[str, str | list[str]]]
54+
:type config: dict[str, dict[str, str] | list[str]]
55+
:type base_url: str
56+
:type name: str
57+
"""
58+
super().__init__(auth_callback_func, internal_attributes, base_url, name)
59+
self.auth_callback_func = auth_callback_func
60+
self.config = config
61+
self.client = _create_client(
62+
config["provider_metadata"],
63+
config["client"]["client_metadata"],
64+
config["client"].get("verify_ssl", True),
65+
)
66+
if "scope" not in config["client"]["auth_req_params"]:
67+
config["auth_req_params"]["scope"] = "openid"
68+
if "response_type" not in config["client"]["auth_req_params"]:
69+
config["auth_req_params"]["response_type"] = "code"
70+
71+
def start_auth(self, context, request_info):
72+
"""
73+
See super class method satosa.backends.base#start_auth
74+
:type context: satosa.context.Context
75+
:type request_info: satosa.internal.InternalData
76+
"""
77+
oidc_nonce = rndstr()
78+
oidc_state = rndstr()
79+
state_data = {
80+
NONCE_KEY: oidc_nonce,
81+
STATE_KEY: oidc_state
82+
}
83+
context.state[self.name] = state_data
84+
85+
args = {
86+
"scope": self.config["client"]["auth_req_params"]["scope"],
87+
"response_type": self.config["client"]["auth_req_params"]["response_type"],
88+
"client_id": self.client.client_id,
89+
"redirect_uri": self.client.registration_response["redirect_uris"][0],
90+
"state": oidc_state,
91+
"nonce": oidc_nonce
92+
}
93+
args.update(self.config["client"]["auth_req_params"])
94+
auth_req = self.client.construct_AuthorizationRequest(request_args=args)
95+
login_url = auth_req.request(self.client.authorization_endpoint)
96+
return Redirect(login_url)
97+
98+
def register_endpoints(self):
99+
"""
100+
Creates a list of all the endpoints this backend module needs to listen to. In this case
101+
it's the authentication response from the underlying OP that is redirected from the OP to
102+
the proxy.
103+
:rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]]
104+
:return: A list that can be used to map the request to SATOSA to this endpoint.
105+
"""
106+
url_map = []
107+
redirect_path = urlparse(self.config["client"]["client_metadata"]["redirect_uris"][0]).path
108+
if not redirect_path:
109+
raise SATOSAError("Missing path in redirect uri")
110+
111+
url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint))
112+
return url_map
113+
114+
def _verify_nonce(self, nonce, context):
115+
"""
116+
Verify the received OIDC 'nonce' from the ID Token.
117+
:param nonce: OIDC nonce
118+
:type nonce: str
119+
:param context: current request context
120+
:type context: satosa.context.Context
121+
:raise SATOSAAuthenticationError: if the nonce is incorrect
122+
"""
123+
backend_state = context.state[self.name]
124+
if nonce != backend_state[NONCE_KEY]:
125+
msg = "Missing or invalid nonce in authn response for state: {}".format(backend_state)
126+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
127+
logger.debug(logline)
128+
raise SATOSAAuthenticationError(context.state, "Missing or invalid nonce in authn response")
129+
130+
def _get_tokens(self, authn_response, context):
131+
"""
132+
:param authn_response: authentication response from OP
133+
:type authn_response: oic.oic.message.AuthorizationResponse
134+
:return: access token and ID Token claims
135+
:rtype: Tuple[Optional[str], Optional[Mapping[str, str]]]
136+
"""
137+
if "code" in authn_response:
138+
# make token request
139+
# https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
140+
args = {
141+
"client_id": self.client.client_id,
142+
"client_secret": self.client.client_secret,
143+
"code": authn_response["code"],
144+
"grant_type": "authorization_code",
145+
"redirect_uri": self.client.registration_response['redirect_uris'][0],
146+
}
147+
148+
token_resp = requests.post(
149+
"https://appleid.apple.com/auth/token",
150+
data=args,
151+
headers={"Content-Type": "application/x-www-form-urlencoded"}
152+
).json()
153+
154+
logger.debug("apple response received")
155+
logger.debug(token_resp)
156+
157+
self._check_error_response(token_resp, context)
158+
159+
keyjar = self.client.keyjar
160+
id_token_claims = dict(Message().from_jwt(token_resp["id_token"], keyjar=keyjar))
161+
162+
return token_resp["access_token"], id_token_claims
163+
164+
return authn_response.get("access_token"), authn_response.get("id_token")
165+
166+
def _check_error_response(self, response, context):
167+
"""
168+
Check if the response is an OAuth error response.
169+
:param response: the OIDC response
170+
:type response: oic.oic.message
171+
:raise SATOSAAuthenticationError: if the response is an OAuth error response
172+
"""
173+
if "error" in response:
174+
msg = "{name} error: {error} {description}".format(
175+
name=type(response).__name__,
176+
error=response["error"],
177+
description=response.get("error_description", ""),
178+
)
179+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
180+
logger.debug(logline)
181+
raise SATOSAAuthenticationError(context.state, "Access denied")
182+
183+
def response_endpoint(self, context, *args):
184+
"""
185+
Handles the authentication response from the OP.
186+
:type context: satosa.context.Context
187+
:type args: Any
188+
:rtype: satosa.response.Response
189+
190+
:param context: SATOSA context
191+
:param args: None
192+
:return:
193+
"""
194+
backend_state = context.state[self.name]
195+
authn_resp = self.client.parse_response(AuthorizationResponse, info=context.request, sformat="dict")
196+
if backend_state[STATE_KEY] != authn_resp["state"]:
197+
msg = "Missing or invalid state in authn response for state: {}".format(backend_state)
198+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
199+
logger.debug(logline)
200+
raise SATOSAAuthenticationError(context.state, "Missing or invalid state in authn response")
201+
202+
self._check_error_response(authn_resp, context)
203+
access_token, id_token_claims = self._get_tokens(authn_resp, context)
204+
if not id_token_claims:
205+
id_token_claims = {}
206+
207+
# Apple has no userinfo endpoint
208+
userinfo = {}
209+
210+
if not id_token_claims and not userinfo:
211+
msg = "No id_token or userinfo, nothing to do.."
212+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
213+
logger.error(logline)
214+
raise SATOSAAuthenticationError(context.state, "No user info available.")
215+
216+
all_user_claims = dict(list(userinfo.items()) + list(id_token_claims.items()))
217+
msg = "UserInfo: {}".format(all_user_claims)
218+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
219+
logger.debug(logline)
220+
del context.state[self.name]
221+
internal_resp = self._translate_response(all_user_claims, self.client.authorization_endpoint)
222+
return self.auth_callback_func(context, internal_resp)
223+
224+
def _translate_response(self, response, issuer):
225+
"""
226+
Translates oidc response to SATOSA internal response.
227+
:type response: dict[str, str]
228+
:type issuer: str
229+
:type subject_type: str
230+
:rtype: InternalData
231+
232+
:param response: Dictioary with attribute name as key.
233+
:param issuer: The oidc op that gave the repsonse.
234+
:param subject_type: public or pairwise according to oidc standard.
235+
:return: A SATOSA internal response.
236+
"""
237+
auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer)
238+
internal_resp = InternalData(auth_info=auth_info)
239+
internal_resp.attributes = self.converter.to_internal("openid", response)
240+
internal_resp.subject_id = response["sub"]
241+
return internal_resp
242+
243+
def get_metadata_desc(self):
244+
"""
245+
See satosa.backends.oauth.get_metadata_desc
246+
:rtype: satosa.metadata_creation.description.MetadataDescription
247+
"""
248+
return get_metadata_desc_for_oauth_backend(self.config["provider_metadata"]["issuer"], self.config)
249+
250+
251+
def _create_client(provider_metadata, client_metadata, verify_ssl=True):
252+
"""
253+
Create a pyoidc client instance.
254+
:param provider_metadata: provider configuration information
255+
:type provider_metadata: Mapping[str, Union[str, Sequence[str]]]
256+
:param client_metadata: client metadata
257+
:type client_metadata: Mapping[str, Union[str, Sequence[str]]]
258+
:return: client instance to use for communicating with the configured provider
259+
:rtype: oic.oic.Client
260+
"""
261+
client = oic.Client(
262+
client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=verify_ssl
263+
)
264+
265+
# Provider configuration information
266+
if "authorization_endpoint" in provider_metadata:
267+
# no dynamic discovery necessary
268+
client.handle_provider_config(ProviderConfigurationResponse(**provider_metadata),
269+
provider_metadata["issuer"])
270+
else:
271+
# do dynamic discovery
272+
client.provider_config(provider_metadata["issuer"])
273+
274+
# Client information
275+
if "client_id" in client_metadata:
276+
# static client info provided
277+
client.store_registration_info(RegistrationRequest(**client_metadata))
278+
else:
279+
# do dynamic registration
280+
client.register(client.provider_info['registration_endpoint'],
281+
**client_metadata)
282+
283+
client.subject_type = (client.registration_response.get("subject_type") or
284+
client.provider_info["subject_types_supported"][0])
285+
return client

0 commit comments

Comments
 (0)