Skip to content

Commit

Permalink
feat: Support Apple team Migration (#31861)
Browse files Browse the repository at this point in the history
* feat: LEARNER-8790 Support Apple team migration
  • Loading branch information
moeez96 authored Mar 10, 2023
1 parent 8ac76da commit 5b1eb37
Show file tree
Hide file tree
Showing 13 changed files with 675 additions and 1 deletion.
8 changes: 8 additions & 0 deletions common/djangoapps/third_party_auth/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .models import (
_PSA_OAUTH2_BACKENDS,
_PSA_SAML_BACKENDS,
AppleMigrationUserIdInfo,
LTIProviderConfig,
OAuth2ProviderConfig,
SAMLConfiguration,
Expand Down Expand Up @@ -196,3 +197,10 @@ def get_list_display(self, request):
)

admin.site.register(LTIProviderConfig, LTIProviderConfigAdmin)


class AppleMigrationUserIdInfoAdmin(admin.ModelAdmin):
""" Django Admin class for AppleMigrationUserIdInfo """


admin.site.register(AppleMigrationUserIdInfo, AppleMigrationUserIdInfoAdmin)
31 changes: 31 additions & 0 deletions common/djangoapps/third_party_auth/appleid.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@
from jwt.algorithms import RSAAlgorithm
from jwt.exceptions import PyJWTError

from django.apps import apps
from social_core.backends.oauth import BaseOAuth2
from social_core.exceptions import AuthFailed
import social_django

from common.djangoapps.third_party_auth.toggles import is_apple_user_migration_enabled


class AppleIdAuth(BaseOAuth2):
Expand Down Expand Up @@ -205,6 +209,33 @@ def get_user_details(self, response):

return user_details

def get_user_id(self, details, response):
"""
If Apple team has been migrated, return the correct team_scoped apple_id that matches
existing UserSocialAuth instance. Else return apple_id as received in response.
"""
apple_id = super().get_user_id(details, response)

if is_apple_user_migration_enabled():
if social_django.models.DjangoStorage.user.get_social_auth(provider=self.name, uid=apple_id):
return apple_id

transfer_sub = response.get('transfer_sub')
if transfer_sub:
# Apple will send a transfer_sub till 60 days after the Apple Team has been migrated.
# If the team has been migrated and UserSocialAuth entries have not yet been updated
# with the new team-scoped apple-ids', use the transfer_sub to match to old apple ids'
# belonging to already signed-in users.
AppleMigrationUserIdInfo = apps.get_model('third_party_auth', 'AppleMigrationUserIdInfo')
user_apple_id_info = AppleMigrationUserIdInfo.objects.filter(transfer_id=transfer_sub).first()
old_apple_id = user_apple_id_info.old_apple_id
if social_django.models.DjangoStorage.user.get_social_auth(provider=self.name, uid=old_apple_id):
user_apple_id_info.new_apple_id = response.get(self.ID_KEY)
user_apple_id_info.save()
return user_apple_id_info.old_apple_id

return apple_id

def do_auth(self, access_token, *args, **kwargs):
response = kwargs.pop('response', None) or {}
jwt_string = response.get(self.TOKEN_KEY) or access_token
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
Migrating Apple users while switching teams on Apple
-----------------------------------------------

This document explains how to migrate apple signed-in users in the event of
switching teams on the Apple Developer console. When a user uses Apple to sign in,
LMS receives an `id_token from apple containing user information`_, including
user's unique identifier with key `sub`. This unique identifier is unique to
Apple team this user belongs to. Upon switching teams on Apple, developers need
to migrate users from one team to another i.e. migrate users' unique
identifiers. In the LMS, users' unique apple identifiers are stored in
social_django.models.UserSocialAuth.uid. Following is an outline specifying the
migration process.

1. `Create transfer_identifiers for all apple users`_ using the current respective apple unique id.

i. Run management command generate_and_store_apple_transfer_ids to generate and store apple transfer ids.

ii. Transfer ids are stored in third_party_auth.models.AppleMigrationUserIdInfo to be used later on.

2. Transfer/Migrate teams on Apple account.

i. After the migration, `Apple continues to send the transfer identifier`_ with key `transfer_sub` in information sent after login.

ii. These transfer identifiers are available in the login information for 60 days after team transfer.

ii. The method get_user_id() in third_party_auth.appleid.AppleIdAuth enables existing users to sign in by matching the transfer_sub sent in the login information with stored records of old Apple unique identifiers in third_party_auth.models.AppleMigrationUserIdInfo.

3. Update Apple Backend credentials in third_party_auth.models.OAuth2ProviderConfig for the Apple backend.

4. Create new team-scoped apple unique ids' for users after the migration using transfer ids created in Step 1.

i. Run management command generate_and_store_new_apple_ids to generate and store new team-scoped apple ids.

5. Update apple unique identifiers in the Database with new team-scoped apple ids retrieved in step 3.

i. Run management command update_new_apple_ids_in_social_auth.

6. Apple user migration is complete!


.. _id_token from apple containing user information: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
.. _Create transfer_identifiers for all apple users: https://developer.apple.com/documentation/sign_in_with_apple/transferring_your_apps_and_users_to_another_team
.. _Apple continues to send the transfer identifier: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Management command to generate Transfer Identifiers for users who signed in with Apple.
These transfer identifiers are used in the event of migrating an app from one team to another.
"""


import logging
import requests
import time

from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
import jwt
from social_django.models import UserSocialAuth
from social_django.utils import load_strategy


from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
from common.djangoapps.third_party_auth.appleid import AppleIdAuth

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Management command to generate transfer identifiers for apple users using their apple_id
stored in social_django.models.UserSocialAuth.uid.
Usage:
manage.py generate_and_store_apple_transfer_ids <target_team_id>
"""

def _generate_client_secret(self):
"""
Generate client secret for use in Apple API's
"""
now = int(time.time())
expiry = 60 * 60 * 3 # 3 hours

backend = load_strategy().get_backend(AppleIdAuth.name)
team_id = backend.setting('TEAM')
key_id = backend.setting('KEY')
private_key = backend.get_private_key()
audience = backend.TOKEN_AUDIENCE

headers = {
"alg": "ES256",
'kid': key_id
}
payload = {
'iss': team_id,
'iat': now,
'exp': now + expiry,
'aud': audience,
'sub': "org.edx.mobile",
}

return jwt.encode(payload, key=private_key, algorithm='ES256',
headers=headers)

def _generate_access_token(self, client_secret):
"""
Generate access token for use in Apple API's
"""
access_token_url = 'https://appleid.apple.com/auth/token'
app_id = "org.edx.mobile"
payload = {
"grant_type": "client_credentials",
"scope": "user.migration",
"client_id": app_id,
"client_secret": client_secret
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Host": "appleid.apple.com"
}
response = requests.post(access_token_url, data=payload, headers=headers)
access_token = response.json().get('access_token')
return access_token

def add_arguments(self, parser):
parser.add_argument('target_team_id', help='Team ID to which the app is to be migrated to.')

@transaction.atomic
def handle(self, *args, **options):
target_team_id = options['target_team_id']

migration_url = "https://appleid.apple.com/auth/usermigrationinfo"
app_id = "org.edx.mobile"

client_secret = self._generate_client_secret()
access_token = self._generate_access_token(client_secret)
if not access_token:
raise CommandError('Failed to create access token.')

headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Host": "appleid.apple.com",
"Authorization": "Bearer " + access_token
}
payload = {
"target": target_team_id,
"client_id": app_id,
"client_secret": client_secret
}

apple_ids = UserSocialAuth.objects.filter(provider=AppleIdAuth.name).values_list('uid', flat=True)
for apple_id in apple_ids:
payload['sub'] = apple_id
response = requests.post(migration_url, data=payload, headers=headers)
transfer_id = response.json().get('transfer_sub')
if transfer_id:
apple_user_id_info, _ = AppleMigrationUserIdInfo.objects.get_or_create(old_apple_id=apple_id)
apple_user_id_info.transfer_id = transfer_id
apple_user_id_info.save()
log.info('Updated transfer_id for uid %s', apple_id)
else:
log.info('Unable to fetch transfer_id for uid %s', apple_id)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
Management command to exchange apple transfer identifiers with Apple ID of the
user for new migrated team.
"""


import logging
import requests
import time

from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
import jwt
from social_django.utils import load_strategy

from common.djangoapps.third_party_auth.models import AppleMigrationUserIdInfo
from common.djangoapps.third_party_auth.appleid import AppleIdAuth

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Management command to exchange transfer identifiers for new team-scoped identifier for
the user in new migrated team.
Usage:
manage.py generate_and_store_apple_transfer_ids
"""

def _generate_client_secret(self):
"""
Generate client secret for use in Apple API's
"""
now = int(time.time())
expiry = 60 * 60 * 3 # 3 hours

backend = load_strategy().get_backend(AppleIdAuth.name)
team_id = backend.setting('TEAM')
key_id = backend.setting('KEY')
private_key = backend.get_private_key()
audience = backend.TOKEN_AUDIENCE

headers = {
"alg": "ES256",
'kid': key_id
}
payload = {
'iss': team_id,
'iat': now,
'exp': now + expiry,
'aud': audience,
'sub': "org.edx.mobile",
}

return jwt.encode(payload, key=private_key, algorithm='ES256',
headers=headers)

def _generate_access_token(self, client_secret):
"""
Generate access token for use in Apple API's
"""
access_token_url = 'https://appleid.apple.com/auth/token'
app_id = "org.edx.mobile"
payload = {
"grant_type": "client_credentials",
"scope": "user.migration",
"client_id": app_id,
"client_secret": client_secret
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Host": "appleid.apple.com"
}
response = requests.post(access_token_url, data=payload, headers=headers)
access_token = response.json().get('access_token')
return access_token

@transaction.atomic
def handle(self, *args, **options):
migration_url = "https://appleid.apple.com/auth/usermigrationinfo"
app_id = "org.edx.mobile"

client_secret = self._generate_client_secret()
access_token = self._generate_access_token(client_secret)
if not access_token:
raise CommandError('Failed to create access token.')

headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Host": "appleid.apple.com",
"Authorization": "Bearer " + access_token
}
payload = {
"client_id": app_id,
"client_secret": client_secret
}

apple_user_ids_info = AppleMigrationUserIdInfo.objects.all()
for apple_user_id_info in apple_user_ids_info:
payload['transfer_sub'] = apple_user_id_info.transfer_id
response = requests.post(migration_url, data=payload, headers=headers)
new_apple_id = response.json().get('sub')
if new_apple_id:
apple_user_id_info.new_apple_id = new_apple_id
apple_user_id_info.save()
log.info('Updated new Apple ID for uid %s',
apple_user_id_info.old_apple_id)
else:
log.info('Unable to fetch new Apple ID for uid %s',
apple_user_id_info.old_apple_id)
Loading

0 comments on commit 5b1eb37

Please sign in to comment.