pennersr / django-allauth

Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication.
https://allauth.org
MIT License
8.95k stars 2.96k forks source link

Apple SSO: switch between service ID and bundle ID as the client ID? #2718

Closed lee-hodg closed 1 week ago

lee-hodg commented 3 years ago

For the Apple SSO, there are 2 Client IDs, a Bundle ID and a Services ID. When the flow is started from a mobile iOS device the app would use the Bundle ID as the Client ID, but when it is started from a web app such as react the authorization flow would be started with the Service ID as the Client ID.

How does the django-allauth Apple provider handle these 2 different Client ID?

There are some comments in the PR thread https://github.com/pennersr/django-allauth/pull/2424 that suggest setting up SOCIALPROVIDERS using a comma-delimited string of the 2 client IDs, like Client id = <APPLE_SERVICE_ID>, <APPLE_APP_ID> (https://github.com/pennersr/django-allauth/pull/2424#issuecomment-670597679) however looking at allauth/socialaccount/providers/apple/client.py shows that always the first client ID in the string would be used anyway

    def get_client_id(self):
        """ We support multiple client_ids, but use the first one for api calls """
        return self.consumer_key.split(",")[0]

This means if the Service ID is the first in the settings, then it would ALWAYS be the one used.

If iOS initiated the auth flow therefore with the Bundle ID as the client ID, then the backend (allauth) tries to use the Service ID, it will fail with invalid_id because of the mismatch. If however the Bundle ID was first in the settings string, then things would work for iOS, but would fail for flows started by web (react) because web would use the Service ID, but backend would use the Bundle ID.

By what mechanism is allauth intending to cope with these 2 disparate IDs?

Maybe the identity token from the auth response should be used to derive the sub (subject claim) so it always matches the requesting client id, rather than just taking the first client id in the settings list?

julianpacker commented 2 years ago

Any chance you figured anything out here?

blablacio commented 2 years ago

For anyone still wondering: you need to have client ID for web listed first before the one for native.

Web always picks the first one, but for native all of them are checked against the aud field in the id_token.

benrychter commented 2 years ago

@blablacio What do you mean "native all of them are checked against the aud field in the id_token"? We have that issue in our project but the endpoint for web and in our case react-native apps is exactly the same. After signup/login by Apple we are sending request to Django server. And always there is an error of 2 different ID's because react-native is using a different one and web different as well.

I've changed that as you suggested, Web client ID is on first place: com.xxx.login, com.xxx. But now works only web, and mobile is crashing :/ If I change place of these ID's mobile is working, web is crashing :D

blablacio commented 2 years ago

Well, hard to explain why it is like that as it's hard to debug this locally, but it seems to me that the native call to the endpoint goes through get_verified_identity_data and get_client_id methods on the AppleOAuth2Adapter, while web call goes through get_client_id on the AppleOAuth2Client.

Don't quote me on that, that's just my gut feeling because get_verified_identity_data and get_client_id on the AppleOAuth2Adapter checks against all the client IDs, while get_client_id on the AppleOAuth2Client just takes the first client ID.

My current configuration:

SOCIALACCOUNT_PROVIDERS = {
    'apple': {
        'APP': {
            'client_id': 'com.app.web,com.app.native',
            'key': 'key',
            'secret': 'secret',
            'certificate_key': os.getenv('APPLE_LOGIN_CERTIFICATE')
        }
    }
}
benrychter commented 2 years ago

Hmm that's very interesting. But you have created only one endpoint for both platforms, right? Because I've got same configuration basically, and this is my endpoint view for apple login, and it's not working.

class AppleLoginView(SocialLoginView):
    adapter_class = AppleOAuth2Adapter
    client_class = AppleOAuth2Client
    serializer_class = SocialLoginSerializer
blablacio commented 2 years ago

I also have only one endpoint handling both web and native.

Here's the view:

class AppleLoginView(BaseLoginView):
    adapter_class = AppleOAuth2Adapter
    client_class = AppleOAuth2Client
    callback_url = f'https://{settings.APP_DOMAIN}/signup/'

I have some extra stuff on the BaseLoginView as well as the serializer defined there. I had to define callback_url though as I was getting errors without it.

benrychter commented 2 years ago

Ok, so maybe the case is your custom BaseLoginView or Serialiser. Because on that point, all is the same, and for me, it's not working.

blablacio commented 2 years ago

Not much going on in BaseLoginView -- just handling some custom data passed. It inherits from SocialLoginView and overrides post method:

    def post(self, request, *args, **kwargs):
        self.serializer = self.get_serializer(data=self.request.data)
        self.serializer.is_valid(raise_exception=True)

        # Login user
        self.login()

        return self.get_response()

as opposed to the original SocialLoginView->LoginView:post:

    def post(self, request, *args, **kwargs):
        self.request = request
        self.serializer = self.get_serializer(data=self.request.data)
        self.serializer.is_valid(raise_exception=True)

        self.login()
        return self.get_response()

By the way, I'm using dj-rest-auth, maybe that's also something worth mentioning.

benrychter commented 2 years ago

Yeah, I've got dj-rest-auth as well. I don't know what's going on here 😄 I'm definitely missing something.

blablacio commented 2 years ago

What is the error you're getting? Might be able to help if you post more details.

benrychter commented 2 years ago
[OAuth2Error]
Error retrieving access token: b'{"error":"invalid_grant","error_description":"client_id mismatch. The code was not issued to com.app.test.login."}'

That is the situation where com.app.test.login is SERVICE_ID and I want to login via mobile app, which should use com.app.test ID. Both of ID's looks like this in settings:

com.app.test.login, com.app.test

Web first, mobile second. If I reverse order, then I can login on mobile but not on web.

yandiro commented 1 year ago
[OAuth2Error]
Error retrieving access token: b'{"error":"invalid_grant","error_description":"client_id mismatch. The code was not issued to com.app.test.login."}'

That is the situation where com.app.test.login is SERVICE_ID and I want to login via mobile app, which should use com.app.test ID. Both of ID's looks like this in settings:

com.app.test.login, com.app.test

Web first, mobile second. If I reverse order, then I can login on mobile but not on web.

I (am) was facing the exact same issue! This is how I solved it:

I tried to override AppleOAuth2Adapter, but the get_client_id I override never gets called.

However, if I override the AppleOAuth2Client.get_client_id() instead, it gets called. This is how I did it

class CustomAppleOAuth2Client(AppleOAuth2Client):
    def get_client_id(self):
        mobile_client_id = os.environ.get('APPLE_CLIENT_ID').split(",")[1].strip()
        return mobile_client_id

class ApiAppleLoginView(SocialLoginView):
    adapter_class = AppleOAuth2Adapter
    client_class = CustomAppleOAuth2Client
    callback_url = f'{settings.APP_SITE_HOST}/accounts/apple/login/callback/' 

This way you can use both client_ids in your env file (or settings), just keep the web before the mobile, like you have already.

blablacio commented 1 year ago

Weird, still using the default client and adapter.

In any case, seems like your solution should work @yandiro.

yandiro commented 1 year ago

There is a new issue now :man_facepalming:, users that sign in with the mobile app are not recognised by the web app. I think it may be because they are using different client_ids, since the web app uses a service ID and the mobile app uses an App ID. How are you dealing with this, @blablacio ? Would you mind sharing?

Cheers!

benrychter commented 1 year ago

@yandiro That's very weird behavior. I have a very similar solution to yours - custom AppleOAuth2Client and AppleOAuth2Adapter. Everything is working great now.

class WebAppleOAuth2Client(AppleOAuth2Client):
    def get_client_id(self):
        return settings.APPLE_CLIENT_ID

class WebAppleOAuth2Adapter(AppleOAuth2Adapter):
    def get_client_id(self, provider):
        return settings.APPLE_CLIENT_ID

Are you sure that on developer.apple.com in Web Authentication Configuration you have selected the correct Primary App ID?

yandiro commented 1 year ago

Thanks, @brychter-merix!

There is only one issue remaining, when the user is created using the mobile app, the name doesn't seem to come. The mobile team say they are sending the correct scope, though.

So the remaining question is, what does my SocialLoginView Class expect to receive in the request? The mobile app is only sending this to the backend:

{
  "code": <authorizationCode returned by Apple>
}

Do they need to send the name too?

benrychter commented 1 year ago

On the mobile app, on the request, we are including beside code - idToken which is nonce variable coming from Apple Native response object. But we are using React Native and react-native-apple-authentication package for that.

blablacio commented 1 year ago

@yandiro and @brychter-merix I was thinking that the issue might be the data you supply from native and/or web.

So here's what we're doing:

Hope that helps you to figure it out.

aehlke commented 10 months ago

Is the latest advice here working stably for everyone?

JohnsonMasino commented 4 months ago

Please guys... I have a question. Must I open a membership account at apple developer site to obtain client secret and all the necessary IDs to implement apple sign-in in my django api project ? They are asking me to pay the sum of $99 to able to register a developer account. Is there a way I can override this payment ?

aehlke commented 4 months ago

@JohnsonMasino https://developer.apple.com/support/compare-memberships/ looks like it's required. Apple is a trash monopolist company sadly.

JohnsonMasino commented 4 months ago

@aehlke Yes it is ! I made my research and figured I must enroll. Its fine.

pennersr commented 1 week ago

For those using dj-rest-auth, you can work around this and configure separate apps, as follows:

# settings.py
SOCIALACCOUNT_PROVIDERS = {
    "apple": {
        "APPS": [
            {"client_id": "client1", ...},
            {"client_id": "client2", ...},
        ]
    }
}
APPLE_MOBILE_CLIENT_ID = "client1"
APPLE_WEB_CLIENT_ID = "client2"

# Custom Adapter
from allauth.core import context

class MySocialAccountAdapter(DefaultSocialAccountAdapter):
    def list_apps(self, request, provider=None, client_id=None):
        if not request:
            request = context.request
        apps = super().list_apps(request=request, provider=provider, client_id=client_id)
        if provider == "apple":
            if request.path.startswith("/api"):
                apps = [app for app in apps if app.client_id == settings.APPLE_MOBILE_CLIENT_ID]
            else:
                apps = [app for app in apps if not app.client_id == settings.APPLE_WEB_CLIENT_ID]
        return apps

As for using allauth headless API, the client_id is required to be passed to the token endpoint, so it is always clear what app is used, avoiding the need for the above workaround.

https://docs.allauth.org/en/dev/headless/openapi-specification/#tag/Authentication:-Providers/paths/~1_allauth~1%7Bclient%7D~1v1~1auth~1provider~1token/post

pennersr commented 1 week ago

By what mechanism is allauth intending to cope with these 2 disparate IDs?

pennersr commented 1 week ago

Closed via 38021aea