python-social-auth / social-app-django

Python Social Auth - Application - Django
BSD 3-Clause "New" or "Revised" License
2.04k stars 380 forks source link

Apple social login #226

Open Allan-Nava opened 5 years ago

Allan-Nava commented 5 years ago

When is possible to implement the social login with apple sign in?

AlBartash commented 5 years ago

+1 to the request

ramonyaskal commented 4 years ago

+1

aehlke commented 4 years ago

Please don't spam subscribers with +1 replies and use emoji response to the OP, thanks.

goetzb commented 4 years ago

This should now be possible with Python Social Auth - Core 3.3.0 (social-auth-core==3.3.0).

You can find the backend named apple-id in https://github.com/python-social-auth/social-core/blob/master/social_core/backends/apple.py - I believe you can use that release of social-auth-core together with the existing release of social-auth-app-django.

Allan-Nava commented 4 years ago

This should now be possible with Python Social Auth - Core 3.3.0 (social-auth-core==3.3.0).

You can find the backend named apple-id in https://github.com/python-social-auth/social-core/blob/master/social_core/backends/apple.py - I believe you can use that release of social-auth-core together with the existing release of social-auth-app-django.

perfect!

MiltonMilton commented 4 years ago

is there an implementation example ? im trying to implement this on a django rest app but for some reason the the response that arrives to the apple backend in the do_auth method is empty, any help would be appreciated

ramonyaskal commented 4 years ago

is there an implementation example ? im trying to implement this on a django rest app but for some reason the the response that arrives to the apple backend in the do_auth method is empty, any help would be appreciated

I had to do it myself, maybe it will help you.

`class AppleOAuth2(BaseOAuth2): """apple authentication backend"""

name = 'apple-oauth2'
RESPONSE_TYPE = 'code'
AUTHORIZATION_URL = 'https://appleid.apple.com/auth/authorize'
ACCESS_TOKEN_METHOD = 'POST'
ACCESS_TOKEN_URL = 'https://appleid.apple.com/auth/token'
SCOPE_SEPARATOR = ','
ID_KEY = 'uid'
REDIRECT_STATE = False
jwks_url = 'https://appleid.apple.com/auth/keys'

def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    if self.redirect_uri and settings.SCHEME not in self.redirect_uri:
        self.redirect_uri = self.redirect_uri.replace("http", settings.SCHEME) \
            if "http" in self.redirect_uri else self.redirect_uri

def auth_url(self):
    url = super().auth_url()
    extra_params = {
        'response_mode': 'form_post',
        'scope': 'email+name',
    }
    url += ''.join((f'&{key}={extra_params[key]}' for key in extra_params))
    return url

@handle_http_errors
def auth_complete(self, *args, **kwargs):
    """Completes login process, must return user instance"""
    request = kwargs.get('request', None)
    post = request.POST if request else None
    self.STATE_PARAMETER = post.get('state', self.STATE_PARAMETER)

    logger.info(f"Sing with Apple: auth_complete {{'request.POST': {post}}}")

    if 'user' in post:
        kwargs.update({'user_info': json.loads(post['user'])})

    try:
        self.process_error(self.data)
    except social_exceptions.AuthCanceled:
        return None

    state = post.get('state', None) if post else self.validate_state()
    data, params = None, None
    if self.ACCESS_TOKEN_METHOD == 'GET':
        params = self.auth_complete_params(state)
    else:
        data = self.auth_complete_params(state)

    response = self.request_access_token(
        self.access_token_url(),
        data=data,
        params=params,
        headers=self.auth_headers(),
        auth=self.auth_complete_credentials(),
        method=self.ACCESS_TOKEN_METHOD
    )
    logger.info(f"Sing with Apple: auth_complete {{'response': {response}}}")
    self.process_error(response)
    return self.do_auth(response['access_token'], response=response, *args, **kwargs)

@handle_http_errors
def do_auth(self, access_token, *args, **kwargs):
    """
    Finish the auth process once the access_token was retrieved
    Get the email from ID token received from apple
    """
    response_data = {}
    response = kwargs.get('response') or {}
    id_token = response.get('id_token') if 'id_token' in response else None
    user_info = kwargs.get('user_info', None)

    logger.info(f"Sing with Apple: {{'id_token': {id_token}}}")

    if id_token:
        public_key = self.get_public_key(jwks_url=self.jwks_url, id_token=id_token)
        decoded = jwt.decode(id_token, public_key, verify=True, audience=settings.CLIENT_ID)
        logger.info(f"Sing with Apple: {{'decode': {decoded}}}")

        response_data.update({'email': decoded['email']}) if 'email' in decoded else None
        response_data.update({'uid': decoded['sub']}) if 'sub' in decoded else None

        if user_info and 'name' in user_info and user_info['name']:
            response_data.update({'first_name': user_info['name']['firstName']}) \
                if 'firstName' in user_info['name'] else None
            response_data.update({'last_name': user_info['name']['lastName']}) \
                if 'lastName' in user_info['name'] else None

    response = kwargs.get('response') or {}
    response.update(response_data)
    response.update({'access_token': access_token}) if 'access_token' not in response else None

    kwargs.update({'response': response, 'backend': self})

    # Manual create UserSocialAuth obj if user exists
    email = response.get('email', None)
    if email:
        user = User.objects.filter(email=email).first()
        is_exist_user_auth = UserSocialAuth.objects.filter(
            uid=response.get('uid'),
            user=user,
            provider=self.name,
        ).exists()
        if user and not is_exist_user_auth:
            user_social_auth = UserSocialAuth.objects.create(
                user=user,
                provider=self.name,
                uid=response.get('uid'),
                extra_data={
                    'auth_time': int(time.time()),
                    'access_token': response.get('access_token'),
                    'token_type': response.get('token_type') or kwargs.get('token_type'),
                }
            )

            logger.info(f"Sing with Apple: Manual create obj UserSocialAuth for exist user"
                        f" {{'UserSocialAuth': {user_social_auth}}}")

    logger_data = {
        'args': args,
        'kwargs': kwargs,
    }
    logger.info(f"Sing with Apple: finality do_auth {logger_data}")
    return self.strategy.authenticate(*args, **kwargs)

def get_user_details(self, response):
    email = response.get('email', None)
    first_name = response.get('first_name', None)
    last_name = response.get('last_name', None)
    details = {
        'email': email,
        'is_client': True,
        'is_verified': True,
    }
    details.update({'first_name': first_name}) if first_name else None
    details.update({'last_name': last_name}) if last_name else None
    return details

def get_key_and_secret(self):
    headers = {
        'kid': settings.SOCIAL_AUTH_APPLE_KEY_ID
    }

    payload = {
        'iss': settings.SOCIAL_AUTH_APPLE_TEAM_ID,
        'iat': timezone.now(),
        'exp': timezone.now() + timedelta(days=180),
        'aud': 'https://appleid.apple.com',
        'sub': settings.CLIENT_ID,
    }

    client_secret = jwt.encode(
        payload,
        settings.SOCIAL_AUTH_APPLE_PRIVATE_KEY,
        algorithm='ES256',
        headers=headers
    ).decode("utf-8")

    return settings.CLIENT_ID, client_secret

@staticmethod
def get_public_key(jwks_url, id_token):
    """
    Apple give public key https://appleid.apple.com/auth/keys
    Example:
        {
          "keys": [
            {
              "kty": "RSA",
              "kid": "86D88Kf",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            },
            {
              "kty": "RSA",
              "kid": "eXaunmL",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            },
            {
              "kty": "RSA",
              "kid": "AIDOPK1",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            }
          ]
        }
    Use kid for give some parameters for public key.

    Now use by default "kid": "eXaunmL" ago get ['keys'][1]
    Decode https://jwt.io/ id_token and show what kid in HEADER
    :return: RSAAlgorithm obj
    """
    header = jwt.get_unverified_header(id_token)
    kid = header['kid']

    response = requests.get(jwks_url)
    if response.ok:
        json_data = response.json()
        for key_data in json_data['keys']:
            if key_data['kid'] == kid:
                key_json = json.dumps(key_data)
                return RSAAlgorithm.from_jwk(key_json)

        # Or used default
        keys_json = json.dumps(json_data['keys'][1])
        return RSAAlgorithm.from_jwk(keys_json)`
MiltonMilton commented 4 years ago

is there an implementation example ? im trying to implement this on a django rest app but for some reason the the response that arrives to the apple backend in the do_auth method is empty, any help would be appreciated

I had to do it myself, maybe it will help you.

`class AppleOAuth2(BaseOAuth2): """apple authentication backend"""

name = 'apple-oauth2'
RESPONSE_TYPE = 'code'
AUTHORIZATION_URL = 'https://appleid.apple.com/auth/authorize'
ACCESS_TOKEN_METHOD = 'POST'
ACCESS_TOKEN_URL = 'https://appleid.apple.com/auth/token'
SCOPE_SEPARATOR = ','
ID_KEY = 'uid'
REDIRECT_STATE = False
jwks_url = 'https://appleid.apple.com/auth/keys'

def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    if self.redirect_uri and settings.SCHEME not in self.redirect_uri:
        self.redirect_uri = self.redirect_uri.replace("http", settings.SCHEME) \
            if "http" in self.redirect_uri else self.redirect_uri

def auth_url(self):
    url = super().auth_url()
    extra_params = {
        'response_mode': 'form_post',
        'scope': 'email+name',
    }
    url += ''.join((f'&{key}={extra_params[key]}' for key in extra_params))
    return url

@handle_http_errors
def auth_complete(self, *args, **kwargs):
    """Completes login process, must return user instance"""
    request = kwargs.get('request', None)
    post = request.POST if request else None
    self.STATE_PARAMETER = post.get('state', self.STATE_PARAMETER)

    logger.info(f"Sing with Apple: auth_complete {{'request.POST': {post}}}")

    if 'user' in post:
        kwargs.update({'user_info': json.loads(post['user'])})

    try:
        self.process_error(self.data)
    except social_exceptions.AuthCanceled:
        return None

    state = post.get('state', None) if post else self.validate_state()
    data, params = None, None
    if self.ACCESS_TOKEN_METHOD == 'GET':
        params = self.auth_complete_params(state)
    else:
        data = self.auth_complete_params(state)

    response = self.request_access_token(
        self.access_token_url(),
        data=data,
        params=params,
        headers=self.auth_headers(),
        auth=self.auth_complete_credentials(),
        method=self.ACCESS_TOKEN_METHOD
    )
    logger.info(f"Sing with Apple: auth_complete {{'response': {response}}}")
    self.process_error(response)
    return self.do_auth(response['access_token'], response=response, *args, **kwargs)

@handle_http_errors
def do_auth(self, access_token, *args, **kwargs):
    """
    Finish the auth process once the access_token was retrieved
    Get the email from ID token received from apple
    """
    response_data = {}
    response = kwargs.get('response') or {}
    id_token = response.get('id_token') if 'id_token' in response else None
    user_info = kwargs.get('user_info', None)

    logger.info(f"Sing with Apple: {{'id_token': {id_token}}}")

    if id_token:
        public_key = self.get_public_key(jwks_url=self.jwks_url, id_token=id_token)
        decoded = jwt.decode(id_token, public_key, verify=True, audience=settings.CLIENT_ID)
        logger.info(f"Sing with Apple: {{'decode': {decoded}}}")

        response_data.update({'email': decoded['email']}) if 'email' in decoded else None
        response_data.update({'uid': decoded['sub']}) if 'sub' in decoded else None

        if user_info and 'name' in user_info and user_info['name']:
            response_data.update({'first_name': user_info['name']['firstName']}) \
                if 'firstName' in user_info['name'] else None
            response_data.update({'last_name': user_info['name']['lastName']}) \
                if 'lastName' in user_info['name'] else None

    response = kwargs.get('response') or {}
    response.update(response_data)
    response.update({'access_token': access_token}) if 'access_token' not in response else None

    kwargs.update({'response': response, 'backend': self})

    # Manual create UserSocialAuth obj if user exists
    email = response.get('email', None)
    if email:
        user = User.objects.filter(email=email).first()
        is_exist_user_auth = UserSocialAuth.objects.filter(
            uid=response.get('uid'),
            user=user,
            provider=self.name,
        ).exists()
        if user and not is_exist_user_auth:
            user_social_auth = UserSocialAuth.objects.create(
                user=user,
                provider=self.name,
                uid=response.get('uid'),
                extra_data={
                    'auth_time': int(time.time()),
                    'access_token': response.get('access_token'),
                    'token_type': response.get('token_type') or kwargs.get('token_type'),
                }
            )

            logger.info(f"Sing with Apple: Manual create obj UserSocialAuth for exist user"
                        f" {{'UserSocialAuth': {user_social_auth}}}")

    logger_data = {
        'args': args,
        'kwargs': kwargs,
    }
    logger.info(f"Sing with Apple: finality do_auth {logger_data}")
    return self.strategy.authenticate(*args, **kwargs)

def get_user_details(self, response):
    email = response.get('email', None)
    first_name = response.get('first_name', None)
    last_name = response.get('last_name', None)
    details = {
        'email': email,
        'is_client': True,
        'is_verified': True,
    }
    details.update({'first_name': first_name}) if first_name else None
    details.update({'last_name': last_name}) if last_name else None
    return details

def get_key_and_secret(self):
    headers = {
        'kid': settings.SOCIAL_AUTH_APPLE_KEY_ID
    }

    payload = {
        'iss': settings.SOCIAL_AUTH_APPLE_TEAM_ID,
        'iat': timezone.now(),
        'exp': timezone.now() + timedelta(days=180),
        'aud': 'https://appleid.apple.com',
        'sub': settings.CLIENT_ID,
    }

    client_secret = jwt.encode(
        payload,
        settings.SOCIAL_AUTH_APPLE_PRIVATE_KEY,
        algorithm='ES256',
        headers=headers
    ).decode("utf-8")

    return settings.CLIENT_ID, client_secret

@staticmethod
def get_public_key(jwks_url, id_token):
    """
    Apple give public key https://appleid.apple.com/auth/keys
    Example:
        {
          "keys": [
            {
              "kty": "RSA",
              "kid": "86D88Kf",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            },
            {
              "kty": "RSA",
              "kid": "eXaunmL",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            },
            {
              "kty": "RSA",
              "kid": "AIDOPK1",
              "use": "sig",
              "alg": "RS256",
              "n": "some_key",
              "e": "AQAB"
            }
          ]
        }
    Use kid for give some parameters for public key.

    Now use by default "kid": "eXaunmL" ago get ['keys'][1]
    Decode https://jwt.io/ id_token and show what kid in HEADER
    :return: RSAAlgorithm obj
    """
    header = jwt.get_unverified_header(id_token)
    kid = header['kid']

    response = requests.get(jwks_url)
    if response.ok:
        json_data = response.json()
        for key_data in json_data['keys']:
            if key_data['kid'] == kid:
                key_json = json.dumps(key_data)
                return RSAAlgorithm.from_jwk(key_json)

        # Or used default
        keys_json = json.dumps(json_data['keys'][1])
        return RSAAlgorithm.from_jwk(keys_json)`

That's actually pretty helpful, thank you so much! but that means that the default backend "apple-id" didn't worke for you, right?

ramonyaskal commented 4 years ago

@MiltonMilton when I doing this the default backend "apple-id" did not yet exist in this lib. You can debug default backend)

kierandesmond commented 4 years ago

Hi all,

Has this been resolved? Been trying the default apple-id version though it doe's not seem to work. Keep getting model errors. Its working great for Facebook and google logins. If you implemented, which of the solutions above did you use?

Many Thanks

jfbeltran97 commented 4 years ago

I'm getting this error all of a sudden (it was working fine previously): Traceback (most recent call last): File "/env/lib/python3.6/site-packages/social_core/backends/apple.py", line 111, in decode_id_token audience=self.setting("CLIENT"), algorithm="RS256",) File "/env/lib/python3.6/site-packages/jwt/api_jwt.py", line 105, in decode self._validate_claims(payload, merged_options, **kwargs) File "/env/lib/python3.6/site-packages/jwt/api_jwt.py", line 141, in _validate_claims self._validate_aud(payload, audience) File "/env/lib/python3.6/site-packages/jwt/api_jwt.py", line 190, in _validate_aud raise InvalidAudienceError('Invalid audience') jwt.exceptions.InvalidAudienceError: Invalid audience

During handling of the above exception, another exception occurred: File "/env/lib/python3.6/site-packages/social_core/backends/apple.py", line 146, in do_auth decoded_data = self.decode_id_token(jwt_string) File "/env/lib/python3.6/site-packages/social_core/backends/apple.py", line 113, in decode_id_token raise AuthCanceled("Token validation failed") social_core.exceptions.AuthCanceled: Authentication process canceled

adorum commented 3 years ago

@jfbeltran97 I am getting the same error. Have you found a solution? Thanks.

ramonyaskal commented 3 years ago

You have set the right audience?

adorum commented 3 years ago

My django API server allows to login with social access token. In case of SIWA I was sending wrong token from iOS to django API server. Now I am sending the corrent JWT identity token and it works as expected. Only issue I am facing is that after I sign in iOS with apple id at first time, at receice all necessarry information about the user (email, first name, last name). But after I send the identify token to API server, apple-id backend of python-social-auth cant get the first name and last name of logged in user. I read that Apple responds with user details only at first login. Any subsequest login response does not contains user details because of security or what...So my django API server can not get the user name when user is sign up from iOS device.

jfbeltran97 commented 3 years ago

@ramonyaskal @adorum I haven't made any changes and as I said, it was working fine previously. I thought maybe it was a problem with the apple servers.

lhwangweb commented 3 years ago

Hi @jfbeltran97 @adorum

Share my case for your reference:

Perhaps check your SOCIAL_AUTH_APPLE_ID_CLIENT

First, make sure it is the correct Service IDs.

If yes, check your social-auth-core version and your SOCIAL_AUTH_APPLE_ID_CLIENT format, string or list or else?

SOCIAL_AUTH_APPLE_ID_CLIENT is the parameter for audience of JWT decode (Refer social-auth-core's source code site-packages/social_core/backends/apple.py def decode_id_token() if you are interested it)

In social-auth-core<=3.3.3, It use SOCIAL_AUTH_APPLE_ID_CLIENT to do audience, and it allow you set string or list

In social-auth-core>=3.4.0, It use SOCIAL_AUTH_APPLE_ID_AUDIENCE or SOCIAL_AUTH_APPLE_ID_CLIENT.

If SOCIAL_AUTH_APPLE_ID_AUDIENCE existed, package use it to do audience, and allow you set string or list If SOCIAL_AUTH_APPLE_ID_AUDIENCE empty, it will use [ SOCIAL_AUTH_APPLE_ID_CLIENT , ] to do audience.

Yes, SOCIAL_AUTH_APPLE_ID_CLIENT will be put into a list. In other words, you can't set list for it. It will be converted to a nested list and of course has error 'Invalid audience'. (Refer social-auth-core's source code site-packages/social_core/backends/apple.py def get_audience() if you are interested it)

In my case, I set SOCIAL_AUTH_APPLE_ID_CLIENT = ["com.aaa", "com.bbb", "com.ccc"] in previous version and fail after 3.4.0.

I use SOCIAL_AUTH_APPLE_ID_AUDIENCE now, and social-auth-core is 4.0.2. It's OK

FYI