Files
edx-platform/common/djangoapps/third_party_auth/appleid.py
2021-03-18 11:26:19 +05:00

222 lines
7.9 KiB
Python

# Vendored from version 3.4.0 (9d93069564a60495e0ebd697b33e16fcff14195b)
# of social-core:
# https://github.com/python-social-auth/social-core/blob/3.4.0/social_core/backends/apple.py
#
# Additional changes:
#
# - Patch for JWT algorithms specification: eed3007c4ccdbe959b1a3ac83102fe869d261948
#
# v3.4.0 is unreleased at this time (2020-07-28) and contains several necessary
# bugfixes over 3.3.3 for AppleID, but also causes the
# TestShibIntegrationTest.test_full_pipeline_succeeds_for_unlinking_testshib_account
# test in common/djangoapps/third_party_auth/tests/specs/test_testshib.py to break
# (somehow related to social-core's change 561642bf which makes a bugfix to partial
# pipeline cleaning).
#
# Since we're not maintaining this file and want a relatively clean diff:
# pylint: skip-file
#
#
# social-core, and therefore this code, is under a BSD license:
#
#
# Copyright (c) 2012-2016, Matías Aguirre
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of this project nor the names of its contributors may be
# used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Sign In With Apple authentication backend.
Docs:
* https://developer.apple.com/documentation/signinwithapplerestapi
* https://developer.apple.com/documentation/signinwithapplerestapi/tokenresponse
Settings:
* `TEAM` - your team id;
* `KEY` - your key id;
* `CLIENT` - your client id;
* `AUDIENCE` - a list of authorized client IDs, defaults to [CLIENT].
Use this if you need to accept both service and bundle id to
be able to login both via iOS and ie a web form.
* `SECRET` - your secret key;
* `SCOPE` (optional) - e.g. `['name', 'email']`;
* `EMAIL_AS_USERNAME` - use apple email is username is set, use apple id
otherwise.
* `AppleIdAuth.TOKEN_TTL_SEC` - time before JWT token expiration, seconds.
* `SOCIAL_AUTH_APPLE_ID_INACTIVE_USER_LOGIN` - allow inactive users email to
login
"""
import json
import time
import jwt
from jwt.algorithms import RSAAlgorithm
from jwt.exceptions import PyJWTError
from social_core.backends.oauth import BaseOAuth2
from social_core.exceptions import AuthFailed
class AppleIdAuth(BaseOAuth2):
name = 'apple-id'
JWK_URL = 'https://appleid.apple.com/auth/keys'
AUTHORIZATION_URL = 'https://appleid.apple.com/auth/authorize'
ACCESS_TOKEN_URL = 'https://appleid.apple.com/auth/token'
ACCESS_TOKEN_METHOD = 'POST'
RESPONSE_MODE = None
ID_KEY = 'sub'
TOKEN_KEY = 'id_token'
STATE_PARAMETER = True
REDIRECT_STATE = False
TOKEN_AUDIENCE = 'https://appleid.apple.com'
TOKEN_TTL_SEC = 6 * 30 * 24 * 60 * 60
def get_audience(self):
client_id = self.setting('CLIENT')
return self.setting('AUDIENCE', default=[client_id])
def auth_params(self, *args, **kwargs):
"""
Apple requires to set `response_mode` to `form_post` if `scope`
parameter is passed.
"""
params = super().auth_params(*args, **kwargs)
if self.RESPONSE_MODE:
params['response_mode'] = self.RESPONSE_MODE
elif self.get_scope():
params['response_mode'] = 'form_post'
return params
def get_private_key(self):
"""
Return contents of the private key file. Override this method to provide
secret key from another source if needed.
"""
return self.setting('SECRET')
def generate_client_secret(self):
now = int(time.time())
client_id = self.setting('CLIENT')
team_id = self.setting('TEAM')
key_id = self.setting('KEY')
private_key = self.get_private_key()
headers = {'kid': key_id}
payload = {
'iss': team_id,
'iat': now,
'exp': now + self.TOKEN_TTL_SEC,
'aud': self.TOKEN_AUDIENCE,
'sub': client_id,
}
return jwt.encode(payload, key=private_key, algorithm='ES256',
headers=headers)
def get_key_and_secret(self):
client_id = self.setting('CLIENT')
client_secret = self.generate_client_secret()
return client_id, client_secret
def get_apple_jwk(self, kid=None):
"""
Return requested Apple public key or all available.
"""
keys = self.get_json(url=self.JWK_URL).get('keys')
if not isinstance(keys, list) or not keys:
raise AuthFailed(self, 'Invalid jwk response')
if kid:
return json.dumps([key for key in keys if key['kid'] == kid][0])
else:
return (json.dumps(key) for key in keys)
def decode_id_token(self, id_token):
"""
Decode and validate JWT token from apple and return payload including
user data.
"""
if not id_token:
raise AuthFailed(self, 'Missing id_token parameter')
try:
kid = jwt.get_unverified_header(id_token).get('kid')
public_key = RSAAlgorithm.from_jwk(self.get_apple_jwk(kid))
decoded = jwt.decode(
id_token,
key=public_key,
audience=self.get_audience(),
algorithms=['RS256'],
)
except PyJWTError:
raise AuthFailed(self, 'Token validation failed')
return decoded
def get_user_details(self, response):
name = response.get('name') or {}
fullname, first_name, last_name = self.get_user_names(
fullname='',
first_name=name.get('firstName', ''),
last_name=name.get('lastName', '')
)
email = response.get('email', '')
apple_id = response.get(self.ID_KEY, '')
# prevent updating User with empty strings
user_details = {
'first_name': first_name or None,
'last_name': last_name or None,
'email': email,
}
if email and self.setting('EMAIL_AS_USERNAME'):
user_details['username'] = email
if apple_id and not self.setting('EMAIL_AS_USERNAME'):
user_details['username'] = apple_id
return user_details
def do_auth(self, access_token, *args, **kwargs):
response = kwargs.pop('response', None) or {}
jwt_string = response.get(self.TOKEN_KEY) or access_token
if not jwt_string:
raise AuthFailed(self, 'Missing id_token parameter')
decoded_data = self.decode_id_token(jwt_string)
return super().do_auth(
access_token,
response=decoded_data,
*args,
**kwargs
)