obi-auth 1.2.1__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.
- obi_auth/__init__.py +13 -0
- obi_auth/cache.py +129 -0
- obi_auth/cli.py +151 -0
- obi_auth/client.py +236 -0
- obi_auth/config.py +101 -0
- obi_auth/exception.py +21 -0
- obi_auth/flows/__init__.py +1 -0
- obi_auth/flows/auth_manager.py +57 -0
- obi_auth/flows/daf.py +139 -0
- obi_auth/flows/pkce.py +92 -0
- obi_auth/request.py +40 -0
- obi_auth/server.py +154 -0
- obi_auth/storage.py +60 -0
- obi_auth/typedef.py +88 -0
- obi_auth/util.py +65 -0
- obi_auth-1.2.1.dist-info/METADATA +171 -0
- obi_auth-1.2.1.dist-info/RECORD +21 -0
- obi_auth-1.2.1.dist-info/WHEEL +5 -0
- obi_auth-1.2.1.dist-info/entry_points.txt +2 -0
- obi_auth-1.2.1.dist-info/licenses/LICENSE.txt +201 -0
- obi_auth-1.2.1.dist-info/top_level.txt +1 -0
obi_auth/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""obi_auth."""
|
|
2
|
+
|
|
3
|
+
from obi_auth.client import get_token, get_token_info, get_user_info
|
|
4
|
+
from obi_auth.typedef import AuthMode, DeploymentEnvironment, TokenProvider
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"get_token",
|
|
8
|
+
"get_token_info",
|
|
9
|
+
"get_user_info",
|
|
10
|
+
"DeploymentEnvironment",
|
|
11
|
+
"AuthMode",
|
|
12
|
+
"TokenProvider",
|
|
13
|
+
]
|
obi_auth/cache.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Token cache module."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
7
|
+
|
|
8
|
+
from obi_auth.config import settings
|
|
9
|
+
from obi_auth.storage import Storage
|
|
10
|
+
from obi_auth.typedef import (
|
|
11
|
+
AuthManagerTokenInfo,
|
|
12
|
+
CachedAuthManagerTokenInfo,
|
|
13
|
+
CachedTokenInfo,
|
|
14
|
+
KeycloakTokenInfo,
|
|
15
|
+
)
|
|
16
|
+
from obi_auth.util import derive_fernet_key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseTokenCache:
|
|
20
|
+
"""Shared Fernet helpers for token caches."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
"""Initialize the token cache."""
|
|
24
|
+
self._cipher = Fernet(key=derive_fernet_key())
|
|
25
|
+
|
|
26
|
+
def _encrypt_access_token(self, access_token: str) -> tuple[bytes, int]:
|
|
27
|
+
creation_time, time_to_live = _get_token_times(access_token)
|
|
28
|
+
fernet_token = self._cipher.encrypt_at_time(
|
|
29
|
+
data=access_token.encode(encoding="utf-8"),
|
|
30
|
+
current_time=creation_time,
|
|
31
|
+
)
|
|
32
|
+
return fernet_token, time_to_live
|
|
33
|
+
|
|
34
|
+
def _decrypt_access_token(self, token: bytes, ttl: int) -> str:
|
|
35
|
+
return self._cipher.decrypt_at_time(
|
|
36
|
+
token=token,
|
|
37
|
+
ttl=ttl,
|
|
38
|
+
current_time=_now(),
|
|
39
|
+
).decode()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TokenCache(BaseTokenCache):
|
|
43
|
+
"""Cache for Keycloak access tokens."""
|
|
44
|
+
|
|
45
|
+
def get(self, storage: Storage) -> KeycloakTokenInfo | None:
|
|
46
|
+
"""Get a cached Keycloak token if still valid, else None."""
|
|
47
|
+
if not (cached_token_info := storage.read()):
|
|
48
|
+
return None
|
|
49
|
+
if not isinstance(cached_token_info, CachedTokenInfo):
|
|
50
|
+
storage.clear()
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
access_token = self._decrypt_access_token(
|
|
54
|
+
cached_token_info.token, cached_token_info.ttl
|
|
55
|
+
)
|
|
56
|
+
except InvalidToken:
|
|
57
|
+
storage.clear()
|
|
58
|
+
return None
|
|
59
|
+
return KeycloakTokenInfo(access_token=access_token)
|
|
60
|
+
|
|
61
|
+
def set(self, token_info: KeycloakTokenInfo, storage: Storage) -> None:
|
|
62
|
+
"""Store a Keycloak token in the cache."""
|
|
63
|
+
fernet_token, time_to_live = self._encrypt_access_token(token_info.access_token)
|
|
64
|
+
storage.write(CachedTokenInfo(token=fernet_token, ttl=time_to_live))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthManagerTokenCache(BaseTokenCache):
|
|
68
|
+
"""Cache for auth-manager access tokens and persistent token ids."""
|
|
69
|
+
|
|
70
|
+
def get(self, storage: Storage) -> AuthManagerTokenInfo | None:
|
|
71
|
+
"""Get a cached auth-manager token.
|
|
72
|
+
|
|
73
|
+
Returns a valid access token when available. If the access token has expired
|
|
74
|
+
but a persistent token id is stored, returns ``AuthManagerTokenInfo`` with an
|
|
75
|
+
empty ``access_token`` so the caller can remint.
|
|
76
|
+
"""
|
|
77
|
+
if not (cached_token_info := storage.read()):
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(cached_token_info, CachedAuthManagerTokenInfo):
|
|
80
|
+
storage.clear()
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
persistent_token_id = self._cipher.decrypt(
|
|
85
|
+
cached_token_info.persistent_token_id
|
|
86
|
+
).decode()
|
|
87
|
+
except InvalidToken:
|
|
88
|
+
storage.clear()
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
access_token = self._decrypt_access_token(
|
|
93
|
+
cached_token_info.token, cached_token_info.ttl
|
|
94
|
+
)
|
|
95
|
+
except InvalidToken:
|
|
96
|
+
return AuthManagerTokenInfo(access_token=None, persistent_token_id=persistent_token_id)
|
|
97
|
+
|
|
98
|
+
return AuthManagerTokenInfo(
|
|
99
|
+
access_token=access_token, persistent_token_id=persistent_token_id
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def set(self, token_info: AuthManagerTokenInfo, storage: Storage) -> None:
|
|
103
|
+
"""Store an auth-manager token and persistent id in the cache."""
|
|
104
|
+
if token_info.access_token is None:
|
|
105
|
+
msg = "Cannot cache AuthManagerTokenInfo without an access_token"
|
|
106
|
+
raise ValueError(msg)
|
|
107
|
+
fernet_token, time_to_live = self._encrypt_access_token(token_info.access_token)
|
|
108
|
+
persistent_token_id = self._cipher.encrypt(
|
|
109
|
+
token_info.persistent_token_id.encode(encoding="utf-8")
|
|
110
|
+
)
|
|
111
|
+
storage.write(
|
|
112
|
+
CachedAuthManagerTokenInfo(
|
|
113
|
+
token=fernet_token,
|
|
114
|
+
ttl=time_to_live,
|
|
115
|
+
persistent_token_id=persistent_token_id,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _now() -> int:
|
|
121
|
+
"""Return UTC timestamp now."""
|
|
122
|
+
return int(time.time())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _get_token_times(token: str) -> tuple[int, int]:
|
|
126
|
+
"""Get the creation time and time to live of a token."""
|
|
127
|
+
info = jwt.decode(token.encode(), options={"verify_signature": False})
|
|
128
|
+
effective_ttl = info["exp"] - info["iat"] - settings.EPSILON_TOKEN_TTL_SECONDS
|
|
129
|
+
return info["iat"], effective_ttl
|
obi_auth/cli.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""CLI for obi-auth."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import click
|
|
9
|
+
except ImportError as exc:
|
|
10
|
+
raise SystemExit(
|
|
11
|
+
"The obi-auth CLI requires optional dependencies that are not installed.\n"
|
|
12
|
+
"Install them with:\n"
|
|
13
|
+
" pip install 'obi-auth[cli]'"
|
|
14
|
+
) from exc
|
|
15
|
+
|
|
16
|
+
import obi_auth
|
|
17
|
+
from obi_auth.typedef import AuthMode, DeploymentEnvironment, TokenProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.option(
|
|
22
|
+
"--log-level",
|
|
23
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False),
|
|
24
|
+
default="WARNING",
|
|
25
|
+
show_default=True,
|
|
26
|
+
help="Logging level",
|
|
27
|
+
)
|
|
28
|
+
def main(log_level: str):
|
|
29
|
+
"""CLI for obi-auth."""
|
|
30
|
+
logging.basicConfig(level=log_level)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@main.command()
|
|
34
|
+
@click.option(
|
|
35
|
+
"--environment",
|
|
36
|
+
"-e",
|
|
37
|
+
type=click.Choice(DeploymentEnvironment),
|
|
38
|
+
default=DeploymentEnvironment.staging,
|
|
39
|
+
show_default=True,
|
|
40
|
+
help="Target environment",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--auth-mode",
|
|
44
|
+
"-m",
|
|
45
|
+
type=click.Choice(AuthMode),
|
|
46
|
+
default=AuthMode.pkce,
|
|
47
|
+
show_default=True,
|
|
48
|
+
help="Authentication method",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--token-provider",
|
|
52
|
+
"-p",
|
|
53
|
+
type=click.Choice(TokenProvider),
|
|
54
|
+
default=TokenProvider.keycloak,
|
|
55
|
+
show_default=True,
|
|
56
|
+
help="Token issuer: keycloak or auth_manager",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--persistent-token-id",
|
|
60
|
+
default=None,
|
|
61
|
+
help="Persistent token id (required when auth-mode is persistent_token)",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--force-refresh",
|
|
65
|
+
help="Clear the cached token and authenticate again",
|
|
66
|
+
is_flag=True,
|
|
67
|
+
default=False,
|
|
68
|
+
)
|
|
69
|
+
def get_token(
|
|
70
|
+
environment: DeploymentEnvironment,
|
|
71
|
+
auth_mode: AuthMode,
|
|
72
|
+
token_provider: TokenProvider,
|
|
73
|
+
persistent_token_id: str | None,
|
|
74
|
+
force_refresh: bool,
|
|
75
|
+
):
|
|
76
|
+
"""Authenticate, print the token to stdout."""
|
|
77
|
+
access_token = obi_auth.get_token(
|
|
78
|
+
environment=environment,
|
|
79
|
+
auth_mode=auth_mode,
|
|
80
|
+
token_provider=token_provider,
|
|
81
|
+
persistent_token_id=persistent_token_id,
|
|
82
|
+
force_refresh=force_refresh,
|
|
83
|
+
)
|
|
84
|
+
print(access_token)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@main.command()
|
|
88
|
+
@click.argument("access_token", required=False)
|
|
89
|
+
def decode_token(access_token: str | None):
|
|
90
|
+
"""Decode token from argument or stdin."""
|
|
91
|
+
if not access_token:
|
|
92
|
+
access_token = sys.stdin.read().strip()
|
|
93
|
+
|
|
94
|
+
if not access_token:
|
|
95
|
+
raise click.ClickException("No access token provided via argument or stdin")
|
|
96
|
+
|
|
97
|
+
print(json.dumps(obi_auth.get_token_info(access_token), indent=2))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command()
|
|
101
|
+
@click.option(
|
|
102
|
+
"--environment",
|
|
103
|
+
"-e",
|
|
104
|
+
type=click.Choice(DeploymentEnvironment),
|
|
105
|
+
default=DeploymentEnvironment.staging,
|
|
106
|
+
show_default=True,
|
|
107
|
+
help="Target environment",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--auth-mode",
|
|
111
|
+
"-m",
|
|
112
|
+
type=click.Choice(AuthMode),
|
|
113
|
+
default=AuthMode.pkce,
|
|
114
|
+
show_default=True,
|
|
115
|
+
help="Authentication method",
|
|
116
|
+
)
|
|
117
|
+
@click.option(
|
|
118
|
+
"--token-provider",
|
|
119
|
+
"-p",
|
|
120
|
+
type=click.Choice(TokenProvider),
|
|
121
|
+
default=TokenProvider.keycloak,
|
|
122
|
+
show_default=True,
|
|
123
|
+
help="Token issuer: keycloak or auth_manager",
|
|
124
|
+
)
|
|
125
|
+
@click.option(
|
|
126
|
+
"--persistent-token-id",
|
|
127
|
+
default=None,
|
|
128
|
+
help="Persistent token id (required when auth-mode is persistent_token)",
|
|
129
|
+
)
|
|
130
|
+
@click.option(
|
|
131
|
+
"--force-refresh",
|
|
132
|
+
help="Clear the cached token and authenticate again",
|
|
133
|
+
is_flag=True,
|
|
134
|
+
default=False,
|
|
135
|
+
)
|
|
136
|
+
def get_user_info(
|
|
137
|
+
environment: DeploymentEnvironment,
|
|
138
|
+
auth_mode: AuthMode,
|
|
139
|
+
token_provider: TokenProvider,
|
|
140
|
+
persistent_token_id: str | None,
|
|
141
|
+
force_refresh: bool,
|
|
142
|
+
):
|
|
143
|
+
"""Show user info information."""
|
|
144
|
+
access_token = obi_auth.get_token(
|
|
145
|
+
environment=environment,
|
|
146
|
+
auth_mode=auth_mode,
|
|
147
|
+
token_provider=token_provider,
|
|
148
|
+
persistent_token_id=persistent_token_id,
|
|
149
|
+
force_refresh=force_refresh,
|
|
150
|
+
)
|
|
151
|
+
print(json.dumps(obi_auth.get_user_info(access_token, environment=environment), indent=2))
|
obi_auth/client.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""This module provides a client for the obi_auth service."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
|
|
8
|
+
from obi_auth.cache import AuthManagerTokenCache, TokenCache
|
|
9
|
+
from obi_auth.config import settings
|
|
10
|
+
from obi_auth.exception import AuthFlowError, ClientError, ConfigError, LocalServerError
|
|
11
|
+
from obi_auth.flows.auth_manager import (
|
|
12
|
+
auth_manager_exchange_token,
|
|
13
|
+
auth_manager_mint_access_token,
|
|
14
|
+
)
|
|
15
|
+
from obi_auth.flows.daf import daf_authenticate
|
|
16
|
+
from obi_auth.flows.pkce import pkce_authenticate
|
|
17
|
+
from obi_auth.request import user_info
|
|
18
|
+
from obi_auth.server import AuthServer
|
|
19
|
+
from obi_auth.storage import Storage
|
|
20
|
+
from obi_auth.typedef import (
|
|
21
|
+
AuthManagerTokenInfo,
|
|
22
|
+
AuthMode,
|
|
23
|
+
DeploymentEnvironment,
|
|
24
|
+
KeycloakTokenInfo,
|
|
25
|
+
TokenProvider,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
L = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_TOKEN_CACHE = TokenCache()
|
|
32
|
+
_AUTH_MANAGER_TOKEN_CACHE = AuthManagerTokenCache()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_token(
|
|
36
|
+
*,
|
|
37
|
+
environment: DeploymentEnvironment = DeploymentEnvironment.staging,
|
|
38
|
+
auth_mode: AuthMode = AuthMode.pkce,
|
|
39
|
+
token_provider: TokenProvider = TokenProvider.keycloak,
|
|
40
|
+
force_refresh: bool = False,
|
|
41
|
+
persistent_token_id: str | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Get token.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
environment: Target deployment environment.
|
|
47
|
+
auth_mode: How to authenticate with Keycloak (``pkce`` or ``daf``), or
|
|
48
|
+
``persistent_token`` to mint from an existing auth-manager id
|
|
49
|
+
(backwards-compatible public API for callers such as obi-one).
|
|
50
|
+
token_provider: Who issues the returned access token when ``auth_mode`` is
|
|
51
|
+
``pkce`` or ``daf``. ``keycloak`` returns the Keycloak token;
|
|
52
|
+
``auth_manager`` exchanges it and mints an auth-manager access token.
|
|
53
|
+
Ignored when ``auth_mode`` is ``persistent_token``.
|
|
54
|
+
force_refresh: Clear the cached token and authenticate again.
|
|
55
|
+
persistent_token_id: Required when ``auth_mode`` is ``persistent_token``.
|
|
56
|
+
"""
|
|
57
|
+
auth_mode = AuthMode(auth_mode)
|
|
58
|
+
token_provider = TokenProvider(token_provider)
|
|
59
|
+
|
|
60
|
+
L.debug("Using %s as the config dir", settings.config_dir)
|
|
61
|
+
|
|
62
|
+
# Public API compat: get_token(auth_mode="persistent_token", persistent_token_id=...)
|
|
63
|
+
if auth_mode == AuthMode.persistent_token:
|
|
64
|
+
if not persistent_token_id:
|
|
65
|
+
raise ClientError("persistent_token_id is required when auth_mode is persistent_token.")
|
|
66
|
+
return _get_persistent_token(
|
|
67
|
+
environment=environment,
|
|
68
|
+
persistent_token_id=persistent_token_id,
|
|
69
|
+
force_refresh=force_refresh,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
storage = Storage(
|
|
73
|
+
config_dir=settings.config_dir,
|
|
74
|
+
environment=environment,
|
|
75
|
+
key=f"{auth_mode}_{token_provider}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if token_provider == TokenProvider.auth_manager:
|
|
79
|
+
return _get_auth_manager_token(
|
|
80
|
+
storage=storage,
|
|
81
|
+
environment=environment,
|
|
82
|
+
auth_mode=auth_mode,
|
|
83
|
+
force_refresh=force_refresh,
|
|
84
|
+
)
|
|
85
|
+
return _get_keycloak_token(
|
|
86
|
+
storage=storage,
|
|
87
|
+
environment=environment,
|
|
88
|
+
auth_mode=auth_mode,
|
|
89
|
+
force_refresh=force_refresh,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_persistent_token(
|
|
94
|
+
*,
|
|
95
|
+
environment: DeploymentEnvironment,
|
|
96
|
+
persistent_token_id: str,
|
|
97
|
+
force_refresh: bool,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Mint from a known persistent id using the auth-manager cache/mint helpers."""
|
|
100
|
+
storage = Storage(
|
|
101
|
+
config_dir=settings.config_dir,
|
|
102
|
+
environment=environment,
|
|
103
|
+
key=persistent_token_id,
|
|
104
|
+
)
|
|
105
|
+
if force_refresh:
|
|
106
|
+
L.debug("Forcing token refresh, clearing cached token")
|
|
107
|
+
storage.clear()
|
|
108
|
+
elif token_info := _AUTH_MANAGER_TOKEN_CACHE.get(storage):
|
|
109
|
+
if token_info.access_token:
|
|
110
|
+
L.debug("Using cached token")
|
|
111
|
+
return token_info.access_token
|
|
112
|
+
if refreshed := _refresh_auth_manager_token(
|
|
113
|
+
token_info.persistent_token_id, storage=storage, environment=environment
|
|
114
|
+
):
|
|
115
|
+
if refreshed.access_token is None:
|
|
116
|
+
raise ClientError("Authentication process failed.")
|
|
117
|
+
return refreshed.access_token
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
token_info = auth_manager_mint_access_token(persistent_token_id, environment=environment)
|
|
121
|
+
except AuthFlowError as e:
|
|
122
|
+
raise ClientError("Authentication process failed.") from e
|
|
123
|
+
|
|
124
|
+
_AUTH_MANAGER_TOKEN_CACHE.set(token_info, storage)
|
|
125
|
+
if token_info.access_token is None:
|
|
126
|
+
raise ClientError("Authentication process failed.")
|
|
127
|
+
return token_info.access_token
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _get_keycloak_token(
|
|
131
|
+
*,
|
|
132
|
+
storage: Storage,
|
|
133
|
+
environment: DeploymentEnvironment,
|
|
134
|
+
auth_mode: AuthMode,
|
|
135
|
+
force_refresh: bool,
|
|
136
|
+
) -> str:
|
|
137
|
+
if force_refresh:
|
|
138
|
+
L.debug("Forcing token refresh, clearing cached token")
|
|
139
|
+
storage.clear()
|
|
140
|
+
elif token_info := _TOKEN_CACHE.get(storage):
|
|
141
|
+
L.debug("Using cached token")
|
|
142
|
+
return token_info.access_token
|
|
143
|
+
|
|
144
|
+
auth_method = _get_auth_method(auth_mode)
|
|
145
|
+
token_info = auth_method(environment=environment)
|
|
146
|
+
_TOKEN_CACHE.set(token_info, storage)
|
|
147
|
+
return token_info.access_token
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_auth_manager_token(
|
|
151
|
+
*,
|
|
152
|
+
storage: Storage,
|
|
153
|
+
environment: DeploymentEnvironment,
|
|
154
|
+
auth_mode: AuthMode,
|
|
155
|
+
force_refresh: bool,
|
|
156
|
+
) -> str:
|
|
157
|
+
if force_refresh:
|
|
158
|
+
L.debug("Forcing token refresh, clearing cached token")
|
|
159
|
+
storage.clear()
|
|
160
|
+
elif token_info := _AUTH_MANAGER_TOKEN_CACHE.get(storage):
|
|
161
|
+
if token_info.access_token:
|
|
162
|
+
L.debug("Using cached token")
|
|
163
|
+
return token_info.access_token
|
|
164
|
+
if refreshed := _refresh_auth_manager_token(
|
|
165
|
+
token_info.persistent_token_id, storage=storage, environment=environment
|
|
166
|
+
):
|
|
167
|
+
if refreshed.access_token is None:
|
|
168
|
+
raise ClientError("Authentication process failed.")
|
|
169
|
+
return refreshed.access_token
|
|
170
|
+
|
|
171
|
+
auth_method = _get_auth_method(auth_mode)
|
|
172
|
+
keycloak_token = auth_method(environment=environment)
|
|
173
|
+
try:
|
|
174
|
+
token_info = auth_manager_exchange_token(keycloak_token, environment=environment)
|
|
175
|
+
except AuthFlowError as e:
|
|
176
|
+
raise ClientError("Authentication process failed.") from e
|
|
177
|
+
|
|
178
|
+
_AUTH_MANAGER_TOKEN_CACHE.set(token_info, storage)
|
|
179
|
+
if token_info.access_token is None:
|
|
180
|
+
raise ClientError("Authentication process failed.")
|
|
181
|
+
return token_info.access_token
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _refresh_auth_manager_token(
|
|
185
|
+
persistent_token_id: str, *, storage: Storage, environment: DeploymentEnvironment
|
|
186
|
+
) -> AuthManagerTokenInfo | None:
|
|
187
|
+
"""Mint a new access token from a cached persistent token id."""
|
|
188
|
+
L.debug("Cached access token expired, minting a new one from persistent token id")
|
|
189
|
+
try:
|
|
190
|
+
token_info = auth_manager_mint_access_token(persistent_token_id, environment=environment)
|
|
191
|
+
except AuthFlowError:
|
|
192
|
+
L.debug("Failed to mint access token from persistent token id, clearing cache")
|
|
193
|
+
storage.clear()
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
_AUTH_MANAGER_TOKEN_CACHE.set(token_info, storage)
|
|
197
|
+
return token_info
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _get_auth_method(auth_mode: AuthMode) -> Callable[..., KeycloakTokenInfo]:
|
|
201
|
+
methods: dict[AuthMode, Callable[..., KeycloakTokenInfo]] = {
|
|
202
|
+
AuthMode.pkce: _pkce_authenticate,
|
|
203
|
+
AuthMode.daf: _daf_authenticate,
|
|
204
|
+
}
|
|
205
|
+
return methods[auth_mode]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _pkce_authenticate(*, environment: DeploymentEnvironment) -> KeycloakTokenInfo:
|
|
209
|
+
try:
|
|
210
|
+
with AuthServer().run() as local_server:
|
|
211
|
+
return pkce_authenticate(server=local_server, environment=environment)
|
|
212
|
+
except AuthFlowError as e:
|
|
213
|
+
raise ClientError("Authentication process failed.") from e
|
|
214
|
+
except LocalServerError as e:
|
|
215
|
+
raise ClientError("Local server failed to authenticate.") from e
|
|
216
|
+
except ConfigError as e:
|
|
217
|
+
raise ClientError("There is a mistake with configuration settings.") from e
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _daf_authenticate(*, environment: DeploymentEnvironment) -> KeycloakTokenInfo:
|
|
221
|
+
try:
|
|
222
|
+
return daf_authenticate(environment=environment)
|
|
223
|
+
except AuthFlowError as e:
|
|
224
|
+
raise ClientError("Authentication process failed.") from e
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_token_info(token: str) -> dict:
|
|
228
|
+
"""Decode token information."""
|
|
229
|
+
return jwt.decode(token, options={"verify_signature": False})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_user_info(
|
|
233
|
+
token: str, environment: DeploymentEnvironment = DeploymentEnvironment.staging
|
|
234
|
+
) -> dict:
|
|
235
|
+
"""Get user info from valid token."""
|
|
236
|
+
return user_info(token, environment=environment).json()
|
obi_auth/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""This module provides a config for the obi_auth service."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from obi_auth.exception import ConfigError
|
|
10
|
+
from obi_auth.typedef import DeploymentEnvironment, KeycloakRealm
|
|
11
|
+
from obi_auth.util import get_config_dir
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings(BaseSettings):
|
|
15
|
+
"""Environment settings for this library."""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_file=".env",
|
|
19
|
+
env_prefix="OBI_AUTH_",
|
|
20
|
+
env_file_encoding="utf-8",
|
|
21
|
+
case_sensitive=False,
|
|
22
|
+
validate_default=False,
|
|
23
|
+
extra="ignore",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
config_dir: Annotated[
|
|
27
|
+
Path,
|
|
28
|
+
Field(
|
|
29
|
+
description="Directory to store the token.",
|
|
30
|
+
default_factory=get_config_dir,
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
KEYCLOAK_ENV: DeploymentEnvironment = DeploymentEnvironment.staging
|
|
35
|
+
KEYCLOAK_REALM: KeycloakRealm = KeycloakRealm.sbo
|
|
36
|
+
KEYCLOAK_CLIENT_ID: str = "obi-entitysdk-auth"
|
|
37
|
+
|
|
38
|
+
EPSILON_TOKEN_TTL_SECONDS: int = 60
|
|
39
|
+
|
|
40
|
+
LOCAL_SERVER_TIMEOUT: int = 60
|
|
41
|
+
|
|
42
|
+
def _get_domain_url(self, override_env: DeploymentEnvironment) -> str:
|
|
43
|
+
"""Return domain url based on environment."""
|
|
44
|
+
match env := override_env or self.KEYCLOAK_ENV:
|
|
45
|
+
case DeploymentEnvironment.staging:
|
|
46
|
+
return "https://staging.cell-a.openbraininstitute.org"
|
|
47
|
+
case DeploymentEnvironment.production:
|
|
48
|
+
return "https://cell-a.openbraininstitute.org"
|
|
49
|
+
case _:
|
|
50
|
+
raise ConfigError(f"Unknown deployment environment {env}")
|
|
51
|
+
|
|
52
|
+
def get_keycloak_url(self, override_env: DeploymentEnvironment | None = None):
|
|
53
|
+
"""Return keycloak url."""
|
|
54
|
+
url = self._get_domain_url(override_env or self.KEYCLOAK_ENV)
|
|
55
|
+
return f"{url}/auth/realms/{self.KEYCLOAK_REALM}"
|
|
56
|
+
|
|
57
|
+
def get_keycloak_token_endpoint(self, override_env: DeploymentEnvironment | None = None) -> str:
|
|
58
|
+
"""Return keycloak token endpoint."""
|
|
59
|
+
base_url = self.get_keycloak_url(override_env=override_env)
|
|
60
|
+
return f"{base_url}/protocol/openid-connect/token"
|
|
61
|
+
|
|
62
|
+
def get_keycloak_auth_endpoint(self, override_env: DeploymentEnvironment | None = None) -> str:
|
|
63
|
+
"""Return keycloak auth endpoint."""
|
|
64
|
+
base_url = self.get_keycloak_url(override_env=override_env)
|
|
65
|
+
return f"{base_url}/protocol/openid-connect/auth"
|
|
66
|
+
|
|
67
|
+
def get_keycloak_device_auth_endpoint(
|
|
68
|
+
self, override_env: DeploymentEnvironment | None = None
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Return keycloack device auth endpoint."""
|
|
71
|
+
base_url = self.get_keycloak_url(override_env=override_env)
|
|
72
|
+
return f"{base_url}/protocol/openid-connect/auth/device"
|
|
73
|
+
|
|
74
|
+
def get_keycloak_user_info_endpoint(
|
|
75
|
+
self, override_env: DeploymentEnvironment | None = None
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Return keycloak user info endpoint."""
|
|
78
|
+
base_url = self.get_keycloak_url(override_env=override_env)
|
|
79
|
+
return f"{base_url}/protocol/openid-connect/userinfo"
|
|
80
|
+
|
|
81
|
+
def get_auth_manager_url(self, override_env: DeploymentEnvironment | None = None) -> str:
|
|
82
|
+
"""Return auth manager url."""
|
|
83
|
+
url = self._get_domain_url(override_env or self.KEYCLOAK_ENV)
|
|
84
|
+
return f"{url}/api/auth-manager/v1"
|
|
85
|
+
|
|
86
|
+
def get_auth_manager_access_token_endpoint(
|
|
87
|
+
self, override_env: DeploymentEnvironment | None = None
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Return auth-manager token endpoint."""
|
|
90
|
+
base_url = self.get_auth_manager_url(override_env=override_env)
|
|
91
|
+
return f"{base_url}/access-token"
|
|
92
|
+
|
|
93
|
+
def get_auth_manager_token_exchange_endpoint(
|
|
94
|
+
self, override_env: DeploymentEnvironment | None = None
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Return auth-manager token exchange endpoint."""
|
|
97
|
+
base_url = self.get_auth_manager_url(override_env=override_env)
|
|
98
|
+
return f"{base_url}/token-exchange"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
settings = Settings()
|
obi_auth/exception.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions module."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ObiAuthError(Exception):
|
|
5
|
+
"""Generic obi-auth error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthFlowError(ObiAuthError):
|
|
9
|
+
"""Authenticatin flow error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientError(ObiAuthError):
|
|
13
|
+
"""Client related error."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LocalServerError(ObiAuthError):
|
|
17
|
+
"""Local server related error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigError(ObiAuthError):
|
|
21
|
+
"""Configuration settings error."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication flows."""
|