Skip to content

Commit

Permalink
ARCHBOM-1281: Vendor in AppleID backend to support Sign-In With Apple
Browse files Browse the repository at this point in the history
This version contains necessary fixes for validating the "audience"
component of the JWT, as seen in ARCHBOM-1281. (I believe we'll need to
pass both the App ID and the Service ID in an additional AUDIENCE "other
settings" key for this third-party-auth backend.)

Vendored from version 3.4.0 (9d93069564a60495e0ebd697b33e16fcff14195b)
social-core:
https://github.com/python-social-auth/social-core/blob/3.4.0/social_core/backends/apple.py

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). ARCHBOM-1389 filed to address
this at our convenience.

Note: 3.4.0 was not released to PyPI due to a broken test, so we might
see a 3.4.1 when it's actually released:
python-social-auth/social-core#485
  • Loading branch information
timmc-edx committed Jul 29, 2020
1 parent 2ea6b25 commit a44444d
Show file tree
Hide file tree
Showing 2 changed files with 218 additions and 1 deletion.
217 changes: 217 additions & 0 deletions common/djangoapps/third_party_auth/appleid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# 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
#
# 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(AppleIdAuth, self).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(),
algorithm='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(AppleIdAuth, self).do_auth(
access_token,
response=decoded_data,
*args,
**kwargs
)
2 changes: 1 addition & 1 deletion lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ def get_env_setting(setting):
'social_core.backends.linkedin.LinkedinOAuth2',
'social_core.backends.facebook.FacebookOAuth2',
'social_core.backends.azuread.AzureADOAuth2',
'social_core.backends.apple.AppleIdAuth',
'third_party_auth.appleid.AppleIdAuth', # vendored 'social_core.backends.apple.AppleIdAuth'
'third_party_auth.identityserver3.IdentityServer3',
'third_party_auth.saml.SAMLAuthBackend',
'third_party_auth.lti.LTIAuthBackend',
Expand Down

0 comments on commit a44444d

Please sign in to comment.