plain.oauth 0.19.1__tar.gz → 0.21.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.19.1 → plain_oauth-0.21.0}/PKG-INFO +7 -5
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/README.md +6 -4
- plain_oauth-0.21.0/plain/oauth/exceptions.py +16 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/models.py +4 -4
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/providers.py +26 -12
- plain_oauth-0.21.0/plain/oauth/templates/oauth/callback.html +8 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/views.py +16 -28
- {plain_oauth-0.19.1/tests/providers → plain_oauth-0.21.0/provider_examples}/bitbucket.py +5 -3
- {plain_oauth-0.19.1/tests/providers → plain_oauth-0.21.0/provider_examples}/github.py +5 -3
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/provider_examples/gitlab.py +5 -3
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/pyproject.toml +2 -5
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/provider_tests/test_github.py +5 -3
- {plain_oauth-0.19.1/provider_examples → plain_oauth-0.21.0/tests/providers}/bitbucket.py +5 -3
- {plain_oauth-0.19.1/provider_examples → plain_oauth-0.21.0/tests/providers}/github.py +5 -3
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/providers/gitlab.py +5 -3
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/test_backends.py +5 -3
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/test_providers.py +5 -3
- plain_oauth-0.19.1/plain/oauth/exceptions.py +0 -12
- plain_oauth-0.19.1/plain/oauth/templates/oauth/error.html +0 -6
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/.gitignore +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/LICENSE +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/README.md +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/admin.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/config.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/default_settings.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0001_initial.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0002_alter_oauthconnection_options_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0003_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0004_alter_oauthconnection_access_token_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0005_alter_oauthconnection_unique_together_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0006_remove_oauthconnection_unique_oauth_provider_user_id_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/0007_alter_oauthconnection_provider_key_and_more.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/migrations/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/plain/oauth/urls.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/provider_examples/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/settings.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/templates/base.html +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/templates/index.html +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/templates/login.html +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/urls.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/users/migrations/0001_initial.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/users/migrations/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/app/users/models.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/provider_tests/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/providers/__init__.py +0 -0
- {plain_oauth-0.19.1 → plain_oauth-0.21.0}/tests/test_checks.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: plain.oauth
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.21.0
|
4
4
|
Summary: OAuth login and API access for Plain.
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
6
|
License-Expression: BSD-3-Clause
|
@@ -103,10 +103,12 @@ class ExampleOAuthProvider(OAuthProvider):
|
|
103
103
|
data = response.json()
|
104
104
|
return OAuthUser(
|
105
105
|
# The provider ID is required
|
106
|
-
|
107
|
-
#
|
108
|
-
|
109
|
-
|
106
|
+
provider_id=data["id"],
|
107
|
+
# Populate your User model fields using the user_model_fields dict
|
108
|
+
user_model_fields={
|
109
|
+
"email": data["email"],
|
110
|
+
"username": data["username"],
|
111
|
+
},
|
110
112
|
)
|
111
113
|
```
|
112
114
|
|
@@ -89,10 +89,12 @@ class ExampleOAuthProvider(OAuthProvider):
|
|
89
89
|
data = response.json()
|
90
90
|
return OAuthUser(
|
91
91
|
# The provider ID is required
|
92
|
-
|
93
|
-
#
|
94
|
-
|
95
|
-
|
92
|
+
provider_id=data["id"],
|
93
|
+
# Populate your User model fields using the user_model_fields dict
|
94
|
+
user_model_fields={
|
95
|
+
"email": data["email"],
|
96
|
+
"username": data["username"],
|
97
|
+
},
|
96
98
|
)
|
97
99
|
```
|
98
100
|
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class OAuthError(Exception):
|
2
|
+
"""Base class for OAuth errors"""
|
3
|
+
|
4
|
+
message = "An error occurred during the OAuth process."
|
5
|
+
|
6
|
+
|
7
|
+
class OAuthStateMissingError(OAuthError):
|
8
|
+
message = "The state parameter is missing. Please try again."
|
9
|
+
|
10
|
+
|
11
|
+
class OAuthStateMismatchError(OAuthError):
|
12
|
+
message = "The state parameter did not match. Please try again."
|
13
|
+
|
14
|
+
|
15
|
+
class OAuthUserAlreadyExistsError(OAuthError):
|
16
|
+
message = "A user already exists with this email address. Please log in first and then connect this OAuth provider to the existing account."
|
@@ -76,7 +76,7 @@ class OAuthConnection(models.Model):
|
|
76
76
|
self.refresh_token_expires_at = oauth_token.refresh_token_expires_at
|
77
77
|
|
78
78
|
def set_user_fields(self, oauth_user: "OAuthUser"):
|
79
|
-
self.provider_user_id = oauth_user.
|
79
|
+
self.provider_user_id = oauth_user.provider_id
|
80
80
|
|
81
81
|
def access_token_expired(self) -> bool:
|
82
82
|
return (
|
@@ -97,7 +97,7 @@ class OAuthConnection(models.Model):
|
|
97
97
|
try:
|
98
98
|
connection = cls.objects.get(
|
99
99
|
provider_key=provider_key,
|
100
|
-
provider_user_id=oauth_user.
|
100
|
+
provider_user_id=oauth_user.provider_id,
|
101
101
|
)
|
102
102
|
connection.set_token_fields(oauth_token)
|
103
103
|
connection.save()
|
@@ -137,7 +137,7 @@ class OAuthConnection(models.Model):
|
|
137
137
|
connection = cls.objects.get(
|
138
138
|
user=user,
|
139
139
|
provider_key=provider_key,
|
140
|
-
provider_user_id=oauth_user.
|
140
|
+
provider_user_id=oauth_user.provider_id,
|
141
141
|
)
|
142
142
|
except cls.DoesNotExist:
|
143
143
|
# Create our own instance (not using get_or_create)
|
@@ -145,7 +145,7 @@ class OAuthConnection(models.Model):
|
|
145
145
|
connection = cls(
|
146
146
|
user=user,
|
147
147
|
provider_key=provider_key,
|
148
|
-
provider_user_id=oauth_user.
|
148
|
+
provider_user_id=oauth_user.provider_id,
|
149
149
|
)
|
150
150
|
|
151
151
|
connection.set_user_fields(oauth_user)
|
@@ -7,10 +7,11 @@ from plain.auth import login as auth_login
|
|
7
7
|
from plain.http import HttpRequest, Response, ResponseRedirect
|
8
8
|
from plain.runtime import settings
|
9
9
|
from plain.urls import reverse
|
10
|
+
from plain.utils.cache import add_never_cache_headers
|
10
11
|
from plain.utils.crypto import get_random_string
|
11
12
|
from plain.utils.module_loading import import_string
|
12
13
|
|
13
|
-
from .exceptions import OAuthError, OAuthStateMismatchError
|
14
|
+
from .exceptions import OAuthError, OAuthStateMismatchError, OAuthStateMissingError
|
14
15
|
from .models import OAuthConnection
|
15
16
|
|
16
17
|
SESSION_STATE_KEY = "plainoauth_state"
|
@@ -23,8 +24,8 @@ class OAuthToken:
|
|
23
24
|
*,
|
24
25
|
access_token: str,
|
25
26
|
refresh_token: str = "",
|
26
|
-
access_token_expires_at: datetime.datetime = None,
|
27
|
-
refresh_token_expires_at: datetime.datetime = None,
|
27
|
+
access_token_expires_at: datetime.datetime | None = None,
|
28
|
+
refresh_token_expires_at: datetime.datetime | None = None,
|
28
29
|
):
|
29
30
|
self.access_token = access_token
|
30
31
|
self.refresh_token = refresh_token
|
@@ -33,16 +34,16 @@ class OAuthToken:
|
|
33
34
|
|
34
35
|
|
35
36
|
class OAuthUser:
|
36
|
-
def __init__(self, *,
|
37
|
-
self.
|
38
|
-
self.user_model_fields = user_model_fields
|
37
|
+
def __init__(self, *, provider_id: str, user_model_fields: dict | None = None):
|
38
|
+
self.provider_id = provider_id # ID on the provider's system
|
39
|
+
self.user_model_fields = user_model_fields or {}
|
39
40
|
|
40
41
|
def __str__(self):
|
41
42
|
if "email" in self.user_model_fields:
|
42
43
|
return self.user_model_fields["email"]
|
43
44
|
if "username" in self.user_model_fields:
|
44
45
|
return self.user_model_fields["username"]
|
45
|
-
return str(self.
|
46
|
+
return str(self.provider_id)
|
46
47
|
|
47
48
|
|
48
49
|
class OAuthProvider:
|
@@ -105,7 +106,11 @@ class OAuthProvider:
|
|
105
106
|
if error := request.query_params.get("error"):
|
106
107
|
raise OAuthError(error)
|
107
108
|
|
108
|
-
|
109
|
+
try:
|
110
|
+
state = request.query_params["state"]
|
111
|
+
except KeyError as e:
|
112
|
+
raise OAuthStateMissingError() from e
|
113
|
+
|
109
114
|
expected_state = request.session.pop(SESSION_STATE_KEY)
|
110
115
|
request.session.save() # Make sure the pop is saved (won't save on an exception)
|
111
116
|
if not secrets.compare_digest(state, expected_state):
|
@@ -130,7 +135,7 @@ class OAuthProvider:
|
|
130
135
|
# Sort authorization params for consistency
|
131
136
|
sorted_authorization_params = sorted(authorization_params.items())
|
132
137
|
redirect_url = authorization_url + "?" + urlencode(sorted_authorization_params)
|
133
|
-
return
|
138
|
+
return self.get_redirect_response(redirect_url)
|
134
139
|
|
135
140
|
def handle_connect_request(
|
136
141
|
self, *, request: HttpRequest, redirect_to: str = ""
|
@@ -144,7 +149,7 @@ class OAuthProvider:
|
|
144
149
|
)
|
145
150
|
connection.delete()
|
146
151
|
redirect_url = self.get_disconnect_redirect_url(request=request)
|
147
|
-
return
|
152
|
+
return self.get_redirect_response(redirect_url)
|
148
153
|
|
149
154
|
def handle_callback_request(self, *, request: HttpRequest) -> Response:
|
150
155
|
self.check_request_state(request=request)
|
@@ -174,9 +179,9 @@ class OAuthProvider:
|
|
174
179
|
self.login(request=request, user=user)
|
175
180
|
|
176
181
|
redirect_url = self.get_login_redirect_url(request=request)
|
177
|
-
return
|
182
|
+
return self.get_redirect_response(redirect_url)
|
178
183
|
|
179
|
-
def login(self, *, request: HttpRequest, user: Any) ->
|
184
|
+
def login(self, *, request: HttpRequest, user: Any) -> None:
|
180
185
|
auth_login(request=request, user=user)
|
181
186
|
|
182
187
|
def get_login_redirect_url(self, *, request: HttpRequest) -> str:
|
@@ -185,6 +190,15 @@ class OAuthProvider:
|
|
185
190
|
def get_disconnect_redirect_url(self, *, request: HttpRequest) -> str:
|
186
191
|
return request.data.get("next", "/")
|
187
192
|
|
193
|
+
def get_redirect_response(self, redirect_url: str) -> Response:
|
194
|
+
"""
|
195
|
+
Returns a redirect response to the given URL.
|
196
|
+
This is a utility method to ensure consistent redirect handling.
|
197
|
+
"""
|
198
|
+
response = ResponseRedirect(redirect_url)
|
199
|
+
add_never_cache_headers(response)
|
200
|
+
return response
|
201
|
+
|
188
202
|
|
189
203
|
def get_oauth_provider_instance(*, provider_key: str) -> OAuthProvider:
|
190
204
|
OAUTH_LOGIN_PROVIDERS = getattr(settings, "OAUTH_LOGIN_PROVIDERS", {})
|
@@ -1,14 +1,11 @@
|
|
1
1
|
import logging
|
2
2
|
|
3
3
|
from plain.auth.views import AuthViewMixin
|
4
|
-
from plain.http import
|
5
|
-
from plain.
|
6
|
-
from plain.views import View
|
4
|
+
from plain.http import ResponseRedirect
|
5
|
+
from plain.views import TemplateView, View
|
7
6
|
|
8
7
|
from .exceptions import (
|
9
8
|
OAuthError,
|
10
|
-
OAuthStateMismatchError,
|
11
|
-
OAuthUserAlreadyExistsError,
|
12
9
|
)
|
13
10
|
from .providers import get_oauth_provider_instance
|
14
11
|
|
@@ -26,39 +23,30 @@ class OAuthLoginView(View):
|
|
26
23
|
return provider_instance.handle_login_request(request=request)
|
27
24
|
|
28
25
|
|
29
|
-
class OAuthCallbackView(
|
26
|
+
class OAuthCallbackView(TemplateView):
|
30
27
|
"""
|
31
28
|
The callback view is used for signup, login, and connect.
|
32
29
|
"""
|
33
30
|
|
31
|
+
template_name = "oauth/callback.html"
|
32
|
+
|
34
33
|
def get(self):
|
35
|
-
request = self.request
|
36
34
|
provider = self.url_kwargs["provider"]
|
37
35
|
provider_instance = get_oauth_provider_instance(provider_key=provider)
|
38
36
|
try:
|
39
|
-
return provider_instance.handle_callback_request(request=request)
|
40
|
-
except OAuthUserAlreadyExistsError:
|
41
|
-
template = Template("oauth/error.html")
|
42
|
-
return ResponseBadRequest(
|
43
|
-
template.render(
|
44
|
-
{
|
45
|
-
"oauth_error": "A user already exists with this email address. Please log in first and then connect this OAuth provider to the existing account."
|
46
|
-
}
|
47
|
-
)
|
48
|
-
)
|
49
|
-
except OAuthStateMismatchError:
|
50
|
-
template = Template("oauth/error.html")
|
51
|
-
return ResponseBadRequest(
|
52
|
-
template.render(
|
53
|
-
{
|
54
|
-
"oauth_error": "The state parameter did not match. Please try again."
|
55
|
-
}
|
56
|
-
)
|
57
|
-
)
|
37
|
+
return provider_instance.handle_callback_request(request=self.request)
|
58
38
|
except OAuthError as e:
|
59
39
|
logger.exception("OAuth error")
|
60
|
-
|
61
|
-
|
40
|
+
self.oauth_error = e
|
41
|
+
|
42
|
+
response = super().get()
|
43
|
+
response.status_code = 400
|
44
|
+
return response
|
45
|
+
|
46
|
+
def get_template_context(self) -> dict:
|
47
|
+
context = super().get_template_context()
|
48
|
+
context["oauth_error"] = getattr(self, "oauth_error", None)
|
49
|
+
return context
|
62
50
|
|
63
51
|
|
64
52
|
class OAuthConnectView(AuthViewMixin, View):
|
@@ -69,7 +69,9 @@ class BitbucketOAuthProvider(OAuthProvider):
|
|
69
69
|
][0]
|
70
70
|
|
71
71
|
return OAuthUser(
|
72
|
-
|
73
|
-
|
74
|
-
|
72
|
+
provider_id=user_id,
|
73
|
+
user_model_fields={
|
74
|
+
"email": confirmed_primary_email,
|
75
|
+
"username": username,
|
76
|
+
},
|
75
77
|
)
|
@@ -95,7 +95,9 @@ class GitHubOAuthProvider(OAuthProvider):
|
|
95
95
|
raise OAuthError("A verified primary email address is required on GitHub")
|
96
96
|
|
97
97
|
return OAuthUser(
|
98
|
-
|
99
|
-
|
100
|
-
|
98
|
+
provider_id=user_id,
|
99
|
+
user_model_fields={
|
100
|
+
"email": verified_primary_email,
|
101
|
+
"username": username,
|
102
|
+
},
|
101
103
|
)
|
@@ -51,7 +51,9 @@ class GitLabOAuthProvider(OAuthProvider):
|
|
51
51
|
response.raise_for_status()
|
52
52
|
data = response.json()
|
53
53
|
return OAuthUser(
|
54
|
-
|
55
|
-
|
56
|
-
|
54
|
+
provider_id=data["id"],
|
55
|
+
user_model_fields={
|
56
|
+
"email": data["email"],
|
57
|
+
"username": data["username"],
|
58
|
+
},
|
57
59
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "plain.oauth"
|
3
|
-
version = "0.
|
3
|
+
version = "0.21.0"
|
4
4
|
description = "OAuth login and API access for Plain."
|
5
5
|
authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
|
6
6
|
license = "BSD-3-Clause"
|
@@ -13,10 +13,7 @@ dependencies = [
|
|
13
13
|
"requests>=2.0.0",
|
14
14
|
]
|
15
15
|
|
16
|
-
|
17
|
-
python_files = "tests.py test_*.py *_tests.py"
|
18
|
-
PLAIN_SETTINGS_MODULE = "tests.settings"
|
19
|
-
FAIL_INVALID_TEMPLATE_VARS = true
|
16
|
+
|
20
17
|
|
21
18
|
[tool.uv]
|
22
19
|
dev-dependencies = [
|
@@ -13,9 +13,11 @@ class DummyGitHubOAuthProvider(GitHubOAuthProvider):
|
|
13
13
|
|
14
14
|
def get_oauth_user(self, oauth_token):
|
15
15
|
return OAuthUser(
|
16
|
-
|
17
|
-
|
18
|
-
|
16
|
+
provider_id="99",
|
17
|
+
user_model_fields={
|
18
|
+
"username": "userone",
|
19
|
+
"email": "user@example.com",
|
20
|
+
},
|
19
21
|
)
|
20
22
|
|
21
23
|
|
@@ -69,7 +69,9 @@ class BitbucketOAuthProvider(OAuthProvider):
|
|
69
69
|
][0]
|
70
70
|
|
71
71
|
return OAuthUser(
|
72
|
-
|
73
|
-
|
74
|
-
|
72
|
+
provider_id=user_id,
|
73
|
+
user_model_fields={
|
74
|
+
"email": confirmed_primary_email,
|
75
|
+
"username": username,
|
76
|
+
},
|
75
77
|
)
|
@@ -95,7 +95,9 @@ class GitHubOAuthProvider(OAuthProvider):
|
|
95
95
|
raise OAuthError("A verified primary email address is required on GitHub")
|
96
96
|
|
97
97
|
return OAuthUser(
|
98
|
-
|
99
|
-
|
100
|
-
|
98
|
+
provider_id=user_id,
|
99
|
+
user_model_fields={
|
100
|
+
"email": verified_primary_email,
|
101
|
+
"username": username,
|
102
|
+
},
|
101
103
|
)
|
@@ -51,7 +51,9 @@ class GitLabOAuthProvider(OAuthProvider):
|
|
51
51
|
response.raise_for_status()
|
52
52
|
data = response.json()
|
53
53
|
return OAuthUser(
|
54
|
-
|
55
|
-
|
56
|
-
|
54
|
+
provider_id=data["id"],
|
55
|
+
user_model_fields={
|
56
|
+
"email": data["email"],
|
57
|
+
"username": data["username"],
|
58
|
+
},
|
57
59
|
)
|
@@ -10,9 +10,11 @@ class DummyProvider(OAuthProvider):
|
|
10
10
|
|
11
11
|
def get_oauth_user(self, *, oauth_token):
|
12
12
|
return OAuthUser(
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
provider_id="dummy_user_id",
|
14
|
+
user_model_fields={
|
15
|
+
"username": "dummy_username",
|
16
|
+
"email": "dummy@example.com",
|
17
|
+
},
|
16
18
|
)
|
17
19
|
|
18
20
|
def check_request_state(self, *, request):
|
@@ -38,9 +38,11 @@ class DummyProvider(OAuthProvider):
|
|
38
38
|
|
39
39
|
def get_oauth_user(self, *, oauth_token: OAuthToken) -> OAuthUser:
|
40
40
|
return OAuthUser(
|
41
|
-
|
42
|
-
|
43
|
-
|
41
|
+
provider_id="dummy_id",
|
42
|
+
user_model_fields={
|
43
|
+
"email": "dummy@example.com",
|
44
|
+
"username": "dummy_username",
|
45
|
+
},
|
44
46
|
)
|
45
47
|
|
46
48
|
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|