plain.oauth 0.7.4__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.
Files changed (43) hide show
  1. plain_oauth-0.7.5/.gitignore +7 -0
  2. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/PKG-INFO +10 -19
  3. plain_oauth-0.7.5/provider_examples/__init__.py +0 -0
  4. plain_oauth-0.7.5/provider_examples/bitbucket.py +75 -0
  5. plain_oauth-0.7.5/provider_examples/github.py +101 -0
  6. plain_oauth-0.7.5/provider_examples/gitlab.py +57 -0
  7. plain_oauth-0.7.5/pyproject.toml +34 -0
  8. plain_oauth-0.7.5/tests/app/settings.py +49 -0
  9. plain_oauth-0.7.5/tests/app/templates/base.html +13 -0
  10. plain_oauth-0.7.5/tests/app/templates/index.html +33 -0
  11. plain_oauth-0.7.5/tests/app/templates/login.html +17 -0
  12. plain_oauth-0.7.5/tests/app/urls.py +26 -0
  13. plain_oauth-0.7.5/tests/app/users/models.py +9 -0
  14. plain_oauth-0.7.5/tests/provider_tests/__init__.py +0 -0
  15. plain_oauth-0.7.5/tests/provider_tests/test_github.py +66 -0
  16. plain_oauth-0.7.5/tests/providers/__init__.py +0 -0
  17. plain_oauth-0.7.5/tests/providers/bitbucket.py +75 -0
  18. plain_oauth-0.7.5/tests/providers/github.py +101 -0
  19. plain_oauth-0.7.5/tests/providers/gitlab.py +57 -0
  20. plain_oauth-0.7.5/tests/test_backends.py +61 -0
  21. plain_oauth-0.7.5/tests/test_checks.py +66 -0
  22. plain_oauth-0.7.5/tests/test_providers.py +485 -0
  23. plain_oauth-0.7.5/uv.lock +337 -0
  24. plain_oauth-0.7.4/pyproject.toml +0 -37
  25. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/LICENSE +0 -0
  26. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/README.md +0 -0
  27. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/README.md +0 -0
  28. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/__init__.py +0 -0
  29. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/config.py +0 -0
  30. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/default_settings.py +0 -0
  31. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/exceptions.py +0 -0
  32. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/0001_initial.py +0 -0
  33. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/0002_alter_oauthconnection_options_and_more.py +0 -0
  34. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/0003_alter_oauthconnection_access_token_and_more.py +0 -0
  35. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/0004_alter_oauthconnection_access_token_and_more.py +0 -0
  36. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/0005_alter_oauthconnection_unique_together_and_more.py +0 -0
  37. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/migrations/__init__.py +0 -0
  38. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/models.py +0 -0
  39. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/providers.py +0 -0
  40. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/staff.py +0 -0
  41. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/templates/oauth/error.html +0 -0
  42. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/urls.py +0 -0
  43. {plain_oauth-0.7.4 → plain_oauth-0.7.5}/plain/oauth/views.py +0 -0
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ /dist
3
+ /.venv
4
+ *.pyc
5
+ __pycache__
6
+ db.sqlite3
7
+ .env
@@ -1,23 +1,15 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: plain.oauth
3
- Version: 0.7.4
3
+ Version: 0.7.5
4
4
  Summary: OAuth login and API access for Plain.
5
- Home-page: https://plainframework.com
6
- License: BSD-3-Clause
7
- Author: Dave Gaeddert
8
- Author-email: dave.gaeddert@dropseed.dev
9
- Requires-Python: >=3.11,<4.0
10
- Classifier: License :: OSI Approved :: BSD License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
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
-
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
+ ]
@@ -0,0 +1,9 @@
1
+ from plain import models
2
+
3
+
4
+ class User(models.Model):
5
+ email = models.EmailField(unique=True)
6
+ username = models.CharField(max_length=100, unique=True)
7
+
8
+ def __str__(self):
9
+ return self.username
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
+ )