This commit is contained in:
Iliyan Angelov
2025-09-14 23:24:25 +03:00
commit c67067a2a4
71311 changed files with 6800714 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
from allauth.socialaccount import app_settings
from allauth.socialaccount.providers.base import (
ProviderAccount,
ProviderException,
)
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
def _extract_name_field(data, field_name):
ret = ""
v = data.get(field_name, {})
if v:
if isinstance(v, str):
# Old V1 data
ret = v
else:
localized = v.get("localized", {})
preferred_locale = v.get(
"preferredLocale", {"country": "US", "language": "en"}
)
locale_key = "_".join(
[preferred_locale["language"], preferred_locale["country"]]
)
if locale_key in localized:
ret = localized.get(locale_key)
elif localized:
ret = next(iter(localized.values()))
return ret
def _extract_email(data):
"""
{'elements': [{'handle': 'urn:li:emailAddress:319371470',
'handle~': {'emailAddress': 'raymond.penners@intenct.nl'}}]}
"""
ret = ""
elements = data.get("elements", [])
if len(elements) > 0:
ret = elements[0].get("handle~", {}).get("emailAddress", "")
return ret
class LinkedInOAuth2Account(ProviderAccount):
def to_str(self):
ret = super(LinkedInOAuth2Account, self).to_str()
first_name = _extract_name_field(self.account.extra_data, "firstName")
last_name = _extract_name_field(self.account.extra_data, "lastName")
if first_name or last_name:
ret = " ".join([first_name, last_name]).strip()
return ret
def get_avatar_url(self):
"""
Attempts the load the avatar associated to the avatar.
Requires the `profilePicture(displayImage~:playableStreams)`
profile field configured in settings.py
:return:
"""
provider_configuration = self.account.get_provider().get_settings()
configured_profile_fields = provider_configuration.get("PROFILE_FIELDS", [])
# Can't get the avatar when this field is not specified
picture_field = "profilePicture(displayImage~:playableStreams)"
if picture_field not in configured_profile_fields:
return super(LinkedInOAuth2Account, self).get_avatar_url()
# Iterate over the fields and attempt to get it by configured size
profile_picture_config = provider_configuration.get("PROFILEPICTURE", {})
req_size = profile_picture_config.get("display_size_w_h", (100.0, 100.0))
req_auth_method = profile_picture_config.get("authorization_method", "PUBLIC")
# Iterate over the data returned by the provider
profile_elements = (
self.account.extra_data.get("profilePicture", {})
.get("displayImage~", {})
.get("elements", [])
)
for single_element in profile_elements:
if not req_auth_method == single_element["authorizationMethod"]:
continue
# Get the dimensions from the payload
image_data = (
single_element.get("data", {})
.get("com.linkedin.digitalmedia.mediaartifact.StillImage", {})
.get("displaySize", {})
)
if not image_data:
continue
width, height = image_data["width"], image_data["height"]
if not width or not height:
continue
if not width == req_size[0] or not height == req_size[1]:
continue
# Get the uri since actual size matches the requested size.
to_return = single_element.get(
"identifiers",
[
{},
],
)[
0
].get("identifier")
if to_return:
return to_return
return super(LinkedInOAuth2Account, self).get_avatar_url()
class LinkedInOAuth2Provider(OAuth2Provider):
id = "linkedin_oauth2"
# Name is displayed to ordinary users -- don't include protocol
name = "LinkedIn"
account_class = LinkedInOAuth2Account
def extract_uid(self, data):
if "id" not in data:
raise ProviderException(
"LinkedIn encountered an internal error while logging in. \
Please try again."
)
return str(data["id"])
def get_profile_fields(self):
default_fields = [
"id",
"firstName",
"lastName",
# This would be needed to in case you need access to the image
# URL. Not enabling this by default due to the amount of data
# returned.
#
# 'profilePicture(displayImage~:playableStreams)'
]
fields = self.get_settings().get("PROFILE_FIELDS", default_fields)
return fields
def get_default_scope(self):
scope = ["r_liteprofile"]
if app_settings.QUERY_EMAIL:
scope.append("r_emailaddress")
return scope
def extract_common_fields(self, data):
return dict(
first_name=_extract_name_field(data, "firstName"),
last_name=_extract_name_field(data, "lastName"),
email=_extract_email(data),
)
provider_classes = [LinkedInOAuth2Provider]

View File

@@ -0,0 +1,540 @@
# -*- coding: utf-8 -*-
from json import loads
from django.test.utils import override_settings
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.providers.base import ProviderException
from allauth.socialaccount.tests import OAuth2TestsMixin
from allauth.tests import MockedResponse, TestCase
from .provider import LinkedInOAuth2Provider
class LinkedInOAuth2Tests(OAuth2TestsMixin, TestCase):
provider_id = LinkedInOAuth2Provider.id
def get_mocked_response(self):
return MockedResponse(
200,
"""
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
}
}
""",
)
def test_data_to_str(self):
data = {
"emailAddress": "john@doe.org",
"firstName": "John",
"id": "a1b2c3d4e",
"lastName": "Doe",
"pictureUrl": "https://media.licdn.com/mpr/foo",
"pictureUrls": {
"_total": 1,
"values": ["https://media.licdn.com/foo"],
},
"publicProfileUrl": "https://www.linkedin.com/in/johndoe",
}
acc = SocialAccount(extra_data=data, provider="linkedin_oauth2")
self.assertEqual(acc.get_provider_account().to_str(), "John Doe")
def test_get_avatar_url_no_picture_setting(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertIsNone(acc.get_avatar_url())
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"linkedin_oauth2": {
"PROFILE_FIELDS": [
"id",
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)",
],
"PROFILEPICTURE": {
"display_size_w_h": (400, 400.0),
},
},
}
)
def test_get_avatar_url_with_setting(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertIsNone(acc.get_avatar_url())
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"linkedin_oauth2": {
"PROFILE_FIELDS": [
"id",
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)",
],
"PROFILEPICTURE": {
"display_size_w_h": (100, 100.0),
},
},
}
)
def test_get_avatar_url_with_picture(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
},
"profilePicture": {
"displayImage~": {
"elements": [
{
"authorizationMethod": "PUBLIC",
"data": {
"com.linkedin.digitalmedia.mediaartifact.StillImage": {
"storageSize": {
"height": 100,
"width": 100
},
"storageAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"displaySize": {
"height": 100.0,
"width": 100.0,
"uom": "PX"
},
"rawCodecSpec": {
"name": "jpeg",
"type": "image"
},
"displayAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"mediaType": "image/jpeg"
}
},
"artifact": "urn:li:digitalmediaMediaArtifact:avatar",
"identifiers": [
{
"identifierExpiresInSeconds": 4,
"file": "urn:li:digitalmediaFile:this-is-the-link",
"index": 0,
"identifier": "this-is-the-link",
"mediaType": "image/jpeg",
"identifierType": "EXTERNAL_URL"
}
]
}
]
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertEqual("this-is-the-link", acc.get_avatar_url())
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"linkedin_oauth2": {
"PROFILE_FIELDS": [
"id",
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)",
],
"PROFILEPICTURE": {
"display_size_w_h": (400, 400.0),
},
},
}
)
def test_get_avatar_url_size_mismatch(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
},
"profilePicture": {
"displayImage~": {
"elements": [
{
"authorizationMethod": "PUBLIC",
"data": {
"com.linkedin.digitalmedia.mediaartifact.StillImage": {
"storageSize": {
"height": 100,
"width": 100
},
"storageAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"displaySize": {
"height": 100.0,
"width": 100.0,
"uom": "PX"
},
"rawCodecSpec": {
"name": "jpeg",
"type": "image"
},
"displayAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"mediaType": "image/jpeg"
}
},
"artifact": "urn:li:digitalmediaMediaArtifact:avatar",
"identifiers": [
{
"identifierExpiresInSeconds": 4,
"file": "urn:li:digitalmediaFile:this-is-the-link",
"index": 0,
"identifier": "this-is-the-link",
"mediaType": "image/jpeg",
"identifierType": "EXTERNAL_URL"
}
]
}
]
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertIsNone(acc.get_avatar_url())
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"linkedin_oauth2": {
"PROFILE_FIELDS": [
"id",
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)",
],
"PROFILEPICTURE": {
"display_size_w_h": (400, 400.0),
},
},
}
)
def test_get_avatar_url_auth_mismatch(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
},
"profilePicture": {
"displayImage~": {
"elements": [
{
"authorizationMethod": "PRIVATE",
"data": {
"com.linkedin.digitalmedia.mediaartifact.StillImage": {
"storageSize": {
"height": 100,
"width": 100
},
"storageAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"displaySize": {
"height": 100.0,
"width": 100.0,
"uom": "PX"
},
"rawCodecSpec": {
"name": "jpeg",
"type": "image"
},
"displayAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"mediaType": "image/jpeg"
}
},
"artifact": "urn:li:digitalmediaMediaArtifact:avatar",
"identifiers": [
{
"identifierExpiresInSeconds": 4,
"file": "urn:li:digitalmediaFile:this-is-the-link",
"index": 0,
"identifier": "this-is-the-link",
"mediaType": "image/jpeg",
"identifierType": "EXTERNAL_URL"
}
]
}
]
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertIsNone(acc.get_avatar_url())
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"linkedin_oauth2": {
"PROFILE_FIELDS": [
"id",
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)",
],
"PROFILEPICTURE": {
"display_size_w_h": (100, 100),
},
},
}
)
def test_get_avatar_url_float_vs_int(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"id": "1234567",
"lastName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Penners"
}
},
"firstName": {
"preferredLocale": {
"language": "en",
"country": "US"
},
"localized": {
"en_US": "Raymond"
}
},
"profilePicture": {
"displayImage~": {
"elements": [
{
"authorizationMethod": "PUBLIC",
"data": {
"com.linkedin.digitalmedia.mediaartifact.StillImage": {
"storageSize": {
"height": 100,
"width": 100
},
"storageAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"displaySize": {
"height": 100.0,
"width": 100.0,
"uom": "PX"
},
"rawCodecSpec": {
"name": "jpeg",
"type": "image"
},
"displayAspectRatio": {
"heightAspect": 1.0,
"formatted": "1.00:1.00",
"widthAspect": 1.0
},
"mediaType": "image/jpeg"
}
},
"artifact": "urn:li:digitalmediaMediaArtifact:avatar",
"identifiers": [
{
"identifierExpiresInSeconds": 4,
"file": "urn:li:digitalmediaFile:this-is-the-link",
"index": 0,
"identifier": "this-is-the-link",
"mediaType": "image/jpeg",
"identifierType": "EXTERNAL_URL"
}
]
}
]
}
}
}
"""
acc = SocialAccount(
extra_data=loads(extra_data),
provider="linkedin_oauth2",
)
self.assertEqual("this-is-the-link", acc.get_avatar_url())
def test_id_missing(self):
extra_data = """
{
"profilePicture": {
"displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd"
},
"Id": "1234567"
}
"""
self.assertRaises(
ProviderException, self.provider.extract_uid, loads(extra_data)
)

View File

@@ -0,0 +1,6 @@
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
from .provider import LinkedInOAuth2Provider
urlpatterns = default_urlpatterns(LinkedInOAuth2Provider)

View File

@@ -0,0 +1,50 @@
import requests
from allauth.socialaccount import app_settings
from allauth.socialaccount.providers.oauth2.views import (
OAuth2Adapter,
OAuth2CallbackView,
OAuth2LoginView,
)
from .provider import LinkedInOAuth2Provider
class LinkedInOAuth2Adapter(OAuth2Adapter):
provider_id = LinkedInOAuth2Provider.id
access_token_url = "https://www.linkedin.com/oauth/v2/accessToken"
authorize_url = "https://www.linkedin.com/oauth/v2/authorization"
profile_url = "https://api.linkedin.com/v2/me"
email_url = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))" # noqa
# See:
# http://developer.linkedin.com/forum/unauthorized-invalid-or-expired-token-immediately-after-receiving-oauth2-token?page=1 # noqa
access_token_method = "GET"
def complete_login(self, request, app, token, **kwargs):
extra_data = self.get_user_info(token)
return self.get_provider().sociallogin_from_response(request, extra_data)
def get_user_info(self, token):
fields = self.get_provider().get_profile_fields()
headers = {}
headers.update(self.get_provider().get_settings().get("HEADERS", {}))
headers["Authorization"] = " ".join(["Bearer", token.token])
info = {}
if app_settings.QUERY_EMAIL:
resp = requests.get(self.email_url, headers=headers)
# If this response goes wrong, that is not a blocker in order to
# continue.
if resp.ok:
info = resp.json()
url = self.profile_url + "?projection=(%s)" % ",".join(fields)
resp = requests.get(url, headers=headers)
resp.raise_for_status()
info.update(resp.json())
return info
oauth2_login = OAuth2LoginView.adapter_view(LinkedInOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(LinkedInOAuth2Adapter)