diff --git a/ChangeLog.rst b/ChangeLog.rst index cf7ffa00d6..2a889563b7 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -8,6 +8,12 @@ Note worthy changes configure a different scope per app by including ``"scope": [...]`` in the app settings. +- Facebook login: Facebook `Limited Login + `_ is now + supported via the Headless API. When you have a Limited Login JWT obtained + from the iOS SDK, you can use the Headless "provider token" flow to login with + it. + Fixes ----- diff --git a/allauth/socialaccount/conftest.py b/allauth/socialaccount/conftest.py index abe791e9f3..45d1997cd9 100644 --- a/allauth/socialaccount/conftest.py +++ b/allauth/socialaccount/conftest.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager +from unittest.mock import patch + import pytest from allauth.account.models import EmailAddress @@ -33,3 +36,31 @@ def factory( return sociallogin return factory + + +@pytest.fixture +def jwt_decode_bypass(): + @contextmanager + def f(jwt_data): + with patch("allauth.socialaccount.internal.jwtkit.verify_and_decode") as m: + data = { + "iss": "https://accounts.google.com", + "aud": "client_id", + "sub": "123sub", + "hd": "example.com", + "email": "raymond@example.com", + "email_verified": True, + "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q", + "name": "Raymond Penners", + "picture": "https://lh5.googleusercontent.com/photo.jpg", + "given_name": "Raymond", + "family_name": "Penners", + "locale": "en", + "iat": 123, + "exp": 456, + } + data.update(jwt_data) + m.return_value = data + yield + + return f diff --git a/allauth/socialaccount/providers/facebook/flows.py b/allauth/socialaccount/providers/facebook/flows.py index b46f61876f..86a66c37ae 100644 --- a/allauth/socialaccount/providers/facebook/flows.py +++ b/allauth/socialaccount/providers/facebook/flows.py @@ -3,14 +3,27 @@ from datetime import timedelta from django.core.cache import cache +from django.http import HttpRequest from django.utils import timezone from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.internal import jwtkit from allauth.socialaccount.models import SocialLogin, SocialToken from allauth.socialaccount.providers.base import Provider from allauth.socialaccount.providers.facebook.constants import GRAPH_API_URL +# maps fields from the Limited Login JWT to Graph API response fields +JWT_FIELD_TO_GRAPH_API_FIELD_MAP = { + "sub": "id", + "email": "email", + "given_name": "first_name", + "family_name": "last_name", + "name": "name", + "user_link": "link", +} + + def compute_appsecret_proof(app, token): # Generate an appsecret_proof parameter to secure the Graph API call # see https://developers.facebook.com/docs/graph-api/securing-requests @@ -135,3 +148,33 @@ def verify_token( login = complete_login(request, provider, token) login.token = token return login + + +def verify_limited_login_token( + request: HttpRequest, provider, id_token: str +) -> SocialLogin: + """ + Verifies a Facebook Limited Login token. + See https://developers.facebook.com/docs/facebook-login/limited-login/token/validating. + + We validate the JWT, then convert its data/claims into + a fake Facebook Graph API response, which is then passed to + `provider.sociallogin_from_response` to be handled as normal. + """ + + # note: this already does replay protection internally + jwt_data = jwtkit.verify_and_decode( + credential=id_token, + keys_url=provider.limited_login_jwks_url, + issuer=provider.limited_login_expected_jwt_issuer, + audience=provider.app.client_id, + lookup_kid=jwtkit.lookup_kid_jwk, + ) + + fake_response = { + graph_field: jwt_data[jwt_field] + for jwt_field, graph_field in JWT_FIELD_TO_GRAPH_API_FIELD_MAP.items() + if jwt_field in jwt_data + } + + return provider.sociallogin_from_response(request, fake_response) diff --git a/allauth/socialaccount/providers/facebook/provider.py b/allauth/socialaccount/providers/facebook/provider.py index 8b4265c23c..88078e5527 100644 --- a/allauth/socialaccount/providers/facebook/provider.py +++ b/allauth/socialaccount/providers/facebook/provider.py @@ -56,6 +56,13 @@ class FacebookProvider(OAuth2Provider): oauth2_adapter_class = FacebookOAuth2Adapter supports_token_authentication = True + # TODO: populate these from https://www.facebook.com/.well-known/openid-configuration/ + # just like in a normal OIDC provider (as that's what "Limited Login" really is) + limited_login_expected_jwt_issuer = "https://www.facebook.com" + limited_login_jwks_url = ( + "https://limited.facebook.com/.well-known/oauth/openid/jwks/" + ) + def __init__(self, *args, **kwargs): self._locale_callable_cache = None super().__init__(*args, **kwargs) @@ -204,17 +211,30 @@ def extract_email_addresses(self, data): ret.append(EmailAddress(email=email, verified=False, primary=True)) return ret - def verify_token(self, request, token): + def verify_token(self, request, token: dict): + """ + Verifies both normal oAuth2-style "access_token"s as well + as OIDC-style "Limited Login" JWTs. + + Limited Login is an OIDC-based form of Facebook Login + that their iOS SDK uses when App Tracking Transparency consent is denied. + """ from allauth.socialaccount.providers.facebook import flows access_token = token.get("access_token") - if not access_token: + id_token = token.get("id_token") + + if not any([access_token, id_token]): raise get_adapter().validation_error("invalid_token") + try: - login = flows.verify_token(request, self, access_token) + if access_token: + return flows.verify_token(request, self, access_token) + else: + assert id_token + return flows.verify_limited_login_token(request, self, id_token) except (OAuth2Error, requests.RequestException) as e: raise get_adapter().validation_error("invalid_token") from e - return login provider_classes = [FacebookProvider] diff --git a/allauth/socialaccount/providers/facebook/tests.py b/allauth/socialaccount/providers/facebook/tests.py index 85ded41394..4f5b5b9ae9 100644 --- a/allauth/socialaccount/providers/facebook/tests.py +++ b/allauth/socialaccount/providers/facebook/tests.py @@ -5,6 +5,7 @@ from allauth.account import app_settings as account_settings from allauth.account.models import EmailAddress +from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.tests import OAuth2TestsMixin from allauth.tests import MockedResponse, TestCase, mocked_response @@ -163,3 +164,28 @@ def test_login_unverified(self): def _login_verified(self): self.login(self.get_mocked_response()) return EmailAddress.objects.get(email="raymond.penners@example.com") + + +def test_limited_token(rf, db, settings, jwt_decode_bypass): + settings.SOCIALACCOUNT_PROVIDERS = { + "facebook": { + "AUTH_PARAMS": {}, + "VERIFIED_EMAIL": False, + "APPS": [{"client_id": "123"}], + } + } + request = rf.get("/") + adapter = get_adapter(request) + provider = adapter.get_provider(request, FacebookProvider.id) + token = {"id_token": "X"} + with jwt_decode_bypass( + { + "sub": "f123", + "email": "e@mail.org", + "given_name": "John", + "family_name": "Doe", + } + ): + login = provider.verify_token(request, token) + assert login.account.uid == "f123" + assert login.email_addresses[0].email == "e@mail.org" diff --git a/docs/socialaccount/providers/facebook.rst b/docs/socialaccount/providers/facebook.rst index f5a83cc67a..e25b73b5bd 100644 --- a/docs/socialaccount/providers/facebook.rst +++ b/docs/socialaccount/providers/facebook.rst @@ -1,8 +1,9 @@ Facebook -------- -For Facebook both OAuth2 and the Facebook Connect Javascript SDK are -supported. You can even mix the two. +For Facebook both OAuth2, Facebook Connect Javascript SDK and even +`Limited Login `_ +are supported. You can even mix and match. An advantage of the Javascript SDK may be a more streamlined user experience as you do not leave your site. Furthermore, you do not need @@ -124,3 +125,12 @@ Development callback URL Leave your App Domains empty and put ``http://localhost:8000`` in the section labeled ``Website with Facebook Login``. Note that you'll need to add your site's actual domain to this section once it goes live. + +For Limited Login, it is exclusively supported via the Headless API's "provider +token" flow. + +Pass your Limited Login JWT (obtained from the Facebook iOS SDK) to that +endpoint as an ``id_token``. + +Note that Limited Login is purely used for login and does not allow access to +the user's Facebook account - no ``SocialToken`` is created.