alpha-python 0.6.3__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
alpha/__init__.py CHANGED
@@ -64,6 +64,18 @@ from alpha.providers.oidc_provider import (
64
64
  from alpha.repositories.models.repository_model import RepositoryModel
65
65
  from alpha.repositories.rest_api_repository import RestApiRepository
66
66
  from alpha.repositories.sql_alchemy_repository import SqlAlchemyRepository
67
+ from alpha.repositories.refresh.cache_repository import (
68
+ CacheRefreshRepository,
69
+ )
70
+ from alpha.repositories.refresh.database_repository import (
71
+ DatabaseRefreshRepository,
72
+ )
73
+ from alpha.repositories.refresh.file_repository import (
74
+ FileRefreshRepository,
75
+ )
76
+ from alpha.repositories.refresh.memory_repository import (
77
+ MemoryRefreshRepository,
78
+ )
67
79
  from alpha.services.authentication_service import AuthenticationService
68
80
  from alpha.services.user_lifecycle_management import UserLifecycleManagement
69
81
  from alpha.utils.is_attrs import is_attrs
@@ -156,6 +168,10 @@ __all__ = [
156
168
  "RepositoryModel",
157
169
  "RestApiRepository",
158
170
  "SqlAlchemyRepository",
171
+ "CacheRefreshRepository",
172
+ "DatabaseRefreshRepository",
173
+ "FileRefreshRepository",
174
+ "MemoryRefreshRepository",
159
175
  "AuthenticationService",
160
176
  "UserLifecycleManagement",
161
177
  "is_attrs",
@@ -9,6 +9,7 @@ from alpha.interfaces.patchable import Patchable
9
9
  # import all repository related interfaces
10
10
  from alpha.interfaces.api_repository import ApiRepository
11
11
  from alpha.interfaces.sql_repository import SqlRepository
12
+ from alpha.interfaces.refresh_repository import RefreshRepository
12
13
 
13
14
  # import all database related interfaces
14
15
  from alpha.interfaces.sql_mapper import SqlMapper
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "Patchable",
36
37
  "ApiRepository",
37
38
  "SqlRepository",
39
+ "RefreshRepository",
38
40
  "SqlMapper",
39
41
  "SqlDatabase",
40
42
  "UnitOfWork",
@@ -0,0 +1,26 @@
1
+ from typing import Protocol, Any, runtime_checkable
2
+
3
+
4
+ @runtime_checkable
5
+ class HTTPClient(Protocol):
6
+ """Interface for HTTP clients like requests, httpx or a custom
7
+ implementation.
8
+
9
+ This interface is compatible with the popular synchronous HTTP client
10
+ libraries, for example, the `requests` library, `httpx` library or any
11
+ custom implementation that follows the same method signatures.
12
+
13
+ This interface defines the methods that an HTTP client should implement to
14
+ be compatible with the REST API repository. It includes methods for making
15
+ HTTP requests (POST, GET, DELETE, PUT, PATCH) and allows for additional
16
+ parameters to be passed as needed.
17
+ """
18
+
19
+ cookies: Any
20
+ headers: Any
21
+
22
+ def post(self, url: str, json: Any = None, **kwargs: Any) -> Any: ...
23
+ def get(self, url: str, **kwargs: Any) -> Any: ...
24
+ def delete(self, url: str, **kwargs: Any) -> Any: ...
25
+ def put(self, url: str, json: Any = None, **kwargs: Any) -> Any: ...
26
+ def patch(self, url: str, json: Any = None, **kwargs: Any) -> Any: ...
@@ -1,11 +1,15 @@
1
1
  """This module contains interfaces for various types of identity providers."""
2
2
 
3
- from typing import ClassVar, Protocol, runtime_checkable
3
+ from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable, Any
4
4
 
5
5
  from alpha.interfaces.token_factory import TokenFactory
6
6
  from alpha.providers.models.credentials import PasswordCredentials
7
7
  from alpha.providers.models.identity import Identity
8
- from alpha.providers.models.token import Token
8
+
9
+ if TYPE_CHECKING:
10
+ from alpha.providers.models.token import Token
11
+ else:
12
+ Token = Any
9
13
 
10
14
 
11
15
  @runtime_checkable
@@ -0,0 +1,60 @@
1
+ from typing import TYPE_CHECKING, Protocol, Any
2
+
3
+ if TYPE_CHECKING:
4
+ from alpha.providers.models.token import Token
5
+ else:
6
+ Token = Any
7
+
8
+
9
+ class RefreshRepository(Protocol):
10
+ """Repository interface for managing refresh tokens."""
11
+
12
+ def get(self, token: str) -> Token:
13
+ """Get a token by its value.
14
+
15
+ Parameters
16
+ ----------
17
+ token
18
+ Token value.
19
+
20
+ Returns
21
+ -------
22
+ Token
23
+ Token object with the given token value.
24
+ """
25
+ ...
26
+
27
+ def create(self, subject: str) -> Token:
28
+ """Create a new token for a given subject.
29
+
30
+ Parameters
31
+ ----------
32
+ subject
33
+ Subject identifier
34
+
35
+ Returns
36
+ -------
37
+ Token
38
+ Newly created token object.
39
+ """
40
+ ...
41
+
42
+ def delete(self, token: str) -> None:
43
+ """Delete a token by its value.
44
+
45
+ Parameters
46
+ ----------
47
+ token
48
+ Token value.
49
+ """
50
+ ...
51
+
52
+ def delete_all(self, subject: str) -> None:
53
+ """Delete all tokens for a given subject.
54
+
55
+ Parameters
56
+ ----------
57
+ subject
58
+ Subject identifier.
59
+ """
60
+ ...
@@ -1,9 +1,10 @@
1
1
  from uuid import UUID
2
- from datetime import datetime
2
+ from datetime import datetime, timedelta, timezone
3
3
  from dataclasses import dataclass
4
4
  from typing import Any, Literal
5
5
 
6
6
  from alpha.domain.models.base_model import BaseDomainModel
7
+ from alpha.utils.secret_generator import generate_secret
7
8
 
8
9
 
9
10
  @dataclass
@@ -79,6 +80,40 @@ class Token(BaseDomainModel):
79
80
  ),
80
81
  )
81
82
 
83
+ @classmethod
84
+ def create_refresh(
85
+ cls,
86
+ subject: str,
87
+ max_age_seconds: int = 7 * 24 * 3600,
88
+ token_length: int = 32,
89
+ ) -> "Token":
90
+ """Factory method to create a new Refresh Token instance.
91
+
92
+ Parameters
93
+ ----------
94
+ subject
95
+ The subject or user associated with the token.
96
+ max_age_seconds, optional
97
+ Optional maximum age of the token in seconds. Defaults to 7 days.
98
+ If provided, the expires_at will be set to created_at +
99
+ max_age_seconds.
100
+ token_length, optional
101
+ Optional length of the token value. Defaults to 32.
102
+
103
+ Returns
104
+ -------
105
+ Token
106
+ A new Refresh Token instance with the provided attributes and generated id and created_at.
107
+ """
108
+ return cls(
109
+ value=generate_secret(token_length),
110
+ token_type="Refresh",
111
+ subject=subject,
112
+ created_at=datetime.now(tz=timezone.utc),
113
+ expires_at=datetime.now(tz=timezone.utc)
114
+ + timedelta(seconds=max_age_seconds),
115
+ )
116
+
82
117
  def to_dict(self) -> dict[str, str | None]:
83
118
  """Converts the Token instance to a dictionary.
84
119
 
@@ -97,15 +132,18 @@ class Token(BaseDomainModel):
97
132
  - "expires_at": ISO format datetime string for when the token
98
133
  expires or None.
99
134
  """
100
- return {
101
- "id": str(self.id) if self.id else None,
135
+
136
+ obj: dict[str, str | None] = {
102
137
  "value": self.value,
103
138
  "subject": self.subject,
104
139
  "token_type": self.token_type,
105
- "created_at": (
106
- self.created_at.isoformat() if self.created_at else None
107
- ),
108
- "expires_at": (
109
- self.expires_at.isoformat() if self.expires_at else None
110
- ),
111
140
  }
141
+
142
+ if self.id:
143
+ obj["id"] = str(self.id)
144
+ if self.created_at:
145
+ obj["created_at"] = self.created_at.isoformat()
146
+ if self.expires_at:
147
+ obj["expires_at"] = self.expires_at.isoformat()
148
+
149
+ return obj
@@ -1,4 +1,16 @@
1
1
  from alpha.repositories.models.repository_model import RepositoryModel
2
+ from alpha.repositories.refresh.cache_repository import (
3
+ CacheRefreshRepository,
4
+ )
5
+ from alpha.repositories.refresh.database_repository import (
6
+ DatabaseRefreshRepository,
7
+ )
8
+ from alpha.repositories.refresh.file_repository import (
9
+ FileRefreshRepository,
10
+ )
11
+ from alpha.repositories.refresh.memory_repository import (
12
+ MemoryRefreshRepository,
13
+ )
2
14
  from alpha.repositories.rest_api_repository import RestApiRepository
3
15
  from alpha.repositories.sql_alchemy_repository import SqlAlchemyRepository
4
16
 
@@ -6,4 +18,8 @@ __all__ = [
6
18
  "RepositoryModel",
7
19
  "RestApiRepository",
8
20
  "SqlAlchemyRepository",
21
+ "CacheRefreshRepository",
22
+ "DatabaseRefreshRepository",
23
+ "FileRefreshRepository",
24
+ "MemoryRefreshRepository",
9
25
  ]
@@ -0,0 +1,19 @@
1
+ from alpha.repositories.refresh.cache_repository import (
2
+ CacheRefreshRepository,
3
+ )
4
+ from alpha.repositories.refresh.database_repository import (
5
+ DatabaseRefreshRepository,
6
+ )
7
+ from alpha.repositories.refresh.file_repository import (
8
+ FileRefreshRepository,
9
+ )
10
+ from alpha.repositories.refresh.memory_repository import (
11
+ MemoryRefreshRepository,
12
+ )
13
+
14
+ __all__ = [
15
+ "CacheRefreshRepository",
16
+ "DatabaseRefreshRepository",
17
+ "FileRefreshRepository",
18
+ "MemoryRefreshRepository",
19
+ ]
@@ -0,0 +1,54 @@
1
+ from typing import Any
2
+
3
+ from alpha.providers.models.token import Token
4
+
5
+
6
+ class CacheRefreshRepository:
7
+ def __init__(
8
+ self,
9
+ cache_connector: Any,
10
+ token_model: type[Token] = Token,
11
+ token_max_age_seconds: int = 7 * 24 * 3600,
12
+ token_length: int = 32,
13
+ ):
14
+ """Initialize the CacheRefreshRepository with the given cache connector.
15
+
16
+ Parameters
17
+ ----------
18
+ cache_connector
19
+ The cache connector instance to use for cache operations.
20
+ token_model, optional
21
+ The model class for tokens, by default Token. The model class
22
+ should have a `from_dict` class method that takes a dictionary and
23
+ returns an instance of the model. The dictionary will have the same
24
+ structure as the token data in the JSON file. The model class
25
+ should also have a `to_dict` method that converts an instance of
26
+ the model to a dictionary with the same structure as the token data
27
+ in the JSON file. The model class should also have a
28
+ `create_refresh` class method that creates a new refresh token.
29
+ token_max_age_seconds, optional
30
+ The maximum age of a token in seconds, by default the equivalent of
31
+ 7 days in seconds
32
+ token_length, optional
33
+ The length of the generated token string, by default 32 characters
34
+ """
35
+ self._cache_connector = cache_connector
36
+ self._token_model = token_model
37
+ self._token_max_age_seconds = token_max_age_seconds
38
+ self._token_length = token_length
39
+
40
+ def get(self, token: str) -> Token:
41
+ """Get a token by its value."""
42
+ raise NotImplementedError("Method not implemented yet.")
43
+
44
+ def create(self, subject: str) -> Token:
45
+ """Create a new token for a given subject."""
46
+ raise NotImplementedError("Method not implemented yet.")
47
+
48
+ def delete(self, token: str) -> None:
49
+ """Delete a token by its value."""
50
+ raise NotImplementedError("Method not implemented yet.")
51
+
52
+ def delete_all(self, subject: str) -> None:
53
+ """Delete all tokens for a given subject."""
54
+ raise NotImplementedError("Method not implemented yet.")
@@ -0,0 +1,145 @@
1
+ from alpha import exceptions
2
+ from alpha.infra.connectors.sql_alchemy import SqlAlchemyDatabase
3
+ from alpha.providers.models.token import Token
4
+
5
+
6
+ class DatabaseRefreshRepository:
7
+ """Implementation of the RefreshRepository interface for database
8
+ operations.
9
+
10
+ This repository uses a SQLAlchemy database connector to manage refresh
11
+ tokens in a database. It provides methods to get, create, delete, and
12
+ delete all refresh tokens for a given subject. The tokens are stored in a
13
+ database table which is mapped to the token model.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ database_connector: SqlAlchemyDatabase,
19
+ token_model: type[Token] = Token,
20
+ token_max_age_seconds: int = 7 * 24 * 3600,
21
+ token_length: int = 32,
22
+ ):
23
+ """Initialize the DatabaseRefreshRepository with the given database
24
+ connector and token model.
25
+
26
+ Parameters
27
+ ----------
28
+ database_connector
29
+ The database connector instance to use for database operations.
30
+ token_model, optional
31
+ The model class for tokens, by default Token. The model class
32
+ should have a `from_dict` class method that takes a dictionary and
33
+ returns an instance of the model. The dictionary will have the same
34
+ structure as the token data in the JSON file. The model class
35
+ should also have a `to_dict` method that converts an instance of
36
+ the model to a dictionary with the same structure as the token data
37
+ in the JSON file. The model class should also have a
38
+ `create_refresh` class method that creates a new refresh token.
39
+ token_max_age_seconds, optional
40
+ The maximum age of a token in seconds, by default the equivalent of
41
+ 7 days in seconds
42
+ token_length, optional
43
+ The length of the generated token string, by default 32 characters
44
+ """
45
+ self._database_connector = database_connector
46
+ self._token_model = token_model
47
+ self._token_max_age_seconds = token_max_age_seconds
48
+ self._token_length = token_length
49
+
50
+ def get(self, token: str) -> Token:
51
+ """Get a token by its value.
52
+
53
+ Parameters
54
+ ----------
55
+ token
56
+ The value of the token to retrieve.
57
+
58
+ Returns
59
+ -------
60
+ Token
61
+ The token object corresponding to the given value.
62
+
63
+ Raises
64
+ ------
65
+ NotFoundException
66
+ If the token is not found in the database.
67
+ """
68
+ with self._database_connector.get_session() as session:
69
+ result = (
70
+ session.query(self._token_model)
71
+ .filter_by(value=token)
72
+ .one_or_none()
73
+ )
74
+
75
+ if result is None:
76
+ raise exceptions.NotFoundException("Refresh token not found")
77
+
78
+ return result
79
+
80
+ def create(self, subject: str) -> Token:
81
+ """Create a new token for a given subject.
82
+
83
+ Parameters
84
+ ----------
85
+ subject
86
+ The subject for which to create the token.
87
+
88
+ Returns
89
+ -------
90
+ Token
91
+ The newly created token object.
92
+ """
93
+ token = self._token_model.create_refresh(
94
+ subject=subject,
95
+ max_age_seconds=self._token_max_age_seconds,
96
+ token_length=self._token_length,
97
+ )
98
+
99
+ with self._database_connector.get_session() as session:
100
+ session.add(token)
101
+ session.commit()
102
+ session.refresh(token)
103
+ return token
104
+
105
+ def delete(self, token: str) -> None:
106
+ """Delete a token by its value.
107
+
108
+ Parameters
109
+ ----------
110
+ token
111
+ The value of the token to delete.
112
+
113
+ Raises
114
+ ------
115
+ NotFoundException
116
+ If the token is not found in the database.
117
+ """
118
+ with self._database_connector.get_session() as session:
119
+ token_obj = (
120
+ session.query(self._token_model)
121
+ .filter_by(value=token)
122
+ .one_or_none()
123
+ )
124
+ if token_obj is None:
125
+ raise exceptions.NotFoundException("Refresh token not found")
126
+ session.delete(token_obj)
127
+ session.commit()
128
+
129
+ def delete_all(self, subject: str) -> None:
130
+ """Delete all tokens for a given subject.
131
+
132
+ Parameters
133
+ ----------
134
+ subject
135
+ The subject for which to delete all tokens.
136
+ """
137
+ with self._database_connector.get_session() as session:
138
+ tokens = (
139
+ session.query(self._token_model)
140
+ .filter_by(subject=subject)
141
+ .all()
142
+ )
143
+ for token in tokens:
144
+ session.delete(token)
145
+ session.commit()
@@ -0,0 +1,167 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from alpha import exceptions
5
+ from alpha.providers.models.token import Token
6
+
7
+
8
+ class FileRefreshRepository:
9
+ def __init__(
10
+ self,
11
+ file_path: str | None = None,
12
+ token_model: type[Token] = Token,
13
+ token_max_age_seconds: int = 7 * 24 * 3600,
14
+ token_length: int = 32,
15
+ ):
16
+ """Initialize the FileRefreshRepository with the given file path.
17
+
18
+ Parameters
19
+ ----------
20
+ file_path
21
+ File path for storing refresh tokens if using file storage,
22
+ by default None. When the value is None the file will be stored in
23
+ the current working directory. The file should be a JSON file that
24
+ stores an object of refresh tokens. If the file does not exist, it
25
+ will be created automatically. The structure of the JSON file
26
+ should be as follows:
27
+ ```json
28
+ {
29
+ "<TOKEN_VALUE>": {
30
+ "value": "<TOKEN_VALUE>",
31
+ "token_type": "Refresh",
32
+ "subject": "<SUBJECT>",
33
+ "created_at": "<ISO8601_DATETIME>",
34
+ "expires_at": "<ISO8601_DATETIME>"
35
+ },
36
+ ...
37
+ }
38
+ ```
39
+ token_model, optional
40
+ The model class for tokens, by default Token. The model class
41
+ should have a `from_dict` class method that takes a dictionary and
42
+ returns an instance of the model. The dictionary will have the same
43
+ structure as the token data in the JSON file. The model class
44
+ should also have a `to_dict` method that converts an instance of
45
+ the model to a dictionary with the same structure as the token data
46
+ in the JSON file. The model class should also have a
47
+ `create_refresh` class method that creates a new refresh token.
48
+ token_max_age_seconds, optional
49
+ The maximum age of a token in seconds, by default the equivalent of
50
+ 7 days in seconds
51
+ token_length, optional
52
+ The length of the generated token string, by default 32 characters
53
+ """
54
+ self._file_path = file_path or "refresh_tokens.json"
55
+ self._token_model = token_model
56
+ self._token_max_age_seconds = token_max_age_seconds
57
+ self._token_length = token_length
58
+
59
+ with open(self._file_path, "a+") as file:
60
+ file.seek(0)
61
+ try:
62
+ json.load(file)
63
+ except json.JSONDecodeError:
64
+ file.seek(0)
65
+ file.write("{}")
66
+ file.truncate()
67
+
68
+ def get(self, token: str) -> Token:
69
+ """Get a token by its value.
70
+
71
+ Parameters
72
+ ----------
73
+ token
74
+ The value of the token to retrieve.
75
+
76
+ Returns
77
+ -------
78
+ Token
79
+ The token object corresponding to the given value.
80
+ """
81
+ with open(self._file_path, "r") as file:
82
+ tokens_data: dict[str, dict[str, Any]] = json.load(file)
83
+ token_data = tokens_data.get(token, None)
84
+
85
+ if not token_data:
86
+ raise exceptions.NotFoundException("Refresh token not found")
87
+
88
+ return self._token_model.from_dict(token_data)
89
+
90
+ def create(self, subject: str) -> Token:
91
+ """Create a new token for a given subject.
92
+
93
+ Parameters
94
+ ----------
95
+ subject
96
+ The subject for which to create a new token.
97
+
98
+ Returns
99
+ -------
100
+ Token
101
+ The newly created token object.
102
+ """
103
+ token = self._token_model.create_refresh(
104
+ subject=subject,
105
+ max_age_seconds=self._token_max_age_seconds,
106
+ token_length=self._token_length,
107
+ )
108
+
109
+ with open(self._file_path, "r") as file:
110
+ tokens_data: dict[str, dict[str, Any]] = json.load(file)
111
+
112
+ tokens_data[token.value] = token.to_dict()
113
+
114
+ with open(self._file_path, "w") as file:
115
+ json.dump(tokens_data, file, indent=4)
116
+
117
+ return token
118
+
119
+ def delete(self, token: str) -> None:
120
+ """Delete a token by its value.
121
+
122
+ Parameters
123
+ ----------
124
+ token
125
+ The value of the token to delete.
126
+
127
+ Raises
128
+ ------
129
+ NotFoundException
130
+ If the token with the given value is not found in the file.
131
+ """
132
+ with open(self._file_path, "r") as file:
133
+ tokens_data: dict[str, dict[str, Any]] = json.load(file)
134
+
135
+ if token in tokens_data:
136
+ del tokens_data[token]
137
+
138
+ with open(self._file_path, "w") as file:
139
+ json.dump(tokens_data, file, indent=4)
140
+ else:
141
+ raise exceptions.NotFoundException("Refresh token not found")
142
+
143
+ def delete_all(self, subject: str) -> None:
144
+ """Delete all tokens for a given subject.
145
+
146
+ Parameters
147
+ ----------
148
+ subject
149
+ The subject for which to delete all tokens.
150
+ """
151
+ with open(self._file_path, "r") as file:
152
+ tokens_data: dict[str, dict[str, Any]] = json.load(file)
153
+
154
+ tokens_to_delete = [
155
+ token
156
+ for token, data in tokens_data.items()
157
+ if data["subject"] == subject
158
+ ]
159
+
160
+ if not tokens_to_delete:
161
+ return None
162
+
163
+ for token in tokens_to_delete:
164
+ del tokens_data[token]
165
+
166
+ with open(self._file_path, "w") as file:
167
+ json.dump(tokens_data, file, indent=4)