diracx-logic 0.0.1a29__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 (26) hide show
  1. diracx_logic-0.0.1a29/PKG-INFO +18 -0
  2. diracx_logic-0.0.1a29/README.md +0 -0
  3. diracx_logic-0.0.1a29/pyproject.toml +40 -0
  4. diracx_logic-0.0.1a29/setup.cfg +4 -0
  5. diracx_logic-0.0.1a29/src/diracx/logic/__init__.py +0 -0
  6. diracx_logic-0.0.1a29/src/diracx/logic/auth/__init__.py +0 -0
  7. diracx_logic-0.0.1a29/src/diracx/logic/auth/authorize_code_flow.py +99 -0
  8. diracx_logic-0.0.1a29/src/diracx/logic/auth/device_flow.py +100 -0
  9. diracx_logic-0.0.1a29/src/diracx/logic/auth/management.py +32 -0
  10. diracx_logic-0.0.1a29/src/diracx/logic/auth/token.py +396 -0
  11. diracx_logic-0.0.1a29/src/diracx/logic/auth/utils.py +288 -0
  12. diracx_logic-0.0.1a29/src/diracx/logic/auth/well_known.py +63 -0
  13. diracx_logic-0.0.1a29/src/diracx/logic/jobs/__init__.py +0 -0
  14. diracx_logic-0.0.1a29/src/diracx/logic/jobs/query.py +97 -0
  15. diracx_logic-0.0.1a29/src/diracx/logic/jobs/sandboxes.py +186 -0
  16. diracx_logic-0.0.1a29/src/diracx/logic/jobs/status.py +476 -0
  17. diracx_logic-0.0.1a29/src/diracx/logic/jobs/submission.py +257 -0
  18. diracx_logic-0.0.1a29/src/diracx/logic/jobs/utils.py +40 -0
  19. diracx_logic-0.0.1a29/src/diracx/logic/py.typed +0 -0
  20. diracx_logic-0.0.1a29/src/diracx/logic/task_queues/__init__.py +0 -0
  21. diracx_logic-0.0.1a29/src/diracx/logic/task_queues/priority.py +151 -0
  22. diracx_logic-0.0.1a29/src/diracx_logic.egg-info/PKG-INFO +18 -0
  23. diracx_logic-0.0.1a29/src/diracx_logic.egg-info/SOURCES.txt +24 -0
  24. diracx_logic-0.0.1a29/src/diracx_logic.egg-info/dependency_links.txt +1 -0
  25. diracx_logic-0.0.1a29/src/diracx_logic.egg-info/requires.txt +7 -0
  26. diracx_logic-0.0.1a29/src/diracx_logic.egg-info/top_level.txt +1 -0
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.2
2
+ Name: diracx-logic
3
+ Version: 0.0.1a29
4
+ Summary: TODO
5
+ License: GPL-3.0-only
6
+ Classifier: Intended Audience :: Science/Research
7
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering
10
+ Classifier: Topic :: System :: Distributed Computing
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: cachetools
14
+ Requires-Dist: dirac
15
+ Requires-Dist: diracx-core
16
+ Requires-Dist: pydantic>=2.10
17
+ Provides-Extra: types
18
+ Requires-Dist: types-cachetools; extra == "types"
File without changes
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "diracx-logic"
3
+ description = "TODO"
4
+ readme = "README.md"
5
+ requires-python = ">=3.11"
6
+ keywords = []
7
+ license = {text = "GPL-3.0-only"}
8
+ classifiers = [
9
+ "Intended Audience :: Science/Research",
10
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
11
+ "Programming Language :: Python :: 3",
12
+ "Topic :: Scientific/Engineering",
13
+ "Topic :: System :: Distributed Computing",
14
+ ]
15
+ dependencies = [
16
+ "cachetools",
17
+ "dirac",
18
+ "diracx-core",
19
+ "pydantic >=2.10",
20
+ ]
21
+ dynamic = ["version"]
22
+
23
+ [project.optional-dependencies]
24
+ types = [
25
+ "types-cachetools",
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [build-system]
32
+ requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"]
33
+ build-backend = "setuptools.build_meta"
34
+
35
+ [tool.setuptools_scm]
36
+ root = ".."
37
+
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,99 @@
1
+ """Authorization code flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from diracx.core.config import Config
8
+ from diracx.core.models import GrantType
9
+ from diracx.core.properties import SecurityProperty
10
+ from diracx.core.settings import AuthSettings
11
+ from diracx.db.sql import AuthDB
12
+
13
+ from .utils import (
14
+ decrypt_state,
15
+ get_token_from_iam,
16
+ initiate_authorization_flow_with_iam,
17
+ parse_and_validate_scope,
18
+ )
19
+
20
+
21
+ async def initiate_authorization_flow(
22
+ request_url: str,
23
+ code_challenge: str,
24
+ code_challenge_method: Literal["S256"],
25
+ client_id: str,
26
+ redirect_uri: str,
27
+ scope: str,
28
+ state: str,
29
+ auth_db: AuthDB,
30
+ config: Config,
31
+ settings: AuthSettings,
32
+ available_properties: set[SecurityProperty],
33
+ ) -> str:
34
+ """Initiate the authorization flow."""
35
+ if settings.dirac_client_id != client_id:
36
+ raise ValueError("Unrecognised client_id")
37
+ if redirect_uri not in settings.allowed_redirects:
38
+ raise ValueError("Unrecognised redirect_uri")
39
+
40
+ # Parse and validate the scope
41
+ parsed_scope = parse_and_validate_scope(scope, config, available_properties)
42
+
43
+ # Store the authorization flow details
44
+ uuid = await auth_db.insert_authorization_flow(
45
+ client_id,
46
+ scope,
47
+ code_challenge,
48
+ code_challenge_method,
49
+ redirect_uri,
50
+ )
51
+
52
+ # Initiate the authorization flow with the IAM
53
+ state_for_iam = {
54
+ "external_state": state,
55
+ "uuid": uuid,
56
+ "grant_type": GrantType.authorization_code.value,
57
+ }
58
+
59
+ authorization_flow_url = await initiate_authorization_flow_with_iam(
60
+ config,
61
+ parsed_scope["vo"],
62
+ f"{request_url}/complete",
63
+ state_for_iam,
64
+ settings.state_key.fernet,
65
+ )
66
+
67
+ return authorization_flow_url
68
+
69
+
70
+ async def complete_authorization_flow(
71
+ code: str,
72
+ state: str,
73
+ request_url: str,
74
+ auth_db: AuthDB,
75
+ config: Config,
76
+ settings: AuthSettings,
77
+ ) -> str:
78
+ """Complete the authorization flow."""
79
+ # Decrypt the state to access user details
80
+ decrypted_state = decrypt_state(state, settings.state_key.fernet)
81
+ assert decrypted_state["grant_type"] == GrantType.authorization_code
82
+
83
+ # Get the ID token from the IAM
84
+ id_token = await get_token_from_iam(
85
+ config,
86
+ decrypted_state["vo"],
87
+ code,
88
+ decrypted_state,
89
+ request_url,
90
+ )
91
+
92
+ # Store the ID token and redirect the user to the client's redirect URI
93
+ code, redirect_uri = await auth_db.authorization_flow_insert_id_token(
94
+ decrypted_state["uuid"],
95
+ id_token,
96
+ settings.authorization_flow_expiration_seconds,
97
+ )
98
+
99
+ return f"{redirect_uri}?code={code}&state={decrypted_state['external_state']}"
@@ -0,0 +1,100 @@
1
+ """Device flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from diracx.core.config import Config
6
+ from diracx.core.models import GrantType, InitiateDeviceFlowResponse
7
+ from diracx.core.properties import SecurityProperty
8
+ from diracx.core.settings import AuthSettings
9
+ from diracx.db.sql import AuthDB
10
+
11
+ from .utils import (
12
+ decrypt_state,
13
+ get_token_from_iam,
14
+ initiate_authorization_flow_with_iam,
15
+ parse_and_validate_scope,
16
+ )
17
+
18
+
19
+ async def initiate_device_flow(
20
+ client_id: str,
21
+ scope: str,
22
+ verification_uri: str,
23
+ auth_db: AuthDB,
24
+ config: Config,
25
+ available_properties: set[SecurityProperty],
26
+ settings: AuthSettings,
27
+ ) -> InitiateDeviceFlowResponse:
28
+ """Initiate the device flow against DIRAC authorization Server."""
29
+ if settings.dirac_client_id != client_id:
30
+ raise ValueError("Unrecognised client ID")
31
+
32
+ parse_and_validate_scope(scope, config, available_properties)
33
+
34
+ user_code, device_code = await auth_db.insert_device_flow(client_id, scope)
35
+
36
+ return {
37
+ "user_code": user_code,
38
+ "device_code": device_code,
39
+ "verification_uri_complete": f"{verification_uri}?user_code={user_code}",
40
+ "verification_uri": verification_uri,
41
+ "expires_in": settings.device_flow_expiration_seconds,
42
+ }
43
+
44
+
45
+ async def do_device_flow(
46
+ request_url: str,
47
+ auth_db: AuthDB,
48
+ user_code: str,
49
+ config: Config,
50
+ available_properties: set[SecurityProperty],
51
+ settings: AuthSettings,
52
+ ) -> str:
53
+ """This is called as the verification URI for the device flow."""
54
+ # Here we make sure the user_code actually exists
55
+ scope = await auth_db.device_flow_validate_user_code(
56
+ user_code, settings.device_flow_expiration_seconds
57
+ )
58
+ parsed_scope = parse_and_validate_scope(scope, config, available_properties)
59
+
60
+ redirect_uri = f"{request_url}/complete"
61
+
62
+ state_for_iam = {
63
+ "grant_type": GrantType.device_code.value,
64
+ "user_code": user_code,
65
+ }
66
+
67
+ authorization_flow_url = await initiate_authorization_flow_with_iam(
68
+ config,
69
+ parsed_scope["vo"],
70
+ redirect_uri,
71
+ state_for_iam,
72
+ settings.state_key.fernet,
73
+ )
74
+ return authorization_flow_url
75
+
76
+
77
+ async def finish_device_flow(
78
+ request_url: str,
79
+ code: str,
80
+ state: str,
81
+ auth_db: AuthDB,
82
+ config: Config,
83
+ settings: AuthSettings,
84
+ ):
85
+ """This the url callbacked by IAM/Checkin after the authorization
86
+ flow was granted.
87
+ """
88
+ decrypted_state = decrypt_state(state, settings.state_key.fernet)
89
+ assert decrypted_state["grant_type"] == GrantType.device_code
90
+
91
+ id_token = await get_token_from_iam(
92
+ config,
93
+ decrypted_state["vo"],
94
+ code,
95
+ decrypted_state,
96
+ request_url,
97
+ )
98
+ await auth_db.device_flow_insert_id_token(
99
+ decrypted_state["user_code"], id_token, settings.device_flow_expiration_seconds
100
+ )
@@ -0,0 +1,32 @@
1
+ """This module contains the auth management functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from diracx.db.sql import AuthDB
8
+
9
+
10
+ async def get_refresh_tokens(
11
+ auth_db: AuthDB,
12
+ subject: str | None,
13
+ ) -> list:
14
+ """Get all refresh tokens bound to a given subject. If there is no subject, then
15
+ all the refresh tokens are retrieved.
16
+ """
17
+ return await auth_db.get_user_refresh_tokens(subject)
18
+
19
+
20
+ async def revoke_refresh_token(
21
+ auth_db: AuthDB,
22
+ subject: str | None,
23
+ jti: UUID,
24
+ ) -> str:
25
+ """Revoke a refresh token. If a subject is provided, then the refresh token must be owned by that subject."""
26
+ res = await auth_db.get_refresh_token(jti)
27
+
28
+ if subject and subject != res["Sub"]:
29
+ raise PermissionError("Cannot revoke a refresh token owned by someone else")
30
+
31
+ await auth_db.revoke_refresh_token(jti)
32
+ return f"Refresh token {jti} revoked"
@@ -0,0 +1,396 @@
1
+ """Token endpoint implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ from datetime import datetime, timedelta, timezone
9
+ from uuid import UUID, uuid4
10
+
11
+ from authlib.jose import JsonWebToken
12
+
13
+ from diracx.core.config import Config
14
+ from diracx.core.exceptions import (
15
+ AuthorizationError,
16
+ ExpiredFlowError,
17
+ InvalidCredentialsError,
18
+ PendingAuthorizationError,
19
+ )
20
+ from diracx.core.models import (
21
+ AccessTokenPayload,
22
+ GrantType,
23
+ RefreshTokenPayload,
24
+ TokenPayload,
25
+ )
26
+ from diracx.core.properties import SecurityProperty
27
+ from diracx.core.settings import AuthSettings
28
+ from diracx.db.sql import AuthDB
29
+ from diracx.db.sql.auth.schema import FlowStatus, RefreshTokenStatus
30
+ from diracx.db.sql.utils.functions import substract_date
31
+
32
+ from .utils import (
33
+ get_allowed_user_properties,
34
+ parse_and_validate_scope,
35
+ verify_dirac_refresh_token,
36
+ )
37
+
38
+
39
+ async def get_oidc_token(
40
+ grant_type: GrantType,
41
+ client_id: str,
42
+ auth_db: AuthDB,
43
+ config: Config,
44
+ settings: AuthSettings,
45
+ available_properties: set[SecurityProperty],
46
+ device_code: str | None = None,
47
+ code: str | None = None,
48
+ redirect_uri: str | None = None,
49
+ code_verifier: str | None = None,
50
+ refresh_token: str | None = None,
51
+ ) -> tuple[AccessTokenPayload, RefreshTokenPayload]:
52
+ """Token endpoint to retrieve the token at the end of a flow."""
53
+ legacy_exchange = False
54
+
55
+ if grant_type == GrantType.device_code:
56
+ assert device_code is not None
57
+ oidc_token_info, scope = await get_oidc_token_info_from_device_flow(
58
+ device_code, client_id, auth_db, settings
59
+ )
60
+
61
+ elif grant_type == GrantType.authorization_code:
62
+ assert code is not None
63
+ assert code_verifier is not None
64
+ oidc_token_info, scope = await get_oidc_token_info_from_authorization_flow(
65
+ code, client_id, redirect_uri, code_verifier, auth_db, settings
66
+ )
67
+
68
+ elif grant_type == GrantType.refresh_token:
69
+ assert refresh_token is not None
70
+ (
71
+ oidc_token_info,
72
+ scope,
73
+ legacy_exchange,
74
+ ) = await get_oidc_token_info_from_refresh_flow(
75
+ refresh_token, auth_db, settings
76
+ )
77
+ else:
78
+ raise NotImplementedError(f"Grant type not implemented {grant_type}")
79
+
80
+ # Get a TokenResponse to return to the user
81
+ return await exchange_token(
82
+ auth_db,
83
+ scope,
84
+ oidc_token_info,
85
+ config,
86
+ settings,
87
+ available_properties,
88
+ legacy_exchange=legacy_exchange,
89
+ )
90
+
91
+
92
+ async def get_oidc_token_info_from_device_flow(
93
+ device_code: str, client_id: str, auth_db: AuthDB, settings: AuthSettings
94
+ ) -> tuple[dict, str]:
95
+ """Get OIDC token information from the device flow DB and check few parameters before returning it."""
96
+ info = await get_device_flow(
97
+ auth_db, device_code, settings.device_flow_expiration_seconds
98
+ )
99
+
100
+ if info["ClientID"] != client_id:
101
+ raise ValueError("Bad client_id")
102
+
103
+ oidc_token_info = info["IDToken"]
104
+ scope = info["Scope"]
105
+
106
+ # TODO: use HTTPException while still respecting the standard format
107
+ # required by the RFC
108
+ if info["Status"] != FlowStatus.READY:
109
+ # That should never ever happen
110
+ raise NotImplementedError(f"Unexpected flow status {info['status']!r}")
111
+ return (oidc_token_info, scope)
112
+
113
+
114
+ async def get_oidc_token_info_from_authorization_flow(
115
+ code: str,
116
+ client_id: str | None,
117
+ redirect_uri: str | None,
118
+ code_verifier: str,
119
+ auth_db: AuthDB,
120
+ settings: AuthSettings,
121
+ ) -> tuple[dict, str]:
122
+ """Get OIDC token information from the authorization flow DB and check few parameters before returning it."""
123
+ info = await get_authorization_flow(
124
+ auth_db, code, settings.authorization_flow_expiration_seconds
125
+ )
126
+ if redirect_uri != info["RedirectURI"]:
127
+ raise ValueError("Invalid redirect_uri")
128
+ if client_id != info["ClientID"]:
129
+ raise ValueError("Bad client_id")
130
+
131
+ # Check the code_verifier
132
+ try:
133
+ code_challenge = (
134
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
135
+ .decode()
136
+ .strip("=")
137
+ )
138
+ except Exception as e:
139
+ raise ValueError("Malformed code_verifier") from e
140
+
141
+ if code_challenge != info["CodeChallenge"]:
142
+ raise ValueError("Invalid code_challenge")
143
+
144
+ oidc_token_info = info["IDToken"]
145
+ scope = info["Scope"]
146
+
147
+ # TODO: use HTTPException while still respecting the standard format
148
+ # required by the RFC
149
+ if info["Status"] != FlowStatus.READY:
150
+ # That should never ever happen
151
+ raise NotImplementedError(f"Unexpected flow status {info['status']!r}")
152
+
153
+ return (oidc_token_info, scope)
154
+
155
+
156
+ async def get_oidc_token_info_from_refresh_flow(
157
+ refresh_token: str, auth_db: AuthDB, settings: AuthSettings
158
+ ) -> tuple[dict, str, bool]:
159
+ """Get OIDC token information from the refresh token DB and check few parameters before returning it."""
160
+ # Decode the refresh token to get the JWT ID
161
+ jti, _, legacy_exchange = await verify_dirac_refresh_token(refresh_token, settings)
162
+
163
+ # Get some useful user information from the refresh token entry in the DB
164
+ refresh_token_attributes = await auth_db.get_refresh_token(jti)
165
+
166
+ sub = refresh_token_attributes["Sub"]
167
+
168
+ # Check if the refresh token was obtained from the legacy_exchange endpoint
169
+ # If it is the case, we bypass the refresh token rotation mechanism
170
+ if not legacy_exchange:
171
+ # Refresh token rotation: https://datatracker.ietf.org/doc/html/rfc6749#section-10.4
172
+ # Check that the refresh token has not been already revoked
173
+ # This might indicate that a potential attacker try to impersonate someone
174
+ # In such case, all the refresh tokens bound to a given user (subject) should be revoked
175
+ # Forcing the user to reauthenticate interactively through an authorization/device flow (recommended practice)
176
+ if refresh_token_attributes["Status"] == RefreshTokenStatus.REVOKED:
177
+ # Revoke all the user tokens from the subject
178
+ await auth_db.revoke_user_refresh_tokens(sub)
179
+
180
+ # Commit here, otherwise the revokation operation will not be taken into account
181
+ # as we return an error to the user
182
+ await auth_db.conn.commit()
183
+
184
+ raise InvalidCredentialsError(
185
+ "Revoked refresh token reused: potential attack detected. You must authenticate again"
186
+ )
187
+
188
+ # Part of the refresh token rotation mechanism:
189
+ # Revoke the refresh token provided, a new one needs to be generated
190
+ await auth_db.revoke_refresh_token(jti)
191
+
192
+ # Build an ID token and get scope from the refresh token attributes received
193
+ oidc_token_info = {
194
+ # The sub attribute coming from the DB contains the VO name
195
+ # We need to remove it as if it were coming from an ID token from an external IdP
196
+ "sub": sub.split(":", 1)[1],
197
+ "preferred_username": refresh_token_attributes["PreferredUsername"],
198
+ }
199
+ scope = refresh_token_attributes["Scope"]
200
+ return (oidc_token_info, scope, legacy_exchange)
201
+
202
+
203
+ BASE_64_URL_SAFE_PATTERN = (
204
+ r"(?:[A-Za-z0-9\-_]{4})*(?:[A-Za-z0-9\-_]{2}==|[A-Za-z0-9\-_]{3}=)?"
205
+ )
206
+ LEGACY_EXCHANGE_PATTERN = rf"Bearer diracx:legacy:({BASE_64_URL_SAFE_PATTERN})"
207
+
208
+
209
+ async def perform_legacy_exchange(
210
+ expected_api_key: str,
211
+ preferred_username: str,
212
+ scope: str,
213
+ authorization: str,
214
+ auth_db: AuthDB,
215
+ available_properties: set[SecurityProperty],
216
+ settings: AuthSettings,
217
+ config: Config,
218
+ expires_minutes: int | None = None,
219
+ ) -> tuple[AccessTokenPayload, RefreshTokenPayload]:
220
+ """Endpoint used by legacy DIRAC to mint tokens for proxy -> token exchange."""
221
+ if match := re.fullmatch(LEGACY_EXCHANGE_PATTERN, authorization):
222
+ raw_token = base64.urlsafe_b64decode(match.group(1))
223
+ else:
224
+ raise ValueError("Invalid authorization header")
225
+
226
+ if hashlib.sha256(raw_token).hexdigest() != expected_api_key:
227
+ raise InvalidCredentialsError("Invalid credentials")
228
+
229
+ try:
230
+ parsed_scope = parse_and_validate_scope(scope, config, available_properties)
231
+ vo_users = config.Registry[parsed_scope["vo"]]
232
+ sub = vo_users.sub_from_preferred_username(preferred_username)
233
+ except (KeyError, ValueError) as e:
234
+ raise ValueError("Invalid scope or preferred_username") from e
235
+
236
+ return await exchange_token(
237
+ auth_db,
238
+ scope,
239
+ {"sub": sub, "preferred_username": preferred_username},
240
+ config,
241
+ settings,
242
+ available_properties,
243
+ refresh_token_expire_minutes=expires_minutes,
244
+ legacy_exchange=True,
245
+ )
246
+
247
+
248
+ async def exchange_token(
249
+ auth_db: AuthDB,
250
+ scope: str,
251
+ oidc_token_info: dict,
252
+ config: Config,
253
+ settings: AuthSettings,
254
+ available_properties: set[SecurityProperty],
255
+ *,
256
+ refresh_token_expire_minutes: int | None = None,
257
+ legacy_exchange: bool = False,
258
+ ) -> tuple[AccessTokenPayload, RefreshTokenPayload]:
259
+ """Method called to exchange the OIDC token for a DIRAC generated access token."""
260
+ # Extract dirac attributes from the OIDC scope
261
+ parsed_scope = parse_and_validate_scope(scope, config, available_properties)
262
+ vo = parsed_scope["vo"]
263
+ dirac_group = parsed_scope["group"]
264
+ properties = parsed_scope["properties"]
265
+
266
+ # Extract attributes from the OIDC token details
267
+ sub = oidc_token_info["sub"]
268
+ if user_info := config.Registry[vo].Users.get(sub):
269
+ preferred_username = user_info.PreferedUsername
270
+ else:
271
+ preferred_username = oidc_token_info.get("preferred_username", sub)
272
+ raise NotImplementedError(
273
+ "Dynamic registration of users is not yet implemented"
274
+ )
275
+
276
+ # Check that the subject is part of the dirac users
277
+ if sub not in config.Registry[vo].Groups[dirac_group].Users:
278
+ raise PermissionError(
279
+ f"User is not a member of the requested group ({preferred_username}, {dirac_group})"
280
+ )
281
+
282
+ # Check that the user properties are valid
283
+ allowed_user_properties = get_allowed_user_properties(config, sub, vo)
284
+ if not properties.issubset(allowed_user_properties):
285
+ raise PermissionError(
286
+ f"{' '.join(properties - allowed_user_properties)} are not valid properties "
287
+ f"for user {preferred_username}, available values: {' '.join(allowed_user_properties)}"
288
+ )
289
+
290
+ # Merge the VO with the subject to get a unique DIRAC sub
291
+ sub = f"{vo}:{sub}"
292
+
293
+ # Insert the refresh token with user details into the RefreshTokens table
294
+ # User details are needed to regenerate access tokens later
295
+ jti, creation_time = await insert_refresh_token(
296
+ auth_db=auth_db,
297
+ subject=sub,
298
+ preferred_username=preferred_username,
299
+ scope=scope,
300
+ )
301
+
302
+ # Generate refresh token payload
303
+ if refresh_token_expire_minutes is None:
304
+ refresh_token_expire_minutes = settings.refresh_token_expire_minutes
305
+ refresh_payload: RefreshTokenPayload = {
306
+ "jti": str(jti),
307
+ "exp": creation_time + timedelta(minutes=refresh_token_expire_minutes),
308
+ # legacy_exchange is used to indicate that the original refresh token
309
+ # was obtained from the legacy_exchange endpoint
310
+ "legacy_exchange": legacy_exchange,
311
+ "dirac_policies": {},
312
+ }
313
+
314
+ # Generate access token payload
315
+ # For now, the access token is only used to access DIRAC services,
316
+ # therefore, the audience is not set and checked
317
+ access_payload: AccessTokenPayload = {
318
+ "sub": sub,
319
+ "vo": vo,
320
+ "iss": settings.token_issuer,
321
+ "dirac_properties": list(properties),
322
+ "jti": str(uuid4()),
323
+ "preferred_username": preferred_username,
324
+ "dirac_group": dirac_group,
325
+ "exp": creation_time + timedelta(minutes=settings.access_token_expire_minutes),
326
+ "dirac_policies": {},
327
+ }
328
+
329
+ return access_payload, refresh_payload
330
+
331
+
332
+ def create_token(payload: TokenPayload, settings: AuthSettings) -> str:
333
+ jwt = JsonWebToken(settings.token_algorithm)
334
+ encoded_jwt = jwt.encode(
335
+ {"alg": settings.token_algorithm}, payload, settings.token_key.jwk
336
+ )
337
+ return encoded_jwt.decode("ascii")
338
+
339
+
340
+ async def insert_refresh_token(
341
+ auth_db: AuthDB,
342
+ subject: str,
343
+ preferred_username: str,
344
+ scope: str,
345
+ ) -> tuple[UUID, datetime]:
346
+ """Insert a refresh token into the database and return the JWT ID and creation time."""
347
+ # Generate a JWT ID
348
+ jti = uuid4()
349
+
350
+ # Insert the refresh token into the DB
351
+ await auth_db.insert_refresh_token(
352
+ jti=jti,
353
+ subject=subject,
354
+ preferred_username=preferred_username,
355
+ scope=scope,
356
+ )
357
+
358
+ # Get the creation time of the refresh token
359
+ refresh_token = await auth_db.get_refresh_token(jti)
360
+ return jti, refresh_token["CreationTime"]
361
+
362
+
363
+ async def get_device_flow(auth_db: AuthDB, device_code: str, max_validity: int):
364
+ """Get the device flow from the DB and check few parameters before returning it."""
365
+ res = await auth_db.get_device_flow(device_code)
366
+
367
+ if res["CreationTime"].replace(tzinfo=timezone.utc) < substract_date(
368
+ seconds=max_validity
369
+ ):
370
+ raise ExpiredFlowError()
371
+
372
+ if res["Status"] == FlowStatus.READY:
373
+ await auth_db.update_device_flow_status(device_code, FlowStatus.DONE)
374
+ return res
375
+
376
+ if res["Status"] == FlowStatus.DONE:
377
+ raise AuthorizationError("Code was already used")
378
+
379
+ if res["Status"] == FlowStatus.PENDING:
380
+ raise PendingAuthorizationError()
381
+
382
+ raise AuthorizationError("Bad state in device flow")
383
+
384
+
385
+ async def get_authorization_flow(auth_db: AuthDB, code: str, max_validity: int):
386
+ """Get the authorization flow from the DB and check few parameters before returning it."""
387
+ res = await auth_db.get_authorization_flow(code, max_validity)
388
+
389
+ if res["Status"] == FlowStatus.READY:
390
+ await auth_db.update_authorization_flow_status(code, FlowStatus.DONE)
391
+ return res
392
+
393
+ if res["Status"] == FlowStatus.DONE:
394
+ raise AuthorizationError("Code was already used")
395
+
396
+ raise AuthorizationError("Bad state in authorization flow")