Skip to content

Commit d02bfd3

Browse files
authored
Add GitHub App OAuth provider (#12020)
Extracted from readthedocs/common#259. The provider is not exposed to users yet, and we allow only staff users to use it (manually going to /accounts/githubapp/login). There are steps for ops team and Eric or Anthony to do: - Create a new GH app from https://github.com/organizations/readthedocs/settings/apps/new (the name will be used when we do actions as the installation, like when creating a comment). - Callback URL should be https://app.readthedocs.org/accounts/githubapp/login/callback/ - Keep marked "Expire user authorization tokens" - Don't active the webhook, since we aren't going to use it yet. - Permissions (can be updated later if required): - Repository permissions: Commit statuses (read and write, so we can create commit statuses), Contents (read only, so we can clone repos with a token), Metadata (read only, so we read the repo collaborators), Pull requests (read and write, so we can post a comment on PRs in the future). - Organization permissions: Members (read only so we can read the organization members) - Account permissions: Email addresses (read only, so allauth can fetch all verified emails) - Subscribe to events (can be updated later if required): Installation target, Member, Organization, Membership, Pull request, Push, Repository. - Where can this GitHub App be installed?: any account - Copy the client ID and client secret into ops repo for the githubapp provider, we can skip setting a webhook secret and private key, as they won't be used for now. Same process for the app for .com.
1 parent 092e2e5 commit d02bfd3

File tree

10 files changed

+295
-3
lines changed

10 files changed

+295
-3
lines changed

common

docs/dev/settings.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ providers using the following environment variables:
158158

159159
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITHUB_CLIENT_ID
160160
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITHUB_SECRET
161+
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITHUBAPP_CLIENT_ID
162+
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITHUBAPP_SECRET
161163
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITLAB_CLIENT_ID
162164
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GITLAB_SECRET
163165
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_BITBUCKET_OAUTH2_CLIENT_ID
@@ -179,4 +181,4 @@ Ethical Ads variables
179181

180182
The following variables are required to use ``ethicalads`` in dev:
181183

182-
.. envvar:: RTD_USE_PROMOS
184+
.. envvar:: RTD_USE_PROMOS

readthedocs/allauth/providers/githubapp/__init__.py

Whitespace-only changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from allauth.socialaccount.providers.github.provider import GitHubProvider
2+
3+
from readthedocs.allauth.providers.githubapp.views import GitHubAppOAuth2Adapter
4+
5+
6+
class GitHubAppProvider(GitHubProvider):
7+
"""
8+
Provider for GitHub App.
9+
10+
We subclass the GitHubProvider to have two separate providers for the GitHub OAuth App and the GitHub App.
11+
"""
12+
13+
id = "githubapp"
14+
oauth2_adapter_class = GitHubAppOAuth2Adapter
15+
16+
17+
provider_classes = [GitHubAppProvider]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Copied from allauth.socialaccount.providers.github.urls."""
2+
3+
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
4+
5+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
6+
7+
urlpatterns = default_urlpatterns(GitHubAppProvider)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Copied from allauth.socialaccount.providers.github.views."""
2+
3+
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
4+
from allauth.socialaccount.providers.oauth2.views import (
5+
OAuth2CallbackView,
6+
OAuth2LoginView,
7+
)
8+
9+
10+
class GitHubAppOAuth2Adapter(GitHubOAuth2Adapter):
11+
provider_id = "githubapp"
12+
13+
14+
oauth2_login = OAuth2LoginView.adapter_view(GitHubAppOAuth2Adapter)
15+
oauth2_callback = OAuth2CallbackView.adapter_view(GitHubAppOAuth2Adapter)

readthedocs/core/adapters.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22

33
import structlog
44
from allauth.account.adapter import DefaultAccountAdapter
5+
from allauth.account.adapter import get_adapter as get_account_adapter
6+
from allauth.exceptions import ImmediateHttpResponse
57
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
8+
from allauth.socialaccount.models import SocialAccount
9+
from allauth.socialaccount.providers.github.provider import GitHubProvider
10+
from django.contrib import messages
11+
from django.http import HttpResponseRedirect
12+
from django.urls import reverse
613
from django.utils.encoding import force_str
714

15+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
816
from readthedocs.core.utils import send_email_from_object
917
from readthedocs.invitations.models import Invitation
1018

@@ -54,6 +62,10 @@ def save_user(self, request, user, form, commit=True):
5462

5563
class SocialAccountAdapter(DefaultSocialAccountAdapter):
5664
def pre_social_login(self, request, sociallogin):
65+
self._filter_email_addresses(sociallogin)
66+
self._connect_github_app_to_existing_github_account(request, sociallogin)
67+
68+
def _filter_email_addresses(self, sociallogin):
5769
"""
5870
Remove all email addresses except the primary one.
5971
@@ -65,3 +77,59 @@ def pre_social_login(self, request, sociallogin):
6577
sociallogin.email_addresses = [
6678
email for email in sociallogin.email_addresses if email.primary
6779
]
80+
81+
def _connect_github_app_to_existing_github_account(self, request, sociallogin):
82+
"""
83+
Connect a GitHub App (new integration) account to an existing GitHub account (old integration).
84+
85+
When a user signs up with the GitHub App we check if there is an existing GitHub account,
86+
and if it belongs to the same user, we connect the accounts instead of creating a new one.
87+
"""
88+
provider = sociallogin.account.get_provider()
89+
90+
# If the provider is not GitHub App, nothing to do.
91+
if provider.id != GitHubAppProvider.id:
92+
return
93+
94+
# If the user already signed up with the GitHub App, nothing to do.
95+
if sociallogin.is_existing:
96+
return
97+
98+
social_account = SocialAccount.objects.filter(
99+
provider=GitHubProvider.id,
100+
uid=sociallogin.account.uid,
101+
).first()
102+
103+
# If there is an existing GH account, we check if that user can use the GH App,
104+
# otherwise we check for the current user.
105+
user_to_check = social_account.user if social_account else request.user
106+
if not self._can_use_github_app(user_to_check):
107+
raise ImmediateHttpResponse(HttpResponseRedirect(reverse("account_login")))
108+
109+
# If there isn't an existing GH account, nothing to do,
110+
# just let allauth create the new account.
111+
if not social_account:
112+
return
113+
114+
# If the user is logged in, and the GH OAuth account belongs to
115+
# a different user, we should not connect the accounts,
116+
# this is the same as trying to connect an existing GH account to another user.
117+
if request.user.is_authenticated and request.user != social_account.user:
118+
message_template = "socialaccount/messages/account_connected_other.txt"
119+
get_account_adapter(request).add_message(
120+
request=request,
121+
level=messages.ERROR,
122+
message_template=message_template,
123+
)
124+
url = reverse("socialaccount_connections")
125+
raise ImmediateHttpResponse(HttpResponseRedirect(url))
126+
127+
sociallogin.connect(request, social_account.user)
128+
129+
def _can_use_github_app(self, user):
130+
"""
131+
Check if the user can use the GitHub App.
132+
133+
Only staff users can use the GitHub App for now.
134+
"""
135+
return user.is_staff
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from unittest import mock
2+
3+
from allauth.exceptions import ImmediateHttpResponse
4+
from allauth.socialaccount.adapter import get_adapter as get_social_account_adapter
5+
from allauth.socialaccount.models import SocialAccount, SocialLogin
6+
from allauth.socialaccount.providers.github.provider import GitHubProvider
7+
from django.contrib.auth.models import AnonymousUser, User
8+
from django.test import TestCase
9+
from django_dynamic_fixture import get
10+
11+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
12+
13+
14+
class SocialAdapterTest(TestCase):
15+
def setUp(self):
16+
self.user = get(User, username="test")
17+
self.adapter = get_social_account_adapter()
18+
19+
def test_dont_allow_using_githubapp_for_non_staff_users(self):
20+
assert not SocialAccount.objects.filter(provider=GitHubAppProvider.id).exists()
21+
22+
# Anonymous user
23+
request = mock.MagicMock(user=AnonymousUser())
24+
sociallogin = SocialLogin(
25+
user=User(email="me@example.com"),
26+
account=SocialAccount(provider=GitHubAppProvider.id),
27+
)
28+
with self.assertRaises(ImmediateHttpResponse) as exc:
29+
self.adapter.pre_social_login(request, sociallogin)
30+
self.assertEqual(exc.exception.response.status_code, 302)
31+
32+
assert not SocialAccount.objects.filter(provider=GitHubAppProvider.id).exists()
33+
34+
# Existing non-staff user
35+
assert not self.user.is_staff
36+
request = mock.MagicMock(user=self.user)
37+
sociallogin = SocialLogin(
38+
user=User(email="me@example.com"),
39+
account=SocialAccount(provider=GitHubAppProvider.id),
40+
)
41+
with self.assertRaises(ImmediateHttpResponse) as exc:
42+
self.adapter.pre_social_login(request, sociallogin)
43+
self.assertEqual(exc.exception.response.status_code, 302)
44+
assert not self.user.socialaccount_set.filter(
45+
provider=GitHubAppProvider.id
46+
).exists()
47+
48+
def test_allow_using_githubapp_for_staff_users(self):
49+
self.user.is_staff = True
50+
self.user.save()
51+
assert self.user.is_staff
52+
53+
request = mock.MagicMock(user=self.user)
54+
sociallogin = SocialLogin(
55+
user=User(email="me@example.com"),
56+
account=SocialAccount(provider=GitHubAppProvider.id),
57+
)
58+
self.adapter.pre_social_login(request, sociallogin)
59+
# No exception raised, but the account is not created, as that is done in another step by allauth.
60+
assert not self.user.socialaccount_set.filter(
61+
provider=GitHubAppProvider.id
62+
).exists()
63+
64+
def test_connect_to_existing_github_account_from_staff_user(self):
65+
self.user.is_staff = True
66+
self.user.save()
67+
assert self.user.is_staff
68+
assert not self.user.socialaccount_set.filter(
69+
provider=GitHubAppProvider.id
70+
).exists()
71+
72+
github_account = get(
73+
SocialAccount,
74+
provider=GitHubProvider.id,
75+
uid="1234",
76+
user=self.user,
77+
)
78+
79+
request = mock.MagicMock(user=AnonymousUser())
80+
sociallogin = SocialLogin(
81+
user=User(email="me@example.com"),
82+
account=SocialAccount(
83+
provider=GitHubAppProvider.id, uid=github_account.uid
84+
),
85+
)
86+
self.adapter.pre_social_login(request, sociallogin)
87+
# A new user is not created, but the existing user is connected to the GitHub App.
88+
assert self.user.socialaccount_set.filter(
89+
provider=GitHubAppProvider.id
90+
).exists()
91+
92+
def test_connect_to_existing_github_account_from_staff_user_logged_in(self):
93+
self.user.is_staff = True
94+
self.user.save()
95+
assert self.user.is_staff
96+
assert not self.user.socialaccount_set.filter(
97+
provider=GitHubAppProvider.id
98+
).exists()
99+
100+
github_account = get(
101+
SocialAccount,
102+
provider=GitHubProvider.id,
103+
uid="1234",
104+
user=self.user,
105+
)
106+
107+
request = mock.MagicMock(user=self.user)
108+
sociallogin = SocialLogin(
109+
user=User(email="me@example.com"),
110+
account=SocialAccount(
111+
provider=GitHubAppProvider.id, uid=github_account.uid
112+
),
113+
)
114+
self.adapter.pre_social_login(request, sociallogin)
115+
# A new user is not created, but the existing user is connected to the GitHub App.
116+
assert self.user.socialaccount_set.filter(
117+
provider=GitHubAppProvider.id
118+
).exists()
119+
120+
def test_dont_connect_to_existing_github_account_if_user_is_logged_in_with_different_account(
121+
self,
122+
):
123+
self.user.is_staff = True
124+
self.user.save()
125+
assert self.user.is_staff
126+
assert not self.user.socialaccount_set.filter(
127+
provider=GitHubAppProvider.id
128+
).exists()
129+
130+
github_account = get(
131+
SocialAccount,
132+
provider=GitHubProvider.id,
133+
uid="1234",
134+
user=self.user,
135+
)
136+
137+
another_user = get(User, username="another")
138+
request = mock.MagicMock(user=another_user)
139+
sociallogin = SocialLogin(
140+
user=User(email="me@example.com"),
141+
account=SocialAccount(
142+
provider=GitHubAppProvider.id, uid=github_account.uid
143+
),
144+
)
145+
with self.assertRaises(ImmediateHttpResponse) as exc:
146+
self.adapter.pre_social_login(request, sociallogin)
147+
self.assertEqual(exc.exception.response.status_code, 302)
148+
assert not self.user.socialaccount_set.filter(
149+
provider=GitHubAppProvider.id
150+
).exists()
151+
assert not another_user.socialaccount_set.filter(
152+
provider=GitHubAppProvider.id
153+
).exists()
154+
155+
def test_allow_existing_githubapp_accounts_to_login(self):
156+
assert not self.user.is_staff
157+
githubapp_account = get(
158+
SocialAccount,
159+
provider=GitHubAppProvider.id,
160+
uid="1234",
161+
user=self.user,
162+
)
163+
164+
request = mock.MagicMock(user=AnonymousUser())
165+
sociallogin = SocialLogin(
166+
user=self.user,
167+
account=githubapp_account,
168+
)
169+
self.adapter.pre_social_login(request, sociallogin)
170+
171+
self.user.is_staff = True
172+
self.user.save()
173+
assert self.user.is_staff
174+
self.adapter.pre_social_login(request, sociallogin)

readthedocs/settings/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ def INSTALLED_APPS(self): # noqa
297297
"allauth.account",
298298
"allauth.socialaccount",
299299
"allauth.socialaccount.providers.github",
300+
"readthedocs.allauth.providers.githubapp",
300301
"allauth.socialaccount.providers.gitlab",
301302
"allauth.socialaccount.providers.bitbucket_oauth2",
302303
"allauth.mfa",
@@ -713,6 +714,13 @@ def DOCKER_LIMITS(self):
713714
"repo:status",
714715
],
715716
},
717+
"githubapp": {
718+
"APPS": [
719+
{"client_id": "123", "secret": "456", "key": ""},
720+
],
721+
# Scope is determined by the GitHub App permissions.
722+
"SCOPE": [],
723+
},
716724
"gitlab": {
717725
"APPS": [
718726
{"client_id": "123", "secret": "456", "key": ""},

readthedocs/templates/socialaccount/snippets/provider_list.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
{% comment %}
88
- OpenID is not implemented.
99
- SAML is handled in another view, we don't want to list all SAML integrations here.
10+
- GitHub App is not exposed to users yet.
1011
{% endcomment %}
11-
{% if provider.id != 'saml' %}
12+
{% if provider.id != 'saml' and provider.id != 'githubapp' %}
1213
{% if allowed_providers and provider.id in allowed_providers or not allowed_providers %}
1314
<li>
1415
{# Bitbucket doesn't allow more than one callback URL for their OAuth app, so we are redirecting users to the new dashboard for now. #}

0 commit comments

Comments
 (0)