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.
- diracx_logic-0.0.1a29/PKG-INFO +18 -0
- diracx_logic-0.0.1a29/README.md +0 -0
- diracx_logic-0.0.1a29/pyproject.toml +40 -0
- diracx_logic-0.0.1a29/setup.cfg +4 -0
- diracx_logic-0.0.1a29/src/diracx/logic/__init__.py +0 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/__init__.py +0 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/authorize_code_flow.py +99 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/device_flow.py +100 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/management.py +32 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/token.py +396 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/utils.py +288 -0
- diracx_logic-0.0.1a29/src/diracx/logic/auth/well_known.py +63 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/__init__.py +0 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/query.py +97 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/sandboxes.py +186 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/status.py +476 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/submission.py +257 -0
- diracx_logic-0.0.1a29/src/diracx/logic/jobs/utils.py +40 -0
- diracx_logic-0.0.1a29/src/diracx/logic/py.typed +0 -0
- diracx_logic-0.0.1a29/src/diracx/logic/task_queues/__init__.py +0 -0
- diracx_logic-0.0.1a29/src/diracx/logic/task_queues/priority.py +151 -0
- diracx_logic-0.0.1a29/src/diracx_logic.egg-info/PKG-INFO +18 -0
- diracx_logic-0.0.1a29/src/diracx_logic.egg-info/SOURCES.txt +24 -0
- diracx_logic-0.0.1a29/src/diracx_logic.egg-info/dependency_links.txt +1 -0
- diracx_logic-0.0.1a29/src/diracx_logic.egg-info/requires.txt +7 -0
- 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"
|
|
File without changes
|
|
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")
|