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,8 @@
from allauth.socialaccount.sessions import LoginSession
APPLE_SESSION_COOKIE_NAME = "apple-login-session"
def get_apple_session(request):
return LoginSession(request, "apple_login_session", APPLE_SESSION_COOKIE_NAME)

View File

@@ -0,0 +1,98 @@
import requests
from datetime import datetime, timedelta
from urllib.parse import parse_qsl, quote, urlencode
from django.core.exceptions import ImproperlyConfigured
import jwt
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.providers.oauth2.client import (
OAuth2Client,
OAuth2Error,
)
def jwt_encode(*args, **kwargs):
resp = jwt.encode(*args, **kwargs)
if isinstance(resp, bytes):
# For PyJWT <2
resp = resp.decode("utf-8")
return resp
class Scope(object):
EMAIL = "email"
NAME = "name"
class AppleOAuth2Client(OAuth2Client):
"""
Custom client because `Sign In With Apple`:
* requires `response_mode` field in redirect_url
* requires special `client_secret` as JWT
"""
def generate_client_secret(self):
"""Create a JWT signed with an apple provided private key"""
now = datetime.utcnow()
app = get_adapter(self.request).get_app(self.request, "apple")
if not app.key:
raise ImproperlyConfigured("Apple 'key' missing")
if not app.certificate_key:
raise ImproperlyConfigured("Apple 'certificate_key' missing")
claims = {
"iss": app.key,
"aud": "https://appleid.apple.com",
"sub": self.get_client_id(),
"iat": now,
"exp": now + timedelta(hours=1),
}
headers = {"kid": self.consumer_secret, "alg": "ES256"}
client_secret = jwt_encode(
payload=claims, key=app.certificate_key, algorithm="ES256", headers=headers
)
return client_secret
def get_client_id(self):
"""We support multiple client_ids, but use the first one for api calls"""
return self.consumer_key.split(",")[0]
def get_access_token(self, code, pkce_code_verifier=None):
url = self.access_token_url
client_secret = self.generate_client_secret()
data = {
"client_id": self.get_client_id(),
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self.callback_url,
"client_secret": client_secret,
}
if pkce_code_verifier:
data["code_verifier"] = pkce_code_verifier
self._strip_empty_keys(data)
resp = requests.request(
self.access_token_method, url, data=data, headers=self.headers
)
access_token = None
if resp.status_code in [200, 201]:
try:
access_token = resp.json()
except ValueError:
access_token = dict(parse_qsl(resp.text))
if not access_token or "access_token" not in access_token:
raise OAuth2Error("Error retrieving access token: %s" % resp.content)
return access_token
def get_redirect_url(self, authorization_url, extra_params):
params = {
"client_id": self.get_client_id(),
"redirect_uri": self.callback_url,
"response_mode": "form_post",
"scope": self.scope,
"response_type": "code id_token",
}
if self.state:
params["state"] = self.state
params.update(extra_params)
return "%s?%s" % (authorization_url, urlencode(params, quote_via=quote))

View File

@@ -0,0 +1,49 @@
from allauth.account.models import EmailAddress
from allauth.socialaccount.app_settings import QUERY_EMAIL
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
class AppleProvider(OAuth2Provider):
id = "apple"
name = "Apple"
account_class = ProviderAccount
def extract_uid(self, data):
return str(data["sub"])
def extract_common_fields(self, data):
fields = {"email": data.get("email")}
# If the name was provided
name = data.get("name")
if name:
fields["first_name"] = name.get("firstName", "")
fields["last_name"] = name.get("lastName", "")
return fields
def extract_email_addresses(self, data):
ret = []
email = data.get("email")
verified = data.get("email_verified")
if isinstance(verified, str):
verified = verified.lower() == "true"
if email:
ret.append(
EmailAddress(
email=email,
verified=verified,
primary=True,
)
)
return ret
def get_default_scope(self):
scopes = ["name"]
if QUERY_EMAIL:
scopes.append("email")
return scopes
provider_classes = [AppleProvider]

View File

@@ -0,0 +1,253 @@
import json
from datetime import datetime, timedelta
from importlib import import_module
from urllib.parse import parse_qs, urlparse
from django.conf import settings
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.http import urlencode
import jwt
from allauth.socialaccount.tests import OAuth2TestsMixin
from allauth.tests import MockedResponse, TestCase, mocked_response
from .apple_session import APPLE_SESSION_COOKIE_NAME
from .client import jwt_encode
from .provider import AppleProvider
# Generated on https://mkjwk.org/, used to sign and verify the apple id_token
TESTING_JWT_KEYSET = {
"p": (
"4ADzS5jKx_kdQihyOocVS0Qwwo7m0f7Ow56EadySJ-cmnwoHHF3AxgRaq-h-KwybSphv"
"dc-X7NbS79-b9dumHKyt1MeVLAsDZD1a-uQCEneY1g9LsQkscNr7OggcpvMg5UUFwv6A"
"kavu8cB0iyhNdha5_AWX27K5lNebvpaXEJ8"
),
"kty": "RSA",
"q": (
"yy5UvMjrvZyO1Os_nxXIugCa3NyWOkC8oMppPvr1Bl5AnF_xwXN2n9ozPd9Nb3Q3n-om"
"NgLayyUxhwIjWDlI67Vbx-ESuff8ZEBKuTK0Gdmr4C_QU_j0gvvNMNJweSPxDdRmIUgO"
"njTVNWmdqFTZs43jXAT4J519rgveNLAkGNE"
),
"d": (
"riPuGIDde88WS03CVbo_mZ9toFWPyTxvuz8VInJ9S1ZxULo-hQWDBohWGYwvg8cgfXck"
"cqWt5OBqNvPYdLgwb84uVi2JeEHmhcQSc_x0zfRTau5HVE2KdR-gWxQjPWoaBHeDVqwo"
"PKaU2XYxa-gYDXcuSJWHz3BX13oInDEFCXr6VwiLiwLBFsb63EEHwyWXJbTpoar7AARW"
"kz76qtngDkk4t9gk_Q0L1y1qf1GeWiAL7xWb-bdptma4-1ui-R2219-1ONEZ41v_jsIS"
"_z8ooXmVCbUsHV4Z1UDpRvpORVE3u57WK3qXUdAtZsXjaIwkdItbDmL1jFUgefwfO91Y"
"YQ"
),
"e": "AQAB",
"use": "sig",
"kid": "testkey",
"qi": (
"R0Hu4YmpHzw3SKWGYuAcAo6B97-JlN2fXiTjZ2g8eHGQX7LSoKEu0Hmu5hcBZYSgOuor"
"IPsPUu3mNtx3pjLMOaJRk34VwcYu7h23ogEKGcPUt1c4tTotFDdw8WFptDOw4ow31Tml"
"BPExLqzzGjJeQSNULB1bExuuhYMWx6wBXo8"
),
"dp": (
"WBaHlnbjZ3hDVTzqjrGIYizSr-_aPUJitPKlR6wBncd8nJYo7bLAmB4mOewXkX5HozIG"
"wuF78RsZoFLi1fAmhqgxQ7eopcU-9DBcksUPO4vkgmlJbrkYzNiQauW9vrllekOGXIQQ"
"szhVoqP4MLEMpR-Sy9S3PyItcKbJDE3T4ik"
),
"alg": "RS256",
"dq": (
"Ar5kbIw2CsBzeVKX8FkF9eUOMk9URAMdyPoSw8P1zRk2vCXbiOY7Qttad8ptLEUgfytV"
"SsNtGvMsoQsZWRak8nHnhGJ4s0QzB1OK7sdNgU_cL1HV-VxSSPaHhdJBrJEcrzggDPEB"
"KYfDHU6Iz34d1nvjBxoWE8rfqJsGbCW4xxE"
),
"n": (
"sclLPioUv4VOcOZWAKoRhcvwIH2jOhoHhSI_Cj5c5zSp7qaK8jCU6T7-GObsgrhpty-k"
"26ZuqRdgu9d-62WO8OBGt1e0wxbTh128-nTTrOESHUlV_K1wpJmXOxNpJiybcgzZNbAm"
"ACmsHfxZvN9bt7gKPXxf3-_zFAf12PbYMrOionAJ1N_4HxL7fz3xkr5C87Av06QNilIC"
"-mA-4n9Eqw_R2DYNpE3RYMdWtwKqBwJC8qs3677RpG9vcc-yZ_97pEiytd2FBJ8uoTwH"
"d3DHJB8UVgBSh1kMUpSdoM7HxVzKx732nx6Kusln79LrsfOzrXF4enkfKJYI40-uwT95"
"zw"
),
}
# Mocked version of the test data from https://appleid.apple.com/auth/keys
KEY_SERVER_RESP_JSON = json.dumps(
{
"keys": [
{
"kty": TESTING_JWT_KEYSET["kty"],
"kid": TESTING_JWT_KEYSET["kid"],
"use": TESTING_JWT_KEYSET["use"],
"alg": TESTING_JWT_KEYSET["alg"],
"n": TESTING_JWT_KEYSET["n"],
"e": TESTING_JWT_KEYSET["e"],
}
]
}
)
def sign_id_token(payload):
"""
Sign a payload as apple normally would for the id_token.
"""
signing_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(TESTING_JWT_KEYSET))
return jwt_encode(
payload,
signing_key,
algorithm="RS256",
headers={"kid": TESTING_JWT_KEYSET["kid"]},
)
@override_settings(
SOCIALACCOUNT_STORE_TOKENS=False,
SOCIALACCOUNT_PROVIDERS={
"apple": {
"APP": {
"client_id": "app123id",
"key": "apple",
"secret": "dummy",
"certificate_key": """-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2+Eybl8ojH4wB30C
3/iDkpsrxuPfs3DZ+3nHNghBOpmhRANCAAQSpo1eQ+EpNgQQyQVs/F27dkq3gvAI
28m95JEk26v64YAea5NTH56mru30RDqTKPgRVi5qRu3XGyqy3mdb8gMy
-----END PRIVATE KEY-----
""",
}
}
},
)
class AppleTests(OAuth2TestsMixin, TestCase):
provider_id = AppleProvider.id
def get_apple_id_token_payload(self):
now = datetime.utcnow()
return {
"iss": "https://appleid.apple.com",
"aud": "app123id", # Matches `setup_app`
"exp": now + timedelta(hours=1),
"iat": now,
"sub": "000313.c9720f41e9434e18987a.1218",
"at_hash": "CkaUPjk4MJinaAq6Z0tGUA",
"email": "test@privaterelay.appleid.com",
"email_verified": "true",
"is_private_email": "true",
"auth_time": 1234345345, # not converted automatically by pyjwt
}
def get_login_response_json(self, with_refresh_token=True):
"""
`with_refresh_token` is not optional for apple, so it's ignored.
"""
id_token = sign_id_token(self.get_apple_id_token_payload())
return json.dumps(
{
"access_token": "testac", # Matches OAuth2TestsMixin value
"expires_in": 3600,
"id_token": id_token,
"refresh_token": "testrt", # Matches OAuth2TestsMixin value
"token_type": "Bearer",
}
)
def get_mocked_response(self):
"""
Apple is unusual in that the `id_token` contains all the user info
so no profile info request is made. However, it does need the
public key verification, so this mocked response is the public
key request in order to verify the authenticity of the id_token.
"""
return MockedResponse(
200, KEY_SERVER_RESP_JSON, {"content-type": "application/json"}
)
def get_complete_parameters(self, auth_request_params):
"""
Add apple specific response parameters which they include in the
form_post response.
https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms
"""
params = super().get_complete_parameters(auth_request_params)
params.update(
{
"id_token": sign_id_token(self.get_apple_id_token_payload()),
"user": json.dumps(
{
"email": "private@appleid.apple.com",
"name": {
"firstName": "A",
"lastName": "B",
},
}
),
}
)
return params
def login(self, resp_mock, process="login", with_refresh_token=True):
resp = self.client.post(
reverse(self.provider.id + "_login")
+ "?"
+ urlencode(dict(process=process))
)
p = urlparse(resp["location"])
q = parse_qs(p.query)
complete_url = reverse(self.provider.id + "_callback")
self.assertGreater(q["redirect_uri"][0].find(complete_url), 0)
response_json = self.get_login_response_json(
with_refresh_token=with_refresh_token
)
with mocked_response(
MockedResponse(200, response_json, {"content-type": "application/json"}),
resp_mock,
):
resp = self.client.post(
complete_url,
data=self.get_complete_parameters(q),
)
assert reverse("apple_finish_callback") in resp.url
# Follow the redirect
resp = self.client.get(resp.url)
return resp
def test_authentication_error(self):
"""Override base test because apple posts errors"""
resp = self.client.post(
reverse(self.provider.id + "_callback"),
data={"error": "misc", "state": "testingstate123"},
)
assert reverse("apple_finish_callback") in resp.url
# Follow the redirect
resp = self.client.get(resp.url)
self.assertTemplateUsed(
resp,
"socialaccount/authentication_error.%s"
% getattr(settings, "ACCOUNT_TEMPLATE_EXTENSION", "html"),
)
def test_apple_finish(self):
resp = self.login(self.get_mocked_response())
# Check request generating the response
finish_url = reverse("apple_finish_callback")
self.assertEqual(resp.request["PATH_INFO"], finish_url)
self.assertTrue("state" in resp.request["QUERY_STRING"])
self.assertTrue("code" in resp.request["QUERY_STRING"])
# Check have cookie containing apple session
self.assertTrue(APPLE_SESSION_COOKIE_NAME in self.client.cookies)
# Session should have been cleared
apple_session_cookie = self.client.cookies.get(APPLE_SESSION_COOKIE_NAME)
engine = import_module(settings.SESSION_ENGINE)
SessionStore = engine.SessionStore
apple_login_session = SessionStore(apple_session_cookie.value)
self.assertEqual(len(apple_login_session.keys()), 0)
# Check cookie path was correctly set
self.assertEqual(apple_session_cookie.get("path"), finish_url)

View File

@@ -0,0 +1,16 @@
from django.urls import path
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
from .provider import AppleProvider
from .views import oauth2_finish_login
urlpatterns = default_urlpatterns(AppleProvider)
urlpatterns += [
path(
AppleProvider.get_slug() + "/login/callback/finish/",
oauth2_finish_login,
name="apple_finish_callback",
),
]

View File

@@ -0,0 +1,183 @@
import json
import requests
from datetime import timedelta
from django.http import HttpResponseNotAllowed, HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.utils.http import urlencode
from django.views.decorators.csrf import csrf_exempt
import jwt
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialToken
from allauth.socialaccount.providers.oauth2.client import OAuth2Error
from allauth.socialaccount.providers.oauth2.views import (
OAuth2Adapter,
OAuth2CallbackView,
OAuth2LoginView,
)
from allauth.utils import build_absolute_uri, get_request_param
from .apple_session import get_apple_session
from .client import AppleOAuth2Client
from .provider import AppleProvider
class AppleOAuth2Adapter(OAuth2Adapter):
client_class = AppleOAuth2Client
provider_id = AppleProvider.id
access_token_url = "https://appleid.apple.com/auth/token"
authorize_url = "https://appleid.apple.com/auth/authorize"
public_key_url = "https://appleid.apple.com/auth/keys"
def _get_apple_public_key(self, kid):
response = requests.get(self.public_key_url)
response.raise_for_status()
try:
data = response.json()
except json.JSONDecodeError as e:
raise OAuth2Error("Error retrieving apple public key.") from e
for d in data["keys"]:
if d["kid"] == kid:
return d
def get_public_key(self, id_token):
"""
Get the public key which matches the `kid` in the id_token header.
"""
kid = jwt.get_unverified_header(id_token)["kid"]
apple_public_key = self._get_apple_public_key(kid=kid)
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(apple_public_key))
return public_key
def get_client_id(self, provider):
app = get_adapter().get_app(request=None, provider=self.provider_id)
return [aud.strip() for aud in app.client_id.split(",")]
def get_verified_identity_data(self, id_token):
provider = self.get_provider()
allowed_auds = self.get_client_id(provider)
try:
public_key = self.get_public_key(id_token)
identity_data = jwt.decode(
id_token,
public_key,
algorithms=["RS256"],
audience=allowed_auds,
issuer="https://appleid.apple.com",
)
return identity_data
except jwt.PyJWTError as e:
raise OAuth2Error("Invalid id_token") from e
def parse_token(self, data):
token = SocialToken(
token=data["access_token"],
)
token.token_secret = data.get("refresh_token", "")
expires_in = data.get(self.expires_in_key)
if expires_in:
token.expires_at = timezone.now() + timedelta(seconds=int(expires_in))
# `user_data` is a big flat dictionary with the parsed JWT claims
# access_tokens, and user info from the apple post.
identity_data = self.get_verified_identity_data(data["id_token"])
token.user_data = {**data, **identity_data}
return token
def complete_login(self, request, app, token, **kwargs):
extra_data = token.user_data
login = self.get_provider().sociallogin_from_response(
request=request, response=extra_data
)
login.state["id_token"] = token.user_data
# We can safely remove the apple login session now
# Note: The cookie will remain, but it's set to delete on browser close
get_apple_session(request).delete()
return login
def get_user_scope_data(self, request):
user_scope_data = request.apple_login_session.get("user", "")
try:
return json.loads(user_scope_data)
except json.JSONDecodeError:
# We do not care much about user scope data as it maybe blank
# so return blank dictionary instead
return {}
def get_access_token_data(self, request, app, client):
"""We need to gather the info from the apple specific login"""
apple_session = get_apple_session(request)
# Exchange `code`
code = get_request_param(request, "code")
pkce_code_verifier = request.session.pop("pkce_code_verifier", None)
access_token_data = client.get_access_token(
code, pkce_code_verifier=pkce_code_verifier
)
id_token = access_token_data.get("id_token", None)
# In case of missing id_token in access_token_data
if id_token is None:
id_token = apple_session.store.get("id_token")
return {
**access_token_data,
**self.get_user_scope_data(request),
"id_token": id_token,
}
@csrf_exempt
def apple_post_callback(request, finish_endpoint_name="apple_finish_callback"):
"""
Apple uses a `form_post` response type, which due to
CORS/Samesite-cookie rules means this request cannot access
the request since the session cookie is unavailable.
We work around this by storing the apple response in a
separate, temporary session and redirecting to a more normal
oauth flow.
args:
finish_endpoint_name (str): The name of a defined URL, which can be
overridden in your url configuration if you have more than one
callback endpoint.
"""
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
apple_session = get_apple_session(request)
# Add regular OAuth2 params to the URL - reduces the overrides required
keys_to_put_in_url = ["code", "state", "error"]
url_params = {}
for key in keys_to_put_in_url:
value = get_request_param(request, key, "")
if value:
url_params[key] = value
# Add other params to the apple_login_session
keys_to_save_to_session = ["user", "id_token"]
for key in keys_to_save_to_session:
apple_session.store[key] = get_request_param(request, key, "")
url = build_absolute_uri(request, reverse(finish_endpoint_name))
response = HttpResponseRedirect(
"{url}?{query}".format(url=url, query=urlencode(url_params))
)
apple_session.save(response)
return response
oauth2_login = OAuth2LoginView.adapter_view(AppleOAuth2Adapter)
oauth2_callback = apple_post_callback
oauth2_finish_login = OAuth2CallbackView.adapter_view(AppleOAuth2Adapter)