plain.oauth 0.7.4__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_oauth-0.8.0/.gitignore +7 -0
- plain_oauth-0.7.4/README.md → plain_oauth-0.8.0/PKG-INFO +13 -3
- plain_oauth-0.8.0/README.md +1 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/README.md +0 -2
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/staff.py +2 -2
- plain_oauth-0.8.0/provider_examples/__init__.py +0 -0
- plain_oauth-0.8.0/provider_examples/bitbucket.py +75 -0
- plain_oauth-0.8.0/provider_examples/github.py +101 -0
- plain_oauth-0.8.0/provider_examples/gitlab.py +57 -0
- plain_oauth-0.8.0/pyproject.toml +34 -0
- plain_oauth-0.8.0/tests/app/settings.py +49 -0
- plain_oauth-0.8.0/tests/app/templates/base.html +13 -0
- plain_oauth-0.8.0/tests/app/templates/index.html +33 -0
- plain_oauth-0.8.0/tests/app/templates/login.html +17 -0
- plain_oauth-0.8.0/tests/app/urls.py +26 -0
- plain_oauth-0.8.0/tests/app/users/models.py +9 -0
- plain_oauth-0.8.0/tests/provider_tests/__init__.py +0 -0
- plain_oauth-0.8.0/tests/provider_tests/test_github.py +66 -0
- plain_oauth-0.8.0/tests/providers/__init__.py +0 -0
- plain_oauth-0.8.0/tests/providers/bitbucket.py +75 -0
- plain_oauth-0.8.0/tests/providers/github.py +101 -0
- plain_oauth-0.8.0/tests/providers/gitlab.py +57 -0
- plain_oauth-0.8.0/tests/test_backends.py +61 -0
- plain_oauth-0.8.0/tests/test_checks.py +66 -0
- plain_oauth-0.8.0/tests/test_providers.py +485 -0
- plain_oauth-0.8.0/uv.lock +337 -0
- plain_oauth-0.7.4/PKG-INFO +0 -326
- plain_oauth-0.7.4/pyproject.toml +0 -37
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/LICENSE +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/__init__.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/config.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/default_settings.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/exceptions.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/0001_initial.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/0002_alter_oauthconnection_options_and_more.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/0003_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/0004_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/0005_alter_oauthconnection_unique_together_and_more.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/migrations/__init__.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/models.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/providers.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/templates/oauth/error.html +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/urls.py +0 -0
- {plain_oauth-0.7.4 → plain_oauth-0.8.0}/plain/oauth/views.py +0 -0
@@ -1,6 +1,16 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: plain.oauth
|
3
|
+
Version: 0.8.0
|
4
|
+
Summary: OAuth login and API access for Plain.
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
|
+
License-Expression: BSD-3-Clause
|
7
|
+
License-File: LICENSE
|
8
|
+
Requires-Python: >=3.11
|
9
|
+
Requires-Dist: plain-auth<1.0.0
|
10
|
+
Requires-Dist: plain-models<1.0.0
|
11
|
+
Requires-Dist: plain<1.0.0
|
12
|
+
Requires-Dist: requests>=2.0.0
|
13
|
+
Description-Content-Type: text/markdown
|
4
14
|
|
5
15
|
# plain.oauth
|
6
16
|
|
@@ -0,0 +1 @@
|
|
1
|
+
./plain/oauth/README.md
|
@@ -3,7 +3,7 @@ from plain.staff.cards import ChartCard
|
|
3
3
|
from plain.staff.views import (
|
4
4
|
StaffModelDetailView,
|
5
5
|
StaffModelListView,
|
6
|
-
|
6
|
+
StaffViewset,
|
7
7
|
register_viewset,
|
8
8
|
)
|
9
9
|
|
@@ -34,7 +34,7 @@ class ProvidersChartCard(ChartCard):
|
|
34
34
|
|
35
35
|
|
36
36
|
@register_viewset
|
37
|
-
class OAuthConnectionViewset(
|
37
|
+
class OAuthConnectionViewset(StaffViewset):
|
38
38
|
class ListView(StaffModelListView):
|
39
39
|
nav_section = "OAuth"
|
40
40
|
model = OAuthConnection
|
File without changes
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import requests
|
4
|
+
|
5
|
+
from plain.oauth.providers import OAuthProvider, OAuthToken, OAuthUser
|
6
|
+
from plain.utils import timezone
|
7
|
+
|
8
|
+
|
9
|
+
class BitbucketOAuthProvider(OAuthProvider):
|
10
|
+
authorization_url = "https://bitbucket.org/site/oauth2/authorize"
|
11
|
+
|
12
|
+
def _get_token(self, request_data):
|
13
|
+
response = requests.post(
|
14
|
+
"https://bitbucket.org/site/oauth2/access_token",
|
15
|
+
auth=(self.get_client_id(), self.get_client_secret()),
|
16
|
+
headers={
|
17
|
+
"Accept": "application/json",
|
18
|
+
},
|
19
|
+
data=request_data,
|
20
|
+
)
|
21
|
+
response.raise_for_status()
|
22
|
+
data = response.json()
|
23
|
+
return OAuthToken(
|
24
|
+
access_token=data["access_token"],
|
25
|
+
refresh_token=data["refresh_token"],
|
26
|
+
access_token_expires_at=timezone.now()
|
27
|
+
+ datetime.timedelta(seconds=data["expires_in"]),
|
28
|
+
)
|
29
|
+
|
30
|
+
def get_oauth_token(self, *, code, request):
|
31
|
+
return self._get_token(
|
32
|
+
{
|
33
|
+
"grant_type": "authorization_code",
|
34
|
+
"code": code,
|
35
|
+
"redirect_uri": self.get_callback_url(request=request),
|
36
|
+
}
|
37
|
+
)
|
38
|
+
|
39
|
+
def refresh_oauth_token(self, *, oauth_token):
|
40
|
+
return self._get_token(
|
41
|
+
{
|
42
|
+
"grant_type": "refresh_token",
|
43
|
+
"refresh_token": oauth_token.refresh_token,
|
44
|
+
}
|
45
|
+
)
|
46
|
+
|
47
|
+
def get_oauth_user(self, *, oauth_token):
|
48
|
+
response = requests.get(
|
49
|
+
"https://api.bitbucket.org/2.0/user",
|
50
|
+
headers={
|
51
|
+
"Authorization": f"Bearer {oauth_token.access_token}",
|
52
|
+
},
|
53
|
+
)
|
54
|
+
response.raise_for_status()
|
55
|
+
user_id = response.json()["uuid"]
|
56
|
+
username = response.json()["username"]
|
57
|
+
|
58
|
+
response = requests.get(
|
59
|
+
"https://api.bitbucket.org/2.0/user/emails",
|
60
|
+
headers={
|
61
|
+
"Authorization": f"Bearer {oauth_token.access_token}",
|
62
|
+
},
|
63
|
+
)
|
64
|
+
response.raise_for_status()
|
65
|
+
confirmed_primary_email = [
|
66
|
+
x["email"]
|
67
|
+
for x in response.json()["values"]
|
68
|
+
if x["is_primary"] and x["is_confirmed"]
|
69
|
+
][0]
|
70
|
+
|
71
|
+
return OAuthUser(
|
72
|
+
id=user_id,
|
73
|
+
email=confirmed_primary_email,
|
74
|
+
username=username,
|
75
|
+
)
|
@@ -0,0 +1,101 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import requests
|
4
|
+
|
5
|
+
from plain.oauth.exceptions import OAuthError
|
6
|
+
from plain.oauth.providers import OAuthProvider, OAuthToken, OAuthUser
|
7
|
+
from plain.utils import timezone
|
8
|
+
|
9
|
+
|
10
|
+
class GitHubOAuthProvider(OAuthProvider):
|
11
|
+
authorization_url = "https://github.com/login/oauth/authorize"
|
12
|
+
|
13
|
+
github_token_url = "https://github.com/login/oauth/access_token"
|
14
|
+
github_user_url = "https://api.github.com/user"
|
15
|
+
github_emails_url = "https://api.github.com/user/emails"
|
16
|
+
|
17
|
+
def _get_token(self, request_data):
|
18
|
+
response = requests.post(
|
19
|
+
self.github_token_url,
|
20
|
+
headers={
|
21
|
+
"Accept": "application/json",
|
22
|
+
},
|
23
|
+
data=request_data,
|
24
|
+
)
|
25
|
+
response.raise_for_status()
|
26
|
+
data = response.json()
|
27
|
+
|
28
|
+
oauth_token = OAuthToken(
|
29
|
+
access_token=data["access_token"],
|
30
|
+
)
|
31
|
+
|
32
|
+
# Expiration and refresh tokens are optional in GitHub depending on the app
|
33
|
+
if "expires_in" in data:
|
34
|
+
oauth_token.access_token_expires_at = timezone.now() + datetime.timedelta(
|
35
|
+
seconds=data["expires_in"]
|
36
|
+
)
|
37
|
+
|
38
|
+
if "refresh_token" in data:
|
39
|
+
oauth_token.refresh_token = data["refresh_token"]
|
40
|
+
|
41
|
+
if "refresh_token_expires_in" in data:
|
42
|
+
oauth_token.refresh_token_expires_at = timezone.now() + datetime.timedelta(
|
43
|
+
seconds=data["refresh_token_expires_in"]
|
44
|
+
)
|
45
|
+
|
46
|
+
return oauth_token
|
47
|
+
|
48
|
+
def get_oauth_token(self, *, code, request):
|
49
|
+
return self._get_token(
|
50
|
+
{
|
51
|
+
"client_id": self.get_client_id(),
|
52
|
+
"client_secret": self.get_client_secret(),
|
53
|
+
"code": code,
|
54
|
+
}
|
55
|
+
)
|
56
|
+
|
57
|
+
def refresh_oauth_token(self, *, oauth_token):
|
58
|
+
return self._get_token(
|
59
|
+
{
|
60
|
+
"client_id": self.get_client_id(),
|
61
|
+
"client_secret": self.get_client_secret(),
|
62
|
+
"refresh_token": oauth_token.refresh_token,
|
63
|
+
"grant_type": "refresh_token",
|
64
|
+
}
|
65
|
+
)
|
66
|
+
|
67
|
+
def get_oauth_user(self, *, oauth_token):
|
68
|
+
response = requests.get(
|
69
|
+
self.github_user_url,
|
70
|
+
headers={
|
71
|
+
"Accept": "application/json",
|
72
|
+
"Authorization": f"token {oauth_token.access_token}",
|
73
|
+
},
|
74
|
+
)
|
75
|
+
response.raise_for_status()
|
76
|
+
data = response.json()
|
77
|
+
user_id = data["id"]
|
78
|
+
username = data["login"]
|
79
|
+
|
80
|
+
# Use the verified, primary email address (not the public profile email, which is optional anyway)
|
81
|
+
response = requests.get(
|
82
|
+
self.github_emails_url,
|
83
|
+
headers={
|
84
|
+
"Accept": "application/json",
|
85
|
+
"Authorization": f"token {oauth_token.access_token}",
|
86
|
+
},
|
87
|
+
)
|
88
|
+
response.raise_for_status()
|
89
|
+
|
90
|
+
try:
|
91
|
+
verified_primary_email = [
|
92
|
+
x["email"] for x in response.json() if x["primary"] and x["verified"]
|
93
|
+
][0]
|
94
|
+
except IndexError:
|
95
|
+
raise OAuthError("A verified primary email address is required on GitHub")
|
96
|
+
|
97
|
+
return OAuthUser(
|
98
|
+
id=user_id,
|
99
|
+
email=verified_primary_email,
|
100
|
+
username=username,
|
101
|
+
)
|
@@ -0,0 +1,57 @@
|
|
1
|
+
import requests
|
2
|
+
|
3
|
+
from plain.oauth.providers import OAuthProvider, OAuthToken, OAuthUser
|
4
|
+
|
5
|
+
|
6
|
+
class GitLabOAuthProvider(OAuthProvider):
|
7
|
+
authorization_url = "https://gitlab.com/oauth/authorize"
|
8
|
+
|
9
|
+
def _get_token(self, request_data):
|
10
|
+
request_data["client_id"] = self.get_client_id()
|
11
|
+
request_data["client_secret"] = self.get_client_secret()
|
12
|
+
response = requests.post(
|
13
|
+
"https://gitlab.com/oauth/token",
|
14
|
+
headers={
|
15
|
+
"Accept": "application/json",
|
16
|
+
},
|
17
|
+
data=request_data,
|
18
|
+
)
|
19
|
+
response.raise_for_status()
|
20
|
+
data = response.json()
|
21
|
+
return OAuthToken(
|
22
|
+
access_token=data["access_token"],
|
23
|
+
refresh_token=data["refresh_token"],
|
24
|
+
# expires_in is missing in response?
|
25
|
+
)
|
26
|
+
|
27
|
+
def get_oauth_token(self, *, code, request):
|
28
|
+
return self._get_token(
|
29
|
+
{
|
30
|
+
"grant_type": "authorization_code",
|
31
|
+
"code": code,
|
32
|
+
"redirect_uri": self.get_callback_url(request=request),
|
33
|
+
}
|
34
|
+
)
|
35
|
+
|
36
|
+
def refresh_oauth_token(self, *, oauth_token):
|
37
|
+
return self._get_token(
|
38
|
+
{
|
39
|
+
"grant_type": "refresh_token",
|
40
|
+
"refresh_token": oauth_token.refresh_token,
|
41
|
+
}
|
42
|
+
)
|
43
|
+
|
44
|
+
def get_oauth_user(self, *, oauth_token):
|
45
|
+
response = requests.get(
|
46
|
+
"https://gitlab.com/api/v4/user",
|
47
|
+
headers={
|
48
|
+
"Authorization": f"Bearer {oauth_token.access_token}",
|
49
|
+
},
|
50
|
+
)
|
51
|
+
response.raise_for_status()
|
52
|
+
data = response.json()
|
53
|
+
return OAuthUser(
|
54
|
+
id=data["id"],
|
55
|
+
email=data["email"],
|
56
|
+
username=data["username"],
|
57
|
+
)
|
@@ -0,0 +1,34 @@
|
|
1
|
+
[project]
|
2
|
+
name = "plain.oauth"
|
3
|
+
version = "0.8.0"
|
4
|
+
description = "OAuth login and API access for Plain."
|
5
|
+
authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
|
6
|
+
license = "BSD-3-Clause"
|
7
|
+
readme = "README.md"
|
8
|
+
requires-python = ">=3.11"
|
9
|
+
dependencies = [
|
10
|
+
"plain<1.0.0",
|
11
|
+
"plain.auth<1.0.0",
|
12
|
+
"plain.models<1.0.0",
|
13
|
+
"requests>=2.0.0",
|
14
|
+
]
|
15
|
+
|
16
|
+
[tool.pytest.ini_options]
|
17
|
+
python_files = "tests.py test_*.py *_tests.py"
|
18
|
+
PLAIN_SETTINGS_MODULE = "tests.settings"
|
19
|
+
FAIL_INVALID_TEMPLATE_VARS = true
|
20
|
+
|
21
|
+
[tool.uv]
|
22
|
+
dev-dependencies = [
|
23
|
+
"plain.pytest<1.0.0",
|
24
|
+
]
|
25
|
+
|
26
|
+
[tool.uv.sources]
|
27
|
+
"plain.pytest" = {path = "../plain-pytest", editable = true}
|
28
|
+
|
29
|
+
[tool.hatch.build.targets.wheel]
|
30
|
+
packages = ["plain"]
|
31
|
+
|
32
|
+
[build-system]
|
33
|
+
requires = ["hatchling"]
|
34
|
+
build-backend = "hatchling.build"
|
@@ -0,0 +1,49 @@
|
|
1
|
+
from os import environ
|
2
|
+
|
3
|
+
SECRET_KEY = "test"
|
4
|
+
INSTALLED_PACKAGES = [
|
5
|
+
"plain.auth",
|
6
|
+
"plain.sessions",
|
7
|
+
"plain.models",
|
8
|
+
"plain.oauth",
|
9
|
+
"app.users",
|
10
|
+
]
|
11
|
+
DATABASES = {
|
12
|
+
"default": {
|
13
|
+
"ENGINE": "plain.models.backends.sqlite3",
|
14
|
+
"NAME": ":memory:",
|
15
|
+
}
|
16
|
+
}
|
17
|
+
MIDDLEWARE = [
|
18
|
+
"plain.sessions.middleware.SessionMiddleware",
|
19
|
+
"plain.auth.middleware.AuthenticationMiddleware",
|
20
|
+
]
|
21
|
+
AUTH_LOGIN_URL = "login"
|
22
|
+
AUTH_USER_MODEL = "users.User"
|
23
|
+
|
24
|
+
# OAuth providers to use for a real, interactive test
|
25
|
+
# (in a real config you'd probably do environ["key"] to raise a KeyError if an env var is forgotten)
|
26
|
+
OAUTH_LOGIN_PROVIDERS = {
|
27
|
+
"github": {
|
28
|
+
"class": "providers.github.GitHubOAuthProvider",
|
29
|
+
"kwargs": {
|
30
|
+
"client_id": environ.get("GITHUB_CLIENT_ID"),
|
31
|
+
"client_secret": environ.get("GITHUB_CLIENT_SECRET"),
|
32
|
+
},
|
33
|
+
},
|
34
|
+
"bitbucket": {
|
35
|
+
"class": "providers.bitbucket.BitbucketOAuthProvider",
|
36
|
+
"kwargs": {
|
37
|
+
"client_id": environ.get("BITBUCKET_KEY"),
|
38
|
+
"client_secret": environ.get("BITBUCKET_SECRET"),
|
39
|
+
},
|
40
|
+
},
|
41
|
+
"gitlab": {
|
42
|
+
"class": "providers.gitlab.GitLabOAuthProvider",
|
43
|
+
"kwargs": {
|
44
|
+
"client_id": environ.get("GITLAB_APPLICATION_ID"),
|
45
|
+
"client_secret": environ.get("GITLAB_APPLICATION_SECRET"),
|
46
|
+
"scope": "read_user",
|
47
|
+
},
|
48
|
+
},
|
49
|
+
}
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
7
|
+
<title>Document</title>
|
8
|
+
</head>
|
9
|
+
<body>
|
10
|
+
{% block content %}
|
11
|
+
{% endblock %}
|
12
|
+
</body>
|
13
|
+
</html>
|
@@ -0,0 +1,33 @@
|
|
1
|
+
{% extends "base.html" %}
|
2
|
+
|
3
|
+
{% block content %}
|
4
|
+
Hello {{ request.user }}!
|
5
|
+
|
6
|
+
<h2>Existing connections</h2>
|
7
|
+
<ul>
|
8
|
+
{% for connection in request.user.oauth_connections.all() %}
|
9
|
+
<li>
|
10
|
+
{{ connection.provider_key }} [ID: {{ connection.provider_user_id }}]
|
11
|
+
<form action="{{ url('oauth:disconnect', connection.provider_key) }}" method="post">
|
12
|
+
{{ csrf_input }}
|
13
|
+
<input type="hidden" name="provider_user_id" value="{{ connection.provider_user_id }}">
|
14
|
+
<button type="submit">Disconnect</button>
|
15
|
+
</form>
|
16
|
+
</li>
|
17
|
+
{% endfor %}
|
18
|
+
</ul>
|
19
|
+
|
20
|
+
<h2>Add a connection</h2>
|
21
|
+
<ul>
|
22
|
+
{% for provider_key in oauth_provider_keys %}
|
23
|
+
<li>
|
24
|
+
{{ provider_key}}
|
25
|
+
<form action="{{ url('oauth:connect', provider_key) }}" method="post">
|
26
|
+
{{ csrf_input }}
|
27
|
+
<button type="submit">Connect</button>
|
28
|
+
</form>
|
29
|
+
</li>
|
30
|
+
{% endfor %}
|
31
|
+
</ul>
|
32
|
+
|
33
|
+
{% endblock %}
|
@@ -0,0 +1,17 @@
|
|
1
|
+
{% extends "base.html" %}
|
2
|
+
|
3
|
+
{% block content %}
|
4
|
+
<h1>Login</h1>
|
5
|
+
<form action="{% url 'oauth:login' 'github' %}" method="post">
|
6
|
+
{{ csrf_input }}
|
7
|
+
<button type="submit">Login with GitHub</button>
|
8
|
+
</form>
|
9
|
+
<form action="{% url 'oauth:login' 'gitlab' %}" method="post">
|
10
|
+
{{ csrf_input }}
|
11
|
+
<button type="submit">Login with GitLab</button>
|
12
|
+
</form>
|
13
|
+
<form action="{% url 'oauth:login' 'bitbucket' %}" method="post">
|
14
|
+
{{ csrf_input }}
|
15
|
+
<button type="submit">Login with Bitbucket</button>
|
16
|
+
</form>
|
17
|
+
{% endblock %}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import plain.oauth.urls
|
2
|
+
from plain.auth.views import AuthViewMixin, LogoutView
|
3
|
+
from plain.oauth.providers import get_provider_keys
|
4
|
+
from plain.urls import include, path
|
5
|
+
from plain.views import TemplateView
|
6
|
+
|
7
|
+
|
8
|
+
class LoggedInView(AuthViewMixin, TemplateView):
|
9
|
+
template_name = "index.html"
|
10
|
+
|
11
|
+
def get_template_context(self):
|
12
|
+
context = super().get_template_context()
|
13
|
+
context["oauth_provider_keys"] = get_provider_keys()
|
14
|
+
return context
|
15
|
+
|
16
|
+
|
17
|
+
class LoginView(TemplateView):
|
18
|
+
template_name = "login.html"
|
19
|
+
|
20
|
+
|
21
|
+
urlpatterns = [
|
22
|
+
path("oauth/", include(plain.oauth.urls)),
|
23
|
+
path("login/", LoginView, name="login"),
|
24
|
+
path("logout/", LogoutView, name="logout"),
|
25
|
+
path("", LoggedInView),
|
26
|
+
]
|
File without changes
|
@@ -0,0 +1,66 @@
|
|
1
|
+
from tests.providers.github import GitHubOAuthProvider
|
2
|
+
|
3
|
+
from plain.oauth.providers import OAuthToken, OAuthUser
|
4
|
+
|
5
|
+
|
6
|
+
class DummyGitHubOAuthProvider(GitHubOAuthProvider):
|
7
|
+
def generate_state(self) -> str:
|
8
|
+
return "dummy_state"
|
9
|
+
|
10
|
+
def get_oauth_token(self, code, request):
|
11
|
+
return OAuthToken(access_token="gho_key")
|
12
|
+
|
13
|
+
def get_oauth_user(self, oauth_token):
|
14
|
+
return OAuthUser(
|
15
|
+
id="99",
|
16
|
+
username="userone",
|
17
|
+
email="user@example.com",
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
def test_github_provider(db, client, settings):
|
22
|
+
settings.OAUTH_LOGIN_PROVIDERS = {
|
23
|
+
"github": {
|
24
|
+
"class": "provider_tests.test_github.DummyGitHubOAuthProvider",
|
25
|
+
"kwargs": {
|
26
|
+
"client_id": "test_id",
|
27
|
+
"client_secret": "test_secret",
|
28
|
+
"scope": "user",
|
29
|
+
},
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
# Login required for this view
|
34
|
+
response = client.get("/")
|
35
|
+
assert response.status_code == 302
|
36
|
+
assert response.url == "/login/?next=/"
|
37
|
+
|
38
|
+
# User clicks the login link (form submit)
|
39
|
+
response = client.post("/oauth/github/login/")
|
40
|
+
assert response.status_code == 302
|
41
|
+
assert (
|
42
|
+
response.url
|
43
|
+
== "https://github.com/login/oauth/authorize?client_id=test_id&redirect_uri=https%3A%2F%2Ftestserver%2Foauth%2Fgithub%2Fcallback%2F&response_type=code&scope=user&state=dummy_state"
|
44
|
+
)
|
45
|
+
|
46
|
+
# GitHub redirects to the callback url
|
47
|
+
response = client.get("/oauth/github/callback/?code=test_code&state=dummy_state")
|
48
|
+
assert response.status_code == 302
|
49
|
+
assert response.url == "/"
|
50
|
+
|
51
|
+
# Now logged in
|
52
|
+
response = client.get("/")
|
53
|
+
assert response.status_code == 200
|
54
|
+
assert b"Hello userone!\n" in response.content
|
55
|
+
|
56
|
+
# Check the user and connection that was created
|
57
|
+
user = response.user
|
58
|
+
assert user.username == "userone"
|
59
|
+
assert user.email == "user@example.com"
|
60
|
+
connections = user.oauth_connections.all()
|
61
|
+
assert len(connections) == 1
|
62
|
+
assert connections[0].provider_key == "github"
|
63
|
+
assert connections[0].provider_user_id == "99"
|
64
|
+
assert connections[0].access_token == "gho_key"
|
65
|
+
assert connections[0].refresh_token == ""
|
66
|
+
assert connections[0].access_token_expires_at is None
|
File without changes
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import requests
|
4
|
+
|
5
|
+
from plain.oauth.providers import OAuthProvider, OAuthToken, OAuthUser
|
6
|
+
from plain.utils import timezone
|
7
|
+
|
8
|
+
|
9
|
+
class BitbucketOAuthProvider(OAuthProvider):
|
10
|
+
authorization_url = "https://bitbucket.org/site/oauth2/authorize"
|
11
|
+
|
12
|
+
def _get_token(self, request_data):
|
13
|
+
response = requests.post(
|
14
|
+
"https://bitbucket.org/site/oauth2/access_token",
|
15
|
+
auth=(self.get_client_id(), self.get_client_secret()),
|
16
|
+
headers={
|
17
|
+
"Accept": "application/json",
|
18
|
+
},
|
19
|
+
data=request_data,
|
20
|
+
)
|
21
|
+
response.raise_for_status()
|
22
|
+
data = response.json()
|
23
|
+
return OAuthToken(
|
24
|
+
access_token=data["access_token"],
|
25
|
+
refresh_token=data["refresh_token"],
|
26
|
+
access_token_expires_at=timezone.now()
|
27
|
+
+ datetime.timedelta(seconds=data["expires_in"]),
|
28
|
+
)
|
29
|
+
|
30
|
+
def get_oauth_token(self, *, code, request):
|
31
|
+
return self._get_token(
|
32
|
+
{
|
33
|
+
"grant_type": "authorization_code",
|
34
|
+
"code": code,
|
35
|
+
"redirect_uri": self.get_callback_url(request=request),
|
36
|
+
}
|
37
|
+
)
|
38
|
+
|
39
|
+
def refresh_oauth_token(self, *, oauth_token):
|
40
|
+
return self._get_token(
|
41
|
+
{
|
42
|
+
"grant_type": "refresh_token",
|
43
|
+
"refresh_token": oauth_token.refresh_token,
|
44
|
+
}
|
45
|
+
)
|
46
|
+
|
47
|
+
def get_oauth_user(self, *, oauth_token):
|
48
|
+
response = requests.get(
|
49
|
+
"https://api.bitbucket.org/2.0/user",
|
50
|
+
headers={
|
51
|
+
"Authorization": f"Bearer {oauth_token.access_token}",
|
52
|
+
},
|
53
|
+
)
|
54
|
+
response.raise_for_status()
|
55
|
+
user_id = response.json()["uuid"]
|
56
|
+
username = response.json()["username"]
|
57
|
+
|
58
|
+
response = requests.get(
|
59
|
+
"https://api.bitbucket.org/2.0/user/emails",
|
60
|
+
headers={
|
61
|
+
"Authorization": f"Bearer {oauth_token.access_token}",
|
62
|
+
},
|
63
|
+
)
|
64
|
+
response.raise_for_status()
|
65
|
+
confirmed_primary_email = [
|
66
|
+
x["email"]
|
67
|
+
for x in response.json()["values"]
|
68
|
+
if x["is_primary"] and x["is_confirmed"]
|
69
|
+
][0]
|
70
|
+
|
71
|
+
return OAuthUser(
|
72
|
+
id=user_id,
|
73
|
+
email=confirmed_primary_email,
|
74
|
+
username=username,
|
75
|
+
)
|