|
| 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