drf-react-by-schema-oauth 0.1.0__py3-none-any.whl

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 (32) hide show
  1. drf_react_by_schema_oauth/__init__.py +18 -0
  2. drf_react_by_schema_oauth/apps.py +29 -0
  3. drf_react_by_schema_oauth/authentication.py +23 -0
  4. drf_react_by_schema_oauth/dcr.py +206 -0
  5. drf_react_by_schema_oauth/metadata.py +100 -0
  6. drf_react_by_schema_oauth/permissions.py +61 -0
  7. drf_react_by_schema_oauth/settings.py +52 -0
  8. drf_react_by_schema_oauth/templates/drbs_oauth/authorize.html +45 -0
  9. drf_react_by_schema_oauth/templates/drbs_oauth/login.html +40 -0
  10. drf_react_by_schema_oauth/tests/__init__.py +0 -0
  11. drf_react_by_schema_oauth/tests/helpers.py +41 -0
  12. drf_react_by_schema_oauth/tests/test_apps.py +24 -0
  13. drf_react_by_schema_oauth/tests/test_auth.py +38 -0
  14. drf_react_by_schema_oauth/tests/test_dcr.py +110 -0
  15. drf_react_by_schema_oauth/tests/test_discovery.py +47 -0
  16. drf_react_by_schema_oauth/tests/test_flow.py +183 -0
  17. drf_react_by_schema_oauth/tests/test_metadata.py +32 -0
  18. drf_react_by_schema_oauth/tests/test_scopes.py +69 -0
  19. drf_react_by_schema_oauth/tests/test_settings.py +29 -0
  20. drf_react_by_schema_oauth/tests/testproject/__init__.py +0 -0
  21. drf_react_by_schema_oauth/tests/testproject/manage.py +17 -0
  22. drf_react_by_schema_oauth/tests/testproject/settings.py +84 -0
  23. drf_react_by_schema_oauth/tests/testproject/urls.py +7 -0
  24. drf_react_by_schema_oauth/tests/testproject/wsgi.py +10 -0
  25. drf_react_by_schema_oauth/tests/urls.py +56 -0
  26. drf_react_by_schema_oauth/urls.py +33 -0
  27. drf_react_by_schema_oauth/views.py +47 -0
  28. drf_react_by_schema_oauth-0.1.0.dist-info/METADATA +288 -0
  29. drf_react_by_schema_oauth-0.1.0.dist-info/RECORD +32 -0
  30. drf_react_by_schema_oauth-0.1.0.dist-info/WHEEL +5 -0
  31. drf_react_by_schema_oauth-0.1.0.dist-info/licenses/LICENSE +674 -0
  32. drf_react_by_schema_oauth-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,18 @@
1
+ """
2
+ drf-react-by-schema-oauth
3
+ =========================
4
+
5
+ OAuth 2.1 authorization server and resource server for
6
+ `drf-react-by-schema <https://gitlab.com/eita/drf-react-by-schema>`_ projects.
7
+
8
+ Adding the ``oauth2_provider`` app and this package's ``DrbsOAuthConfig`` to
9
+ ``INSTALLED_APPS``, including this package's URLs and running ``migrate`` turns
10
+ a DRF project into a standard OAuth 2.1 provider with PKCE and Dynamic Client
11
+ Registration, with no React/SPA changes required.
12
+ """
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ from .settings import SCOPE_READ, SCOPE_WRITE # noqa: E402
17
+
18
+ __all__ = ["__version__", "SCOPE_READ", "SCOPE_WRITE"]
@@ -0,0 +1,29 @@
1
+ from django.apps import AppConfig
2
+ from django.core.exceptions import ImproperlyConfigured
3
+
4
+
5
+ class DrbsOAuthConfig(AppConfig):
6
+ """AppConfig for the OAuth 2.1 provider.
7
+
8
+ ``name`` is the import module (``drf_react_by_schema_oauth``); the Django
9
+ **app label** is kept as ``drbs_oauth`` per the frozen interface contract.
10
+ """
11
+
12
+ default_auto_field = "django.db.models.BigAutoField"
13
+ name = "drf_react_by_schema_oauth"
14
+ label = "drbs_oauth"
15
+
16
+ def ready(self):
17
+ # django-oauth-toolkit ships the AS models and views we build on. Fail
18
+ # loudly and early if the consumer forgot to install/enable it.
19
+ from django.apps import apps
20
+
21
+ try:
22
+ apps.get_app_config("oauth2_provider")
23
+ except LookupError as exc:
24
+ raise ImproperlyConfigured(
25
+ "drf-react-by-schema-oauth requires django-oauth-toolkit. Add "
26
+ "'oauth2_provider' to INSTALLED_APPS *before* "
27
+ "'drf_react_by_schema_oauth', and make sure the "
28
+ "'django-oauth-toolkit' package is installed."
29
+ ) from exc
@@ -0,0 +1,23 @@
1
+ """DRF authentication backed by django-oauth-toolkit.
2
+
3
+ The class is exposed under the frozen import path
4
+ ``drf_react_by_schema_oauth.authentication.OAuth2Authentication`` and is a thin
5
+ subclass of DOT's implementation, so the same Bearer token authenticates both
6
+ ``/mcp`` and ``/api/...`` once it is listed in a view's
7
+ ``authentication_classes`` (or ``REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]``).
8
+ """
9
+
10
+ from oauth2_provider.contrib.rest_framework import (
11
+ OAuth2Authentication as DotOAuth2Authentication,
12
+ )
13
+
14
+
15
+ class OAuth2Authentication(DotOAuth2Authentication):
16
+ """Authenticate ``Authorization: Bearer <token>`` via DOT.
17
+
18
+ On success it returns ``(user, access_token)``; DRF exposes the token as
19
+ ``request.auth``, which :class:`drf_react_by_schema_oauth.permissions.HasScope`
20
+ and DOT's own permission classes read.
21
+ """
22
+
23
+ www_authenticate_realm = "drbs"
@@ -0,0 +1,206 @@
1
+ """Dynamic Client Registration (RFC 7591).
2
+
3
+ ``POST /o/register/`` registers a **public** OAuth client (no client secret)
4
+ that uses the authorization-code grant with PKCE. Redirect URIs are restricted
5
+ to loopback by default (RFC 8252); see ``DRBS_OAUTH["DCR_ALLOWED_REDIRECTS"]``.
6
+ """
7
+
8
+ import json
9
+ import time
10
+ from urllib.parse import urlsplit
11
+
12
+ from django.http import JsonResponse
13
+ from django.utils.decorators import method_decorator
14
+ from django.views import View
15
+ from django.views.decorators.csrf import csrf_exempt
16
+
17
+ from .settings import get_scopes, get_setting
18
+
19
+ # Public clients only, authorization-code + refresh-token, code responses.
20
+ TOKEN_ENDPOINT_AUTH_METHOD = "none"
21
+ ALLOWED_GRANT_TYPES = {"authorization_code", "refresh_token"}
22
+ REQUIRED_GRANT_TYPE = "authorization_code"
23
+ ALLOWED_RESPONSE_TYPES = {"code"}
24
+
25
+
26
+ def _error(status, code, description):
27
+ return JsonResponse(
28
+ {"error": code, "error_description": description}, status=status
29
+ )
30
+
31
+
32
+ def _is_loopback_host(host):
33
+ if not host:
34
+ return False
35
+ host = host.lower()
36
+ if host in ("localhost", "::1"):
37
+ return True
38
+ # IPv4 loopback: 127.0.0.0/8
39
+ parts = host.split(".")
40
+ if len(parts) == 4 and parts[0] == "127":
41
+ try:
42
+ return all(0 <= int(p) <= 255 for p in parts)
43
+ except ValueError:
44
+ return False
45
+ return False
46
+
47
+
48
+ def _matches_allowed(uri, allowed_prefixes):
49
+ """True if ``uri``'s scheme+host (and port, when pinned) matches an entry.
50
+
51
+ Comparison is component-wise (not string-prefix) so ``http://localhost``
52
+ does not accidentally allow ``http://localhost.evil.com``. When the allowed
53
+ entry does not pin a port, any port is accepted (RFC 8252 loopback).
54
+ """
55
+ parsed = urlsplit(uri)
56
+ for prefix in allowed_prefixes:
57
+ allowed = urlsplit(prefix)
58
+ if parsed.scheme != allowed.scheme:
59
+ continue
60
+ if (parsed.hostname or "").lower() != (allowed.hostname or "").lower():
61
+ continue
62
+ if allowed.port is not None and parsed.port != allowed.port:
63
+ continue
64
+ return True
65
+ return False
66
+
67
+
68
+ def validate_redirect_uri(uri, allowed_prefixes):
69
+ """Validate a single registration ``redirect_uri``.
70
+
71
+ Returns ``(ok, message)``. Rejects wildcards, non-http(s) schemes, missing
72
+ hosts, fragments, plain ``http`` on non-loopback hosts, and any URI not
73
+ matching ``DCR_ALLOWED_REDIRECTS``.
74
+ """
75
+ if not isinstance(uri, str) or not uri:
76
+ return False, "must be a non-empty string"
77
+ if "*" in uri:
78
+ return False, "wildcards are not allowed"
79
+
80
+ parsed = urlsplit(uri)
81
+ if parsed.scheme not in ("http", "https"):
82
+ return False, "scheme must be http or https"
83
+ if not parsed.netloc or not parsed.hostname:
84
+ return False, "must include a host"
85
+ if parsed.fragment:
86
+ return False, "must not contain a fragment"
87
+ if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
88
+ return False, "plain http is only allowed for loopback hosts"
89
+ if not _matches_allowed(uri, allowed_prefixes):
90
+ return False, "is not an allowed redirect target"
91
+
92
+ return True, ""
93
+
94
+
95
+ @method_decorator(csrf_exempt, name="dispatch")
96
+ class DynamicClientRegistrationView(View):
97
+ """RFC 7591 client registration endpoint (public clients, PKCE)."""
98
+
99
+ http_method_names = ["post"]
100
+
101
+ def post(self, request, *args, **kwargs):
102
+ if not get_setting("DCR_ENABLED"):
103
+ return _error(
104
+ 403, "access_denied", "Dynamic Client Registration is disabled."
105
+ )
106
+
107
+ try:
108
+ payload = json.loads(request.body or b"{}")
109
+ except (ValueError, TypeError):
110
+ return _error(
111
+ 400, "invalid_client_metadata", "Request body must be valid JSON."
112
+ )
113
+ if not isinstance(payload, dict):
114
+ return _error(
115
+ 400, "invalid_client_metadata", "Request body must be a JSON object."
116
+ )
117
+
118
+ redirect_uris = payload.get("redirect_uris")
119
+ if not isinstance(redirect_uris, list) or not redirect_uris:
120
+ return _error(
121
+ 400,
122
+ "invalid_redirect_uri",
123
+ "'redirect_uris' is required and must be a non-empty array.",
124
+ )
125
+ allowed_prefixes = list(get_setting("DCR_ALLOWED_REDIRECTS"))
126
+ for uri in redirect_uris:
127
+ ok, message = validate_redirect_uri(uri, allowed_prefixes)
128
+ if not ok:
129
+ return _error(
130
+ 400, "invalid_redirect_uri", f"Invalid redirect_uri {uri!r}: {message}."
131
+ )
132
+
133
+ auth_method = payload.get("token_endpoint_auth_method", TOKEN_ENDPOINT_AUTH_METHOD)
134
+ if auth_method != TOKEN_ENDPOINT_AUTH_METHOD:
135
+ return _error(
136
+ 400,
137
+ "invalid_client_metadata",
138
+ "Only public clients are supported "
139
+ "(token_endpoint_auth_method must be 'none').",
140
+ )
141
+
142
+ grant_types = payload.get("grant_types", ["authorization_code", "refresh_token"])
143
+ if (
144
+ not isinstance(grant_types, list)
145
+ or not set(grant_types) <= ALLOWED_GRANT_TYPES
146
+ or REQUIRED_GRANT_TYPE not in grant_types
147
+ ):
148
+ return _error(
149
+ 400,
150
+ "invalid_client_metadata",
151
+ "'grant_types' must be a subset of "
152
+ "['authorization_code', 'refresh_token'] including "
153
+ "'authorization_code'.",
154
+ )
155
+
156
+ response_types = payload.get("response_types", ["code"])
157
+ if not isinstance(response_types, list) or not set(response_types) <= ALLOWED_RESPONSE_TYPES:
158
+ return _error(
159
+ 400,
160
+ "invalid_client_metadata",
161
+ "'response_types' must be a subset of ['code'].",
162
+ )
163
+
164
+ allowed_scopes = list(get_scopes().keys())
165
+ requested_scope = payload.get("scope")
166
+ if requested_scope:
167
+ scopes = str(requested_scope).split()
168
+ if not set(scopes) <= set(allowed_scopes):
169
+ return _error(
170
+ 400,
171
+ "invalid_client_metadata",
172
+ f"'scope' must be a subset of {allowed_scopes}.",
173
+ )
174
+ else:
175
+ scopes = allowed_scopes
176
+
177
+ client_name = payload.get("client_name") or "Dynamic Client"
178
+
179
+ from oauth2_provider.models import get_application_model
180
+
181
+ Application = get_application_model()
182
+ application = Application(
183
+ name=client_name,
184
+ client_type=Application.CLIENT_PUBLIC,
185
+ authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
186
+ redirect_uris=" ".join(redirect_uris),
187
+ user=None,
188
+ skip_authorization=False,
189
+ )
190
+ application.save()
191
+
192
+ response = JsonResponse(
193
+ {
194
+ "client_id": application.client_id,
195
+ "client_id_issued_at": int(time.time()),
196
+ "redirect_uris": redirect_uris,
197
+ "token_endpoint_auth_method": TOKEN_ENDPOINT_AUTH_METHOD,
198
+ "grant_types": grant_types,
199
+ "response_types": response_types,
200
+ "client_name": client_name,
201
+ "scope": " ".join(scopes),
202
+ },
203
+ status=201,
204
+ )
205
+ response["Cache-Control"] = "no-store"
206
+ return response
@@ -0,0 +1,100 @@
1
+ """Authorization Server Metadata (RFC 8414) and Protected Resource Metadata.
2
+
3
+ The AS metadata document is served by :class:`~drf_react_by_schema_oauth.views.AuthorizationServerMetadataView`.
4
+ :func:`build_protected_resource_metadata` is a helper consumed by the resource
5
+ server (``drf-react-by-schema-mcp``) to serve RFC 9728 documents.
6
+ """
7
+
8
+ from django.urls import reverse
9
+
10
+ from .settings import get_scopes, get_setting
11
+
12
+ # RFC 8414 / OAuth 2.1 fixed values for this provider.
13
+ RESPONSE_TYPES_SUPPORTED = ["code"]
14
+ RESPONSE_MODES_SUPPORTED = ["query"]
15
+ GRANT_TYPES_SUPPORTED = ["authorization_code", "refresh_token"]
16
+ TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED = ["none"]
17
+ CODE_CHALLENGE_METHODS_SUPPORTED = ["S256"]
18
+
19
+
20
+ def _absolute(request, url_name):
21
+ return request.build_absolute_uri(reverse(url_name))
22
+
23
+
24
+ def get_issuer(request):
25
+ """Return the canonical issuer identifier.
26
+
27
+ ``DRBS_OAUTH["ISSUER"]`` wins when set; otherwise fall back to the request
28
+ origin (``scheme://host``). Setting ``ISSUER`` in production pins the value
29
+ regardless of proxy/Host header noise.
30
+ """
31
+ configured = get_setting("ISSUER")
32
+ if configured:
33
+ return configured.rstrip("/")
34
+ return request.build_absolute_uri("/").rstrip("/")
35
+
36
+
37
+ def build_authorization_server_metadata(request, *, issuer=None, scopes=None):
38
+ """Return the RFC 8414 metadata document for this authorization server.
39
+
40
+ ``issuer`` defaults to :func:`get_issuer`; ``scopes`` defaults to the keys
41
+ of the resolved ``DRBS_OAUTH["SCOPES"]``.
42
+ """
43
+ if issuer is None:
44
+ issuer = get_issuer(request)
45
+ if scopes is None:
46
+ scopes = list(get_scopes().keys())
47
+
48
+ return {
49
+ "issuer": issuer,
50
+ "authorization_endpoint": _absolute(request, "oauth2_provider:authorize"),
51
+ "token_endpoint": _absolute(request, "oauth2_provider:token"),
52
+ "revocation_endpoint": _absolute(request, "oauth2_provider:revoke-token"),
53
+ "introspection_endpoint": _absolute(request, "oauth2_provider:introspect"),
54
+ "registration_endpoint": _absolute(request, "dcr-register"),
55
+ "scopes_supported": list(scopes),
56
+ "response_types_supported": list(RESPONSE_TYPES_SUPPORTED),
57
+ "response_modes_supported": list(RESPONSE_MODES_SUPPORTED),
58
+ "grant_types_supported": list(GRANT_TYPES_SUPPORTED),
59
+ "token_endpoint_auth_methods_supported": list(
60
+ TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED
61
+ ),
62
+ "code_challenge_methods_supported": list(CODE_CHALLENGE_METHODS_SUPPORTED),
63
+ }
64
+
65
+
66
+ def build_protected_resource_metadata(
67
+ resource,
68
+ authorization_servers,
69
+ *,
70
+ scopes_supported=None,
71
+ bearer_methods_supported=None,
72
+ resource_name=None,
73
+ resource_documentation=None,
74
+ resource_policy_uri=None,
75
+ resource_tos_uri=None,
76
+ ):
77
+ """Build an RFC 9728 Protected Resource Metadata document.
78
+
79
+ ``resource`` is the protected resource identifier (e.g. ``https://host/mcp``)
80
+ and ``authorization_servers`` is a list of AS issuer identifiers (usually a
81
+ single entry — this package's issuer). Optional keyword arguments are only
82
+ included when provided.
83
+ """
84
+ data = {
85
+ "resource": resource,
86
+ "authorization_servers": list(authorization_servers),
87
+ }
88
+ if scopes_supported is not None:
89
+ data["scopes_supported"] = list(scopes_supported)
90
+ if bearer_methods_supported is not None:
91
+ data["bearer_methods_supported"] = list(bearer_methods_supported)
92
+ if resource_name is not None:
93
+ data["resource_name"] = resource_name
94
+ if resource_documentation is not None:
95
+ data["resource_documentation"] = resource_documentation
96
+ if resource_policy_uri is not None:
97
+ data["resource_policy_uri"] = resource_policy_uri
98
+ if resource_tos_uri is not None:
99
+ data["resource_tos_uri"] = resource_tos_uri
100
+ return data
@@ -0,0 +1,61 @@
1
+ """DRF permissions mapping OAuth scopes to HTTP methods.
2
+
3
+ The class is exposed under the frozen import path
4
+ ``drf_react_by_schema_oauth.permissions.HasScope``.
5
+ """
6
+
7
+ from rest_framework.permissions import SAFE_METHODS, BasePermission
8
+
9
+ from .settings import SCOPE_READ, SCOPE_WRITE
10
+
11
+
12
+ class HasScope(BasePermission):
13
+ """Require the OAuth token to carry the scope needed for the request.
14
+
15
+ Default mapping (when the view defines no ``required_scopes``):
16
+
17
+ * safe methods (``GET``/``HEAD``/``OPTIONS``/``TRACE``) require ``read``;
18
+ * unsafe methods (``POST``/``PUT``/``PATCH``/``DELETE``) require ``write``.
19
+
20
+ A view may set ``required_scopes = ["read", "write", ...]`` to require an
21
+ explicit set instead.
22
+
23
+ The permission is **composable and additive**:
24
+
25
+ * unauthenticated requests are denied (so it can be the only permission);
26
+ * requests authenticated by a **non-OAuth** class (Django session, JWT, …)
27
+ are *not* scope-checked here — they keep being governed by the project's
28
+ own DRF permissions;
29
+ * requests authenticated by ``OAuth2Authentication`` are checked against
30
+ their token's scopes.
31
+
32
+ This lets a project add OAuth to existing endpoints (accepting Bearer
33
+ tokens) without breaking its SPA's session/JWT authentication.
34
+ """
35
+
36
+ message = "The token does not grant the scope required for this request."
37
+
38
+ def get_required_scopes(self, request, view):
39
+ explicit = getattr(view, "required_scopes", None)
40
+ if explicit is not None:
41
+ return list(explicit)
42
+ if request.method in SAFE_METHODS:
43
+ return [SCOPE_READ]
44
+ return [SCOPE_WRITE]
45
+
46
+ def has_permission(self, request, view):
47
+ user = getattr(request, "user", None)
48
+ if user is None or not user.is_authenticated:
49
+ return False
50
+
51
+ token = getattr(request, "auth", None)
52
+ # Only an OAuth2 access token carries `scope`. A non-OAuth
53
+ # authentication (session/JWT) is left to the project's permissions.
54
+ if token is None or not hasattr(token, "scope"):
55
+ return True
56
+
57
+ required = self.get_required_scopes(request, view)
58
+ if not required:
59
+ return True
60
+ # Checks expiry and that the token's scopes are a superset of required.
61
+ return token.is_valid(required)
@@ -0,0 +1,52 @@
1
+ """``DRBS_OAUTH`` settings resolution.
2
+
3
+ Consumers configure the package through a single ``DRBS_OAUTH`` dict in their
4
+ Django settings. Every key is optional; :data:`DEFAULTS` holds the fallbacks and
5
+ :func:`get_setting` resolves a key at call time (so tests and ``override_settings``
6
+ behave correctly).
7
+ """
8
+
9
+ from django.conf import settings
10
+
11
+ # Frozen scope names (see PLAN §3).
12
+ SCOPE_READ = "read"
13
+ SCOPE_WRITE = "write"
14
+
15
+ DEFAULTS = {
16
+ # Canonical public base URL of the authorization server. When unset it is
17
+ # derived from the request origin; set it in production to avoid trusting
18
+ # the Host header / proxy scheme (RFC 8414 `issuer` must be stable).
19
+ "ISSUER": None,
20
+ "SCOPES": {
21
+ SCOPE_READ: "Read access to the API.",
22
+ SCOPE_WRITE: "Write access to the API.",
23
+ },
24
+ "DCR_ENABLED": True,
25
+ # Loopback-only by default (RFC 8252 / opencode). Entries are scheme+host
26
+ # prefixes; the DCR view matches redirect URIs against them.
27
+ "DCR_ALLOWED_REDIRECTS": [
28
+ "http://127.0.0.1",
29
+ "http://localhost",
30
+ "http://[::1]",
31
+ ],
32
+ "AUDIENCE": None,
33
+ "LOGIN_TEMPLATE": None,
34
+ "CONSENT_TEMPLATE": None,
35
+ "ACCESS_TOKEN_TTL": None,
36
+ "REFRESH_TOKEN_TTL": None,
37
+ }
38
+
39
+
40
+ def get_setting(name):
41
+ """Return ``DRBS_OAUTH[name]`` or its default. Raises for unknown keys."""
42
+ if name not in DEFAULTS:
43
+ raise KeyError(f"Unknown DRBS_OAUTH setting: {name!r}")
44
+ user = getattr(settings, "DRBS_OAUTH", None) or {}
45
+ if name in user:
46
+ return user[name]
47
+ return DEFAULTS[name]
48
+
49
+
50
+ def get_scopes():
51
+ """Return the ``{scope_name: description}`` mapping for DOT."""
52
+ return dict(get_setting("SCOPES"))
@@ -0,0 +1,45 @@
1
+ {% load i18n %}<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% trans "Authorize" %}</title>
7
+ <style>
8
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
9
+ background: #f5f5f5; margin: 0; padding: 40px 16px; }
10
+ .card { max-width: 460px; margin: 0 auto; background: #fff;
11
+ border: 1px solid #e5e5e5; border-radius: 8px; padding: 24px 28px;
12
+ box-shadow: 0 1px 3px rgba(0,0,0,.06); }
13
+ h1 { font-size: 1.25rem; margin: 0 0 12px; }
14
+ .actions { display: flex; gap: 12px; margin-top: 20px; }
15
+ .actions button { flex: 1; padding: 10px; border-radius: 6px;
16
+ font-size: 1rem; cursor: pointer; }
17
+ .deny { background: #fff; border: 1px solid #ccc; color: #333; }
18
+ .allow { background: #0b6bcb; border: 0; color: #fff; }
19
+ .errors { color: #b00020; margin: 8px 0; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <div class="card">
24
+ {% if not error %}
25
+ <h1>{% blocktrans with name=application.name %}Authorize {{ name }}?{% endblocktrans %}</h1>
26
+ <p>{% trans "The application is requesting the following permissions:" %}</p>
27
+ <ul>
28
+ {% for scope in scopes_descriptions %}<li>{{ scope }}</li>{% endfor %}
29
+ </ul>
30
+ <form id="authorizationForm" method="post">
31
+ {% csrf_token %}
32
+ {% for field in form %}{% if field.is_hidden %}{{ field }}{% endif %}{% endfor %}
33
+ <div class="errors">{{ form.errors }}{{ form.non_field_errors }}</div>
34
+ <div class="actions">
35
+ <button type="submit" class="deny">{% trans "Cancel" %}</button>
36
+ <button type="submit" name="allow" value="Authorize" class="allow">{% trans "Authorize" %}</button>
37
+ </div>
38
+ </form>
39
+ {% else %}
40
+ <h1>{% trans "Error" %}: {{ error.error }}</h1>
41
+ <p>{{ error.description }}</p>
42
+ {% endif %}
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,40 @@
1
+ {% load i18n %}<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% trans "Sign in" %}</title>
7
+ <style>
8
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
9
+ background: #f5f5f5; margin: 0; padding: 40px 16px; }
10
+ .card { max-width: 380px; margin: 0 auto; background: #fff;
11
+ border: 1px solid #e5e5e5; border-radius: 8px; padding: 24px 28px;
12
+ box-shadow: 0 1px 3px rgba(0,0,0,.06); }
13
+ h1 { font-size: 1.25rem; margin: 0 0 16px; }
14
+ label { display: block; font-weight: 600; margin: 12px 0 4px; }
15
+ input[type=text], input[type=password] { width: 100%; padding: 8px 10px;
16
+ border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; }
17
+ button { margin-top: 18px; width: 100%; padding: 10px; border: 0;
18
+ border-radius: 6px; background: #0b6bcb; color: #fff;
19
+ font-size: 1rem; cursor: pointer; }
20
+ .errors { color: #b00020; margin: 8px 0; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div class="card">
25
+ <h1>{% trans "Sign in" %}</h1>
26
+ {% if form.errors %}
27
+ <p class="errors">{% trans "Your username and password did not match." %}</p>
28
+ {% endif %}
29
+ <form method="post">
30
+ {% csrf_token %}
31
+ <label for="id_username">{% trans "Username" %}</label>
32
+ {{ form.username }}
33
+ <label for="id_password">{% trans "Password" %}</label>
34
+ {{ form.password }}
35
+ {% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
36
+ <button type="submit">{% trans "Sign in" %}</button>
37
+ </form>
38
+ </div>
39
+ </body>
40
+ </html>
File without changes
@@ -0,0 +1,41 @@
1
+ """Shared factories for the auth/scope/flow tests."""
2
+
3
+ from datetime import timedelta
4
+
5
+ from django.contrib.auth import get_user_model
6
+ from django.utils import timezone
7
+ from oauth2_provider.models import get_access_token_model, get_application_model
8
+
9
+ User = get_user_model()
10
+ Application = get_application_model()
11
+ AccessToken = get_access_token_model()
12
+
13
+
14
+ def make_user(username="testuser", password="pw"):
15
+ return User.objects.create_user(username=username, password=password)
16
+
17
+
18
+ def make_application(user=None, name="test-client", redirect_uris="http://localhost/cb"):
19
+ return Application.objects.create(
20
+ name=name,
21
+ user=user,
22
+ client_type=Application.CLIENT_PUBLIC,
23
+ authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
24
+ redirect_uris=redirect_uris,
25
+ )
26
+
27
+
28
+ def make_token(user, scope="read", token="tok", expires_in=3600):
29
+ """Create a DOT access token. ``expires_in=None`` yields an expired token."""
30
+ application = make_application(user)
31
+ if expires_in is None:
32
+ expires = timezone.now() - timedelta(seconds=1)
33
+ else:
34
+ expires = timezone.now() + timedelta(seconds=expires_in)
35
+ return AccessToken.objects.create(
36
+ user=user,
37
+ application=application,
38
+ token=token,
39
+ scope=scope,
40
+ expires=expires,
41
+ )
@@ -0,0 +1,24 @@
1
+ from unittest.mock import patch
2
+
3
+ from django.apps import apps
4
+ from django.core.exceptions import ImproperlyConfigured
5
+ from django.test import SimpleTestCase
6
+
7
+
8
+ class AppConfigGuardTests(SimpleTestCase):
9
+ def test_ready_raises_when_dot_is_missing(self):
10
+ config = apps.get_app_config("drbs_oauth")
11
+ with patch("django.apps.apps.get_app_config", side_effect=LookupError):
12
+ with self.assertRaises(ImproperlyConfigured) as ctx:
13
+ config.ready()
14
+ self.assertIn("django-oauth-toolkit", str(ctx.exception))
15
+
16
+ def test_ready_is_quiet_when_dot_present(self):
17
+ config = apps.get_app_config("drbs_oauth")
18
+ # No exception when oauth2_provider is installed (the normal case).
19
+ config.ready()
20
+
21
+ def test_label_is_frozen(self):
22
+ config = apps.get_app_config("drbs_oauth")
23
+ self.assertEqual(config.label, "drbs_oauth")
24
+ self.assertEqual(config.name, "drf_react_by_schema_oauth")