django-suap-auth 1.0.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kelsoncm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-suap-auth
3
+ Version: 1.0.1
4
+ Summary: Django OAuth2 authentication backend for SUAP (IFRN academic management system)
5
+ Author: kelsoncm
6
+ Classifier: Development Status :: 5 - Production/Stable
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Framework :: Django :: 6.0
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.md
21
+ Requires-Dist: Django<7,>=5.2
22
+ Requires-Dist: requests>=2.31
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: pytest-django>=4.8; extra == "dev"
26
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
27
+ Requires-Dist: responses>=0.25; extra == "dev"
28
+ Requires-Dist: black>=24.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+ Requires-Dist: pre-commit>=3.7; extra == "dev"
31
+ Requires-Dist: python-dotenv>=1.0; extra == "dev"
32
+ Requires-Dist: mkdocs>=1.6; extra == "dev"
33
+ Requires-Dist: mkdocs-material>=9.5; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # django-suap-auth
37
+
38
+ [![PyPI Version](https://img.shields.io/pypi/v/django-suap-auth)](https://pypi.org/project/django-suap-auth/)
39
+ [![Python CI and PyPI Deploy](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml/badge.svg)](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml)
40
+ [![Python Versions](https://img.shields.io/pypi/pyversions/django-suap-auth.svg)](https://pypi.org/project/django-suap-auth/)
41
+ [![Django Versions](https://img.shields.io/badge/django-5.2%20|%206.0-blue)](https://www.djangoproject.com/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
43
+ [![Tests](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml/badge.svg)](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml)
44
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
45
+ [![Coverage](https://codecov.io/gh/kelsoncm/django-suap-auth/branch/main/graph/badge.svg)](https://codecov.io/gh/kelsoncm/django-suap-auth)
46
+
47
+ Backend de autenticação OAuth2 do Django para **SUAP** (Sistema Unificado de Administração Pública), o sistema de gestão acadêmica do [IFRN](https://www.ifrn.edu.br), hospedado em [suap.ifrn.edu.br](https://suap.ifrn.edu.br).
48
+
49
+ ## Instalação
50
+
51
+ ```bash
52
+ pip install django-suap-auth
53
+ ```
54
+
55
+ ## Início Rápido
56
+
57
+ ```python
58
+ # settings.py
59
+ INSTALLED_APPS = [
60
+ ...
61
+ "django_suap_auth",
62
+ ]
63
+
64
+ AUTHENTICATION_BACKENDS = [
65
+ "django_suap_auth.backends.SuapAuthBackend",
66
+ "django.contrib.auth.backends.ModelBackend",
67
+ ]
68
+
69
+ SUAP_CLIENT_ID = "your-client-id"
70
+ SUAP_CLIENT_SECRET = "your-client-secret"
71
+ SUAP_REDIRECT_URI = "https://yourapp.example.com/auth/suap/callback/"
72
+ SUAP_AUTH_SCOPES = ["identificacao", "email"] # opcional
73
+ LOGIN_REDIRECT_URL = "/dashboard/"
74
+ LOGIN_URL = "/login/"
75
+ ```
76
+
77
+ ```python
78
+ # urls.py
79
+ from django.urls import path, include
80
+
81
+ urlpatterns = [
82
+ path("auth/suap/", include("django_suap_auth.urls")),
83
+ ...
84
+ ]
85
+ ```
86
+
87
+ ```html
88
+ <!-- template -->
89
+ <a href="{% url 'suap_auth:login' %}">Login com SUAP</a>
90
+ ```
91
+
92
+ ## Escopos Disponíveis
93
+
94
+ | Escopo | Descrição |
95
+ |--------|-----------|
96
+ | `identificacao` | Identificação básica (matricula, nome, campus) |
97
+ | `email` | Endereço de email |
98
+ | `documentos_pessoais` | Documentos pessoais (CPF, RG) |
99
+ | `dados_academicos` | Dados acadêmicos (curso, notas, situação) |
100
+ | `dados_pessoais` | Dados pessoais (data de nascimento, nacionalidade) |
101
+ | `reitoria` | Dados de nível institucional |
102
+
103
+ ## Documentação
104
+
105
+ Documentação completa disponível no diretório [`docs/`](docs/) e nas GitHub Pages do projeto.
106
+
107
+ ## Licença
108
+
109
+ MIT © 2026 kelsoncm
@@ -0,0 +1,74 @@
1
+ # django-suap-auth
2
+
3
+ [![PyPI Version](https://img.shields.io/pypi/v/django-suap-auth)](https://pypi.org/project/django-suap-auth/)
4
+ [![Python CI and PyPI Deploy](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml/badge.svg)](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml)
5
+ [![Python Versions](https://img.shields.io/pypi/pyversions/django-suap-auth.svg)](https://pypi.org/project/django-suap-auth/)
6
+ [![Django Versions](https://img.shields.io/badge/django-5.2%20|%206.0-blue)](https://www.djangoproject.com/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Tests](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml/badge.svg)](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml)
9
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
10
+ [![Coverage](https://codecov.io/gh/kelsoncm/django-suap-auth/branch/main/graph/badge.svg)](https://codecov.io/gh/kelsoncm/django-suap-auth)
11
+
12
+ Backend de autenticação OAuth2 do Django para **SUAP** (Sistema Unificado de Administração Pública), o sistema de gestão acadêmica do [IFRN](https://www.ifrn.edu.br), hospedado em [suap.ifrn.edu.br](https://suap.ifrn.edu.br).
13
+
14
+ ## Instalação
15
+
16
+ ```bash
17
+ pip install django-suap-auth
18
+ ```
19
+
20
+ ## Início Rápido
21
+
22
+ ```python
23
+ # settings.py
24
+ INSTALLED_APPS = [
25
+ ...
26
+ "django_suap_auth",
27
+ ]
28
+
29
+ AUTHENTICATION_BACKENDS = [
30
+ "django_suap_auth.backends.SuapAuthBackend",
31
+ "django.contrib.auth.backends.ModelBackend",
32
+ ]
33
+
34
+ SUAP_CLIENT_ID = "your-client-id"
35
+ SUAP_CLIENT_SECRET = "your-client-secret"
36
+ SUAP_REDIRECT_URI = "https://yourapp.example.com/auth/suap/callback/"
37
+ SUAP_AUTH_SCOPES = ["identificacao", "email"] # opcional
38
+ LOGIN_REDIRECT_URL = "/dashboard/"
39
+ LOGIN_URL = "/login/"
40
+ ```
41
+
42
+ ```python
43
+ # urls.py
44
+ from django.urls import path, include
45
+
46
+ urlpatterns = [
47
+ path("auth/suap/", include("django_suap_auth.urls")),
48
+ ...
49
+ ]
50
+ ```
51
+
52
+ ```html
53
+ <!-- template -->
54
+ <a href="{% url 'suap_auth:login' %}">Login com SUAP</a>
55
+ ```
56
+
57
+ ## Escopos Disponíveis
58
+
59
+ | Escopo | Descrição |
60
+ |--------|-----------|
61
+ | `identificacao` | Identificação básica (matricula, nome, campus) |
62
+ | `email` | Endereço de email |
63
+ | `documentos_pessoais` | Documentos pessoais (CPF, RG) |
64
+ | `dados_academicos` | Dados acadêmicos (curso, notas, situação) |
65
+ | `dados_pessoais` | Dados pessoais (data de nascimento, nacionalidade) |
66
+ | `reitoria` | Dados de nível institucional |
67
+
68
+ ## Documentação
69
+
70
+ Documentação completa disponível no diretório [`docs/`](docs/) e nas GitHub Pages do projeto.
71
+
72
+ ## Licença
73
+
74
+ MIT © 2026 kelsoncm
@@ -0,0 +1 @@
1
+ default_app_config = "django_suap_auth.apps.SuapAuthConfig"
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class SuapAuthConfig(AppConfig):
5
+ name = "django_suap_auth"
6
+ verbose_name = "SUAP Auth"
@@ -0,0 +1,155 @@
1
+ from django.contrib.auth import get_user_model
2
+
3
+ from .exceptions import SuapUserNotAllowedError
4
+ from .utils import apply_user_attr_map, get_suap_settings
5
+
6
+
7
+ def _filter_fields(attrs, allowed):
8
+ """Return a subset of *attrs* restricted to *allowed* field names.
9
+
10
+ ``allowed=None`` means no restriction (return all fields).
11
+ ``allowed=[]`` means return an empty dict.
12
+ """
13
+ if allowed is None:
14
+ return dict(attrs)
15
+ return {k: v for k, v in attrs.items() if k in allowed}
16
+
17
+
18
+ class SuapAuthBackend:
19
+ """
20
+ Django authentication backend for SUAP OAuth2.
21
+
22
+ Looks up or creates a Django user based on the profile info returned by SUAP.
23
+
24
+ Behaviour is controlled by ``SUAP_AUTH`` settings:
25
+
26
+ * ``CREATE_USER`` (bool, default ``True``) — when ``False``, raises
27
+ :exc:`~django_suap_auth.exceptions.SuapUserNotAllowedError` for users
28
+ that do not yet have a local account.
29
+ * ``USER_DEFAULTS`` (dict, default ``{"is_active": True}``) — extra
30
+ field values applied only when creating a new user.
31
+ * ``UPDATE_FIELDS_ON_CREATE`` (list or ``None``, default ``None``) —
32
+ mapped fields written when a new user is created. ``None`` means all
33
+ mapped fields; ``[]`` means none.
34
+ * ``UPDATE_FIELDS_ON_LOGIN`` (list or ``None``, default ``None``) —
35
+ mapped fields synced on every subsequent login. ``None`` means all
36
+ mapped fields; ``[]`` means none.
37
+ * ``FIRST_USER_DEFAULTS`` (dict or ``None``, default ``None``) — when set,
38
+ these field values are used instead of ``USER_DEFAULTS`` if no users
39
+ exist yet (e.g. ``{"is_superuser": True, "is_staff": True}``).
40
+
41
+ Override individual methods to customise behaviour without rewriting
42
+ ``authenticate`` entirely:
43
+
44
+ * :meth:`get_user_attrs` — map raw SUAP info to model field dict.
45
+ * :meth:`get_lookup_value` — extract the lookup key from mapped attrs.
46
+ * :meth:`get_or_create_user` — fetch or create the local user.
47
+ * :meth:`create_user` — instantiate and save a brand-new user.
48
+ * :meth:`update_user` — sync fields on an existing user.
49
+ """
50
+
51
+ def authenticate(self, request, suap_user_info=None, **kwargs):
52
+ if suap_user_info is None:
53
+ return None
54
+
55
+ cfg = get_suap_settings()
56
+ lookup_field = cfg["user_lookup_field"]
57
+
58
+ attrs = self.get_user_attrs(suap_user_info, cfg)
59
+ lookup_value = self.get_lookup_value(attrs, lookup_field)
60
+ if not lookup_value:
61
+ return None
62
+
63
+ mapped_attrs = {k: v for k, v in attrs.items() if k != lookup_field}
64
+ return self.get_or_create_user(lookup_field, lookup_value, mapped_attrs, cfg)
65
+
66
+ # ------------------------------------------------------------------
67
+ # Extension points
68
+ # ------------------------------------------------------------------
69
+
70
+ def get_user_attrs(self, suap_user_info, cfg):
71
+ """Return a dict of model-field → value built from *suap_user_info*.
72
+
73
+ Override to add, remove, or transform attributes before the user is
74
+ looked up or created.
75
+ """
76
+ attrs = apply_user_attr_map(suap_user_info, cfg["user_attr_map"])
77
+ if cfg["json_field"]:
78
+ attrs[cfg["json_field"]] = suap_user_info
79
+ return attrs
80
+
81
+ def get_lookup_value(self, attrs, lookup_field):
82
+ """Return the value used to look up the local user record.
83
+
84
+ Return ``None`` to abort authentication (backend returns ``None``).
85
+ """
86
+ return attrs.get(lookup_field)
87
+
88
+ def get_or_create_user(self, lookup_field, lookup_value, mapped_attrs, cfg):
89
+ """Fetch the existing user or create a new one.
90
+
91
+ Raises :exc:`~django_suap_auth.exceptions.SuapUserNotAllowedError`
92
+ when the user does not exist and ``CREATE_USER`` is ``False``.
93
+ """
94
+ User = get_user_model()
95
+ try:
96
+ user = User.objects.get(**{lookup_field: lookup_value})
97
+ except User.DoesNotExist:
98
+ if not cfg["create_user"]:
99
+ raise SuapUserNotAllowedError(
100
+ f"No local account for SUAP user '{lookup_value}' and CREATE_USER is disabled."
101
+ )
102
+ return self.create_user(lookup_field, lookup_value, mapped_attrs, cfg)
103
+
104
+ return self.update_user(user, mapped_attrs, cfg)
105
+
106
+ def create_user(self, lookup_field, lookup_value, mapped_attrs, cfg):
107
+ """Instantiate, populate, and save a brand-new local user.
108
+
109
+ When ``FIRST_USER_DEFAULTS`` is configured and no users exist yet, those
110
+ defaults are used instead of ``USER_DEFAULTS``.
111
+
112
+ Override to hook into the creation process (e.g. send a welcome e-mail,
113
+ assign groups, etc.).
114
+ """
115
+ User = get_user_model()
116
+ first_defaults = cfg["first_user_defaults"]
117
+ if first_defaults is not None and not User.objects.exists():
118
+ defaults = dict(first_defaults)
119
+ else:
120
+ defaults = dict(cfg["user_defaults"])
121
+ defaults.update(_filter_fields(mapped_attrs, cfg["update_fields_on_create"]))
122
+ user = User(**{lookup_field: lookup_value}, **defaults)
123
+ user.save()
124
+ return user
125
+
126
+ def update_user(self, user, mapped_attrs, cfg):
127
+ """Sync *mapped_attrs* onto an existing *user* and save if changed.
128
+
129
+ Override to add custom logic on every login (e.g. update group
130
+ membership based on SUAP roles).
131
+ """
132
+ changed = False
133
+
134
+ for field, value in _filter_fields(mapped_attrs, cfg["update_fields_on_login"]).items():
135
+ if getattr(user, field, None) != value:
136
+ setattr(user, field, value)
137
+ changed = True
138
+
139
+ # Always enforce user_defaults (e.g. reactivate a deactivated account)
140
+ for field, value in cfg["user_defaults"].items():
141
+ if getattr(user, field, None) != value:
142
+ setattr(user, field, value)
143
+ changed = True
144
+
145
+ if changed:
146
+ user.save()
147
+
148
+ return user
149
+
150
+ def get_user(self, user_id):
151
+ User = get_user_model()
152
+ try:
153
+ return User.objects.get(pk=user_id)
154
+ except User.DoesNotExist:
155
+ return None
@@ -0,0 +1,79 @@
1
+ from urllib.parse import urlencode
2
+
3
+ import requests
4
+
5
+ from .exceptions import SuapTokenError, SuapUserInfoError
6
+
7
+ DEFAULT_BASE_URL = "https://suap.ifrn.edu.br"
8
+
9
+ AUTHORIZE_PATH = "/o/authorize/"
10
+ TOKEN_PATH = "/o/token/"
11
+ USER_INFO_PATH = "/api/rh/eu/"
12
+
13
+ AVAILABLE_SCOPES = [
14
+ "identificacao",
15
+ "email",
16
+ "documentos_pessoais",
17
+ "dados_academicos",
18
+ "dados_pessoais",
19
+ "reitoria",
20
+ ]
21
+
22
+
23
+ class SuapOAuth2Client:
24
+ """Handles the OAuth2 authorization code flow with SUAP."""
25
+
26
+ def __init__(self, client_id, client_secret, redirect_uri, scopes=None, base_url=None):
27
+ self.client_id = client_id
28
+ self.client_secret = client_secret
29
+ self.redirect_uri = redirect_uri
30
+ self.scopes = scopes or ["identificacao", "email"]
31
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
32
+ self._session = requests.Session()
33
+
34
+ def get_authorization_url(self, state):
35
+ """Return the full authorization URL to redirect the user to."""
36
+ params = {
37
+ "response_type": "code",
38
+ "client_id": self.client_id,
39
+ "redirect_uri": self.redirect_uri,
40
+ "scope": " ".join(self.scopes),
41
+ "state": state,
42
+ }
43
+ return f"{self.base_url}{AUTHORIZE_PATH}?{urlencode(params)}"
44
+
45
+ def exchange_code_for_token(self, code, timeout=30):
46
+ """Exchange an authorization code for an access token."""
47
+ url = f"{self.base_url}{TOKEN_PATH}"
48
+ data = {
49
+ "client_id": self.client_id,
50
+ "client_secret": self.client_secret,
51
+ "code": code,
52
+ "redirect_uri": self.redirect_uri,
53
+ "grant_type": "authorization_code",
54
+ }
55
+ try:
56
+ response = self._session.post(url, data=data, timeout=timeout)
57
+ response.raise_for_status()
58
+ return response.json()
59
+ except requests.HTTPError as exc:
60
+ raise SuapTokenError(f"Token exchange failed: {exc}") from exc
61
+ except requests.RequestException as exc:
62
+ raise SuapTokenError(f"Token exchange request error: {exc}") from exc
63
+ except Exception as exc:
64
+ raise SuapTokenError(f"Token exchange unexpected error: {exc}") from exc
65
+
66
+ def get_user_info(self, access_token, timeout=30):
67
+ """Fetch the authenticated user's profile from SUAP."""
68
+ url = f"{self.base_url}{USER_INFO_PATH}"
69
+ headers = {"Authorization": f"Bearer {access_token}"}
70
+ try:
71
+ response = self._session.get(url, headers=headers, timeout=timeout)
72
+ response.raise_for_status()
73
+ return response.json()
74
+ except requests.HTTPError as exc:
75
+ raise SuapUserInfoError(f"Failed to fetch user info: {exc}") from exc
76
+ except requests.RequestException as exc:
77
+ raise SuapUserInfoError(f"User info request error: {exc}") from exc
78
+ except Exception as exc:
79
+ raise SuapUserInfoError(f"User info unexpected error: {exc}") from exc
@@ -0,0 +1,26 @@
1
+ class SuapAuthError(Exception):
2
+ """Base exception for django-suap-auth."""
3
+
4
+
5
+ class SuapTokenError(SuapAuthError):
6
+ """Raised when the OAuth2 token exchange fails."""
7
+
8
+
9
+ class SuapUserInfoError(SuapAuthError):
10
+ """Raised when fetching user info from SUAP fails."""
11
+
12
+
13
+ class SuapStateMismatchError(SuapAuthError):
14
+ """Raised when the OAuth2 state parameter does not match (possible CSRF)."""
15
+
16
+
17
+ class SuapAPIError(SuapAuthError):
18
+ """Raised when a SUAP API call fails."""
19
+
20
+ def __init__(self, message, status_code=None):
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+
24
+
25
+ class SuapUserNotAllowedError(SuapAuthError):
26
+ """Raised when a SUAP user has no local account and CREATE_USER is False."""
@@ -0,0 +1,10 @@
1
+ from django.urls import path
2
+
3
+ from .views import SuapCallbackView, SuapLoginView
4
+
5
+ app_name = "suap_auth"
6
+
7
+ urlpatterns = [
8
+ path("login/", SuapLoginView.as_view(), name="login"),
9
+ path("callback/", SuapCallbackView.as_view(), name="callback"),
10
+ ]
@@ -0,0 +1,130 @@
1
+ import secrets
2
+
3
+ from django.core.exceptions import ImproperlyConfigured
4
+
5
+ # Default mapping: user model field → SUAP response key.
6
+ # A tuple key means "split the SUAP value on the first space and assign
7
+ # the first part to key[0] and the remainder to key[1]".
8
+ DEFAULT_USER_ATTR_MAP = {
9
+ "username": "identificacao",
10
+ "email": "email",
11
+ ("first_name", "last_name"): "nome_usual",
12
+ }
13
+
14
+
15
+ def get_suap_settings():
16
+ """Read and validate SUAP settings from Django settings.
17
+
18
+ Expects a single SUAP_AUTH dictionary with all configuration:
19
+
20
+ SUAP_AUTH = {
21
+ 'CLIENT_ID': 'your-id',
22
+ 'CLIENT_SECRET': 'your-secret',
23
+ 'REDIRECT_URI': 'https://example.com/callback/',
24
+ 'BASE_URL': 'https://suap.ifrn.edu.br', # optional
25
+ 'SCOPES': ['identificacao', 'email'], # optional
26
+ 'USER_LOOKUP_FIELD': 'username', # optional
27
+ 'USER_ATTR_MAP': {...}, # optional
28
+ 'USER_JSON_FIELD': None, # optional
29
+ 'DIRECT_REDIRECT': True, # optional
30
+ }
31
+ """
32
+ from django.conf import settings
33
+
34
+ suap_auth = getattr(settings, "SUAP_AUTH", {})
35
+
36
+ # Validate required fields
37
+ required = ['CLIENT_ID', 'CLIENT_SECRET', 'REDIRECT_URI']
38
+ missing = [field for field in required if not suap_auth.get(field)]
39
+
40
+ if missing:
41
+ raise ImproperlyConfigured(
42
+ f"Missing required SUAP_AUTH settings: {', '.join(missing)}. "
43
+ f"Configure SUAP_AUTH dictionary in settings.py"
44
+ )
45
+
46
+ return {
47
+ "client_id": suap_auth["CLIENT_ID"],
48
+ "client_secret": suap_auth["CLIENT_SECRET"],
49
+ "redirect_uri": suap_auth["REDIRECT_URI"],
50
+ "scopes": suap_auth.get("SCOPES", ["identificacao", "email"]),
51
+ "base_url": suap_auth.get("BASE_URL", "https://suap.ifrn.edu.br"),
52
+ "user_lookup_field": suap_auth.get("USER_LOOKUP_FIELD", "username"),
53
+ "user_attr_map": suap_auth.get("USER_ATTR_MAP", DEFAULT_USER_ATTR_MAP),
54
+ "json_field": suap_auth.get("USER_JSON_FIELD", None),
55
+ "direct_redirect": suap_auth.get("DIRECT_REDIRECT", True),
56
+ "backend": suap_auth.get("BACKEND", "django_suap_auth.backends.SuapAuthBackend"),
57
+ "create_user": suap_auth.get("CREATE_USER", True),
58
+ "user_defaults": suap_auth.get("USER_DEFAULTS", {"is_active": True}),
59
+ "first_user_defaults": suap_auth.get("FIRST_USER_DEFAULTS", None),
60
+ # None = all mapped fields; [] = none; ["field", ...] = only those listed
61
+ "update_fields_on_create": suap_auth.get("UPDATE_FIELDS_ON_CREATE", None),
62
+ "update_fields_on_login": suap_auth.get("UPDATE_FIELDS_ON_LOGIN", None),
63
+ }
64
+
65
+
66
+ def _extract_nested(data, dotted_key):
67
+ """Extract a value from a (possibly nested) dict using a dotted key path.
68
+
69
+ Example: _extract_nested(data, "dados_pessoais.data_nascimento")
70
+ """
71
+ keys = dotted_key.split(".")
72
+ value = data
73
+ for key in keys:
74
+ if not isinstance(value, dict):
75
+ return None
76
+ value = value.get(key)
77
+ if value is None:
78
+ return None
79
+ return value
80
+
81
+
82
+ def apply_user_attr_map(user_info, attr_map):
83
+ """Translate a SUAP user_info dict into a flat dict of user model field→value pairs.
84
+
85
+ The ``attr_map`` uses the convention ``{model_field: suap_key}``:
86
+
87
+ - **Plain string key**: maps the SUAP field to the given user model field.
88
+ - **Tuple key** ``(field_a, field_b)``: splits the SUAP value on the first space;
89
+ the part before the space goes to ``field_a`` and everything after to ``field_b``.
90
+ - **Dotted SUAP key** (e.g. ``"dados_pessoais.data_nascimento"``): traverses nested
91
+ dicts in the SUAP response.
92
+ - Special key ``"fulljson"``: maps the entire raw SUAP response dict to the field.
93
+ Suitable for a ``JSONField`` or any field that accepts a dict.
94
+ - If the SUAP value is ``None`` or absent, the field is skipped.
95
+ """
96
+ result = {}
97
+ for model_field, suap_key in attr_map.items():
98
+ if suap_key == "fulljson":
99
+ result[model_field] = user_info
100
+ continue
101
+ value = _extract_nested(user_info, suap_key)
102
+ if value is None:
103
+ continue
104
+ if isinstance(model_field, (list, tuple)) and len(model_field) == 2:
105
+ field_a, field_b = model_field
106
+ parts = str(value).split(" ", 1)
107
+ result[field_a] = parts[0]
108
+ result[field_b] = parts[1] if len(parts) > 1 else ""
109
+ else:
110
+ result[model_field] = value
111
+ return result
112
+
113
+
114
+ def get_oauth2_client():
115
+ """Return a SuapOAuth2Client configured from Django settings."""
116
+ from .client import SuapOAuth2Client
117
+
118
+ cfg = get_suap_settings()
119
+ return SuapOAuth2Client(
120
+ client_id=cfg["client_id"],
121
+ client_secret=cfg["client_secret"],
122
+ redirect_uri=cfg["redirect_uri"],
123
+ scopes=cfg["scopes"],
124
+ base_url=cfg["base_url"],
125
+ )
126
+
127
+
128
+ def generate_state():
129
+ """Generate a cryptographically secure random state token for OAuth2 CSRF protection."""
130
+ return secrets.token_urlsafe(32)