plain.oauth 0.7.3__tar.gz → 0.7.5__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.7.5/.gitignore +7 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/PKG-INFO +10 -19
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/providers.py +1 -0
- plain_oauth-0.7.5/provider_examples/__init__.py +0 -0
- plain_oauth-0.7.5/provider_examples/bitbucket.py +75 -0
- plain_oauth-0.7.5/provider_examples/github.py +101 -0
- plain_oauth-0.7.5/provider_examples/gitlab.py +57 -0
- plain_oauth-0.7.5/pyproject.toml +34 -0
- plain_oauth-0.7.5/tests/app/settings.py +49 -0
- plain_oauth-0.7.5/tests/app/templates/base.html +13 -0
- plain_oauth-0.7.5/tests/app/templates/index.html +33 -0
- plain_oauth-0.7.5/tests/app/templates/login.html +17 -0
- plain_oauth-0.7.5/tests/app/urls.py +26 -0
- plain_oauth-0.7.5/tests/app/users/models.py +9 -0
- plain_oauth-0.7.5/tests/provider_tests/__init__.py +0 -0
- plain_oauth-0.7.5/tests/provider_tests/test_github.py +66 -0
- plain_oauth-0.7.5/tests/providers/__init__.py +0 -0
- plain_oauth-0.7.5/tests/providers/bitbucket.py +75 -0
- plain_oauth-0.7.5/tests/providers/github.py +101 -0
- plain_oauth-0.7.5/tests/providers/gitlab.py +57 -0
- plain_oauth-0.7.5/tests/test_backends.py +61 -0
- plain_oauth-0.7.5/tests/test_checks.py +66 -0
- plain_oauth-0.7.5/tests/test_providers.py +485 -0
- plain_oauth-0.7.5/uv.lock +337 -0
- plain_oauth-0.7.3/pyproject.toml +0 -37
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/LICENSE +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/README.md +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/README.md +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/__init__.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/config.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/default_settings.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/exceptions.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/0001_initial.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/0002_alter_oauthconnection_options_and_more.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/0003_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/0004_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/0005_alter_oauthconnection_unique_together_and_more.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/migrations/__init__.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/models.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/staff.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/templates/oauth/error.html +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/urls.py +0 -0
- {plain_oauth-0.7.3 → plain_oauth-0.7.5}/plain/oauth/views.py +0 -0
@@ -1,23 +1,15 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: plain.oauth
|
3
|
-
Version: 0.7.
|
3
|
+
Version: 0.7.5
|
4
4
|
Summary: OAuth login and API access for Plain.
|
5
|
-
|
6
|
-
License: BSD-3-Clause
|
7
|
-
|
8
|
-
|
9
|
-
Requires-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
14
|
-
Classifier: Programming Language :: Python :: 3.13
|
15
|
-
Requires-Dist: plain (<1.0.0)
|
16
|
-
Requires-Dist: plain.auth (<1.0.0)
|
17
|
-
Requires-Dist: plain.models (<1.0.0)
|
18
|
-
Requires-Dist: requests
|
19
|
-
Project-URL: Documentation, https://plainframework.com/docs/
|
20
|
-
Project-URL: Repository, https://github.com/dropseed/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
|
21
13
|
Description-Content-Type: text/markdown
|
22
14
|
|
23
15
|
<!-- This file is compiled from plain-oauth/plain/oauth/README.md. Do not edit this file directly. -->
|
@@ -323,4 +315,3 @@ This is the Django setting you're probably looking for:
|
|
323
315
|
```python
|
324
316
|
HTTPS_PROXY_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
325
317
|
```
|
326
|
-
|
@@ -107,6 +107,7 @@ class OAuthProvider:
|
|
107
107
|
|
108
108
|
state = request.GET["state"]
|
109
109
|
expected_state = request.session.pop(SESSION_STATE_KEY)
|
110
|
+
request.session.save() # Make sure the pop is saved (won't save on an exception)
|
110
111
|
if not secrets.compare_digest(state, expected_state):
|
111
112
|
raise OAuthStateMismatchError()
|
112
113
|
|
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.7.5"
|
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
|
+
)
|