pycrossauth 0.0.2__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.
Files changed (34) hide show
  1. crossauth_backend/__init__.py +59 -0
  2. crossauth_backend/auth.py +132 -0
  3. crossauth_backend/common/__init__.py +2 -0
  4. crossauth_backend/common/error.py +498 -0
  5. crossauth_backend/common/interfaces.py +287 -0
  6. crossauth_backend/common/jwt.py +41 -0
  7. crossauth_backend/common/logger.py +202 -0
  8. crossauth_backend/cookieauth.py +575 -0
  9. crossauth_backend/crypto.py +457 -0
  10. crossauth_backend/oauth/__init__.py +2 -0
  11. crossauth_backend/oauth/client.py +1063 -0
  12. crossauth_backend/oauth/clientmanager.py +157 -0
  13. crossauth_backend/oauth/resserver.py +34 -0
  14. crossauth_backend/oauth/tokenconsumer.py +412 -0
  15. crossauth_backend/oauth/wellknown.py +108 -0
  16. crossauth_backend/session.py +450 -0
  17. crossauth_backend/storage.py +626 -0
  18. crossauth_backend/storageimpl/__init__.py +2 -0
  19. crossauth_backend/storageimpl/inmemorystorage.py +132 -0
  20. crossauth_backend/storageimpl/sqlalchemystorage.py +294 -0
  21. crossauth_backend/utils.py +150 -0
  22. crossauth_fastapi/__init__.py +23 -0
  23. crossauth_fastapi/fastapioauthclient.py +2185 -0
  24. crossauth_fastapi/fastapiresserver.py +300 -0
  25. crossauth_fastapi/fastapiserver.py +437 -0
  26. crossauth_fastapi/fastapiserverbase.py +62 -0
  27. crossauth_fastapi/fastapisession.py +654 -0
  28. crossauth_fastapi/fastapisessionadapter.py +94 -0
  29. pycrossauth-0.0.2.dist-info/LICENSE +203 -0
  30. pycrossauth-0.0.2.dist-info/METADATA +257 -0
  31. pycrossauth-0.0.2.dist-info/NOTICE +5 -0
  32. pycrossauth-0.0.2.dist-info/RECORD +34 -0
  33. pycrossauth-0.0.2.dist-info/WHEEL +5 -0
  34. pycrossauth-0.0.2.dist-info/top_level.txt +2 -0
@@ -0,0 +1,287 @@
1
+ # Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
2
+ from typing import Any, Dict, Optional, TypedDict, NotRequired
3
+ import json
4
+ from datetime import datetime
5
+ from nulltype import NullType
6
+ from crossauth_backend.common.logger import CrossauthLogger, j
7
+
8
+ class Key(TypedDict):
9
+ """
10
+ A key (eg session ID, email reset token) as stored in a database table.
11
+
12
+ The fields defined here are the ones used by Crossauth. You may add
13
+ others.
14
+ """
15
+
16
+ value : str
17
+ """ The value of the keykey.
18
+ In a cookie, the value part of cookiename=value; options...
19
+ """
20
+
21
+ created : datetime
22
+ """ The datetime/time the key was created, in local time on the server """
23
+
24
+ expires : datetime | NullType
25
+ """ The datetime/time the key expires """
26
+
27
+ userid : NotRequired[str | int | NullType]
28
+ """ the user this key is for (or undefined for an anonymous session ID)
29
+
30
+ It accepts the value null as usually this is the value stored in the
31
+ database, rather than undefined. Some functions need to differentiate
32
+ between a null value as opposed to the value not being defined (eg for
33
+ a partial updatetime).
34
+ """
35
+
36
+ lastactive : NotRequired[datetime]
37
+ """ The datetime/time key was last used (eg last time a request was made
38
+ with this value as a session ID)
39
+ """
40
+
41
+ data : NotRequired[str]
42
+ """ Additional key-specific data (eg new email address for email change).
43
+
44
+ While application specific, any data Crossauth puts in this field
45
+ is a strified JSON, with its own key so it can co-exist with
46
+ other data.
47
+ """
48
+
49
+ class PartialKey(TypedDict, total = False):
50
+ """
51
+ Same as :class:`crossauth_backend.Key` but all fields are NotRequired
52
+ """
53
+
54
+ value : str
55
+ created : datetime
56
+ expires : datetime | NullType
57
+ userid : Optional[str | int | NullType]
58
+ lastactive : Optional[datetime]
59
+ data : Optional[str]
60
+
61
+
62
+ class ApiKey(Key):
63
+ """
64
+ An API key is a str that can be used in place of a username and
65
+ password. These are not automatically created, like OAuth access tokens.
66
+ """
67
+
68
+ name : str
69
+ """ A name for the key, unique to the user """
70
+
71
+ def get_json_data(key: Key) -> Dict[str, Any]:
72
+ """
73
+ Parses the data field in the key as a JSON object
74
+
75
+ :param crossauth_backend.Key key: the key to extract the data field from
76
+
77
+ :return: the data as dict
78
+ """
79
+ if ("data" not in key):
80
+ return {}
81
+ try:
82
+ return json.loads(key["data"])
83
+ except json.JSONDecodeError:
84
+ CrossauthLogger.logger().warn(j({"msg": "data in key is not JSON"}))
85
+ return {}
86
+
87
+ class UserInputFields(TypedDict):
88
+ """
89
+ Describes a user as fetched from the user storage (eg, database table),
90
+ excluding auto-generated fields such as an auto-generated ID
91
+
92
+ This is extendible with additional fields - provide them to the
93
+ :class:`UserStorage` class as `extraFields`.
94
+
95
+ You may want to do this if you want to pass additional user data back to the
96
+ caller, eg real name.
97
+
98
+ The fields defined here are the ones used by Crossauth. You may add
99
+ others.
100
+ """
101
+
102
+ username : str
103
+ """The username. This may be an email address or anything else,
104
+ application-specific.
105
+ """
106
+
107
+ state : str
108
+ """
109
+ You are free to define your own states. The ones Crossauth recognises
110
+ are defined in :class:`UserState`.
111
+ """
112
+
113
+ email : NotRequired[str]
114
+ """
115
+ You can optionally include an email address field in your user table.
116
+ If your username is an email address, you do not need a separate field.
117
+ """
118
+
119
+ admin : NotRequired[bool]
120
+ """
121
+ Whether or not the user has administrator priviledges (and can acess
122
+ admin-only functions).
123
+ """
124
+
125
+ factor1 : str
126
+ """
127
+ Factor for primary authentication.
128
+
129
+ Should match the name of an authenticator
130
+ """
131
+
132
+ factor2 : NotRequired[str]
133
+ """
134
+ Factor for second factor authentication.
135
+
136
+ Should match the name of an authenticator
137
+ """
138
+
139
+
140
+ class PartialUserInputFields(TypedDict, total=False):
141
+ """
142
+ Same as UserInputFields but all fields are not required
143
+ """
144
+
145
+ username : str
146
+ state : str
147
+ email : NotRequired[str]
148
+ admin : NotRequired[bool]
149
+ factor1 : NotRequired[str]
150
+ factor2 : NotRequired[str]
151
+
152
+ class PartialUser(PartialUserInputFields, total=False):
153
+ """
154
+ Same as User but all fields are not required
155
+ """
156
+ id : str | int
157
+
158
+
159
+ class User(UserInputFields):
160
+ """
161
+ This adds ID to :class:`UserInputFields`.
162
+
163
+ If your `username` field is
164
+ unique and immutable, you can omit ID (passing username anywhere an ID)
165
+ is expected. However, if you want users to be able to change their username,
166
+ you should include ID field and make that immutable instead.
167
+ """
168
+
169
+ """ ID fied, which may be auto-generated """
170
+ id : str | int
171
+
172
+ class UserSecretsInputFields(TypedDict, total=False):
173
+ """
174
+ Secrets, such as a password, are not in the User object to prevent them
175
+ accidentally being leaked to the frontend. All functions that return
176
+ secrets return them in this separate object.
177
+
178
+ The fields in this class are the ones that are not autogenerated by the
179
+ database.
180
+ """
181
+
182
+ password : str
183
+ totpsecret : str
184
+ otp: str
185
+ expiry: int
186
+
187
+ class PartialUserSecrets(UserSecretsInputFields, total=False):
188
+ """
189
+ Same as UserSecrets except all fields are NotRequired
190
+ """
191
+
192
+ userid : str|int
193
+
194
+
195
+ class UserSecrets(UserSecretsInputFields):
196
+ """
197
+ This adds the user ID toi :class:`UserSecretsInputFields`.
198
+ """
199
+
200
+ userid : str|int
201
+
202
+ class OAuthClient(TypedDict):
203
+ """
204
+ OAuth client data as stored in a database table
205
+ """
206
+
207
+ client_id : str
208
+ """The client_id, which is auto-generated and immutable """
209
+
210
+ confidential : bool
211
+ """Whether or not the client is confidential (and can therefore
212
+ keep the client secret secret) """
213
+
214
+ client_name : str
215
+ """
216
+ A user-friendly name for the client (not used as part of the OAuth
217
+ API).
218
+ """
219
+
220
+ client_secret : NotRequired[str|NullType]
221
+ """
222
+ Client secret, which is autogenerated.
223
+
224
+ If there is no client secret, it should be set to `undefined`.
225
+
226
+ This field allows `null` as well as `undefined` this is used, for
227
+ example, when partially updating a client and you specifically
228
+ want to set the secret to undefined, as opposed to just not wishing
229
+ to change the value. Other than that, this value is always either
230
+ a str or `undefined`.
231
+ """
232
+
233
+ redirect_uri : list[str]
234
+ """
235
+ An array of value redirect URIs for the client.
236
+ """
237
+
238
+ valid_flow : list[str]
239
+ """
240
+ An array of OAuth flows allowed for this client.
241
+
242
+ See :class:`OAuthFlows`.
243
+ """
244
+
245
+
246
+ userid : NotRequired[str|int|NullType]
247
+ """
248
+ ID of the user who owns this client, which may be `undefined`
249
+ for not being owned by a specific user.
250
+
251
+ This field allows `null` as well as `undefined` this is used, for
252
+ example, when partially updating a client and you specifically
253
+ want to set the user ID to undefined, as opposed to just not wishing
254
+ to change the value. Other than that, this value is always either
255
+ a str or number (depending on the ID type in your user storage)
256
+ or `undefined`.
257
+ """
258
+
259
+ class UserState:
260
+ """
261
+ These are used valiues for `state` in a :class:`crossauth_backend.User` object
262
+ """
263
+
264
+ active = "active"
265
+ disabled = "disabled"
266
+ awaiting_two_factor_setup = "awaitingtwofactorsetup"
267
+ awaiting_email_verification = "awaitingemailverification"
268
+ password_change_needed = "passwordchangeneeded"
269
+ password_reset_needed = "passwordresetneeded"
270
+ factor2_reset_needed = "factor2resetneeded"
271
+ password_and_factor2_reset_needed = "passwordandfactor2resetneeded"
272
+
273
+ class KeyPrefix:
274
+ """
275
+ These are used prefixes for keye in a key storage entry
276
+ """
277
+
278
+ session = "s:"
279
+ password_reset_token = "p:"
280
+ email_verification_token = "e:"
281
+ api_key = "api:"
282
+ authorization_code = "authz:"
283
+ access_token = "access:"
284
+ refresh_token = "refresh:"
285
+ mfa_token = "omfa:"
286
+ device_code = "dc:"
287
+ user_code = "uc:"
@@ -0,0 +1,41 @@
1
+ # Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
2
+ import json
3
+ import base64
4
+ from typing import Dict, Optional, Any
5
+
6
+ from crossauth_backend.common.error import CrossauthError, ErrorCode
7
+ from crossauth_backend.common.logger import CrossauthLogger, j
8
+
9
+ class JWT:
10
+ """
11
+ Encapsulates the payload of a JWT, with both the token and
12
+ decoded JSON payload.
13
+ """
14
+
15
+ def __init__(self, token: Optional[str] = None, payload: Dict[str, Any] = {}):
16
+ """
17
+ Constructor. Pass either `token` or `payload`.
18
+
19
+ Args:
20
+ token: the string JWT token - the payload will be parsed from it
21
+ payload: the JSON payload. The payload will be set but not
22
+ the string `token`.
23
+ """
24
+ self.token: Optional[str] = token
25
+
26
+ if payload:
27
+ self.payload = payload
28
+ elif self.token:
29
+ parts = self.token.split(".")
30
+ if len(parts) != 3:
31
+ raise CrossauthError(ErrorCode.InvalidToken, "JWT not in correct format")
32
+ try:
33
+ self.payload = json.loads(base64.urlsafe_b64decode(parts[1] + '==').decode('utf-8'))
34
+ except Exception as e:
35
+ CrossauthLogger.logger().error(j({"err": str(e)}))
36
+ if len(parts) != 3:
37
+ raise CrossauthError(ErrorCode.InvalidToken, "JWT payload not in correct format")
38
+ json_string = base64.urlsafe_b64decode(parts[1] + '==').decode('utf-8')
39
+ self.payload = json.loads(json_string)
40
+ else:
41
+ self.payload = {}
@@ -0,0 +1,202 @@
1
+ # Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
2
+ from .. import CrossauthError
3
+
4
+ import json
5
+ import os
6
+ from typing import Any, Dict, Mapping
7
+ import traceback
8
+
9
+ class CrossauthLoggerInterface:
10
+ """
11
+ You can implement your own logger. Crossauth only needs these functions
12
+ and variables to be present.
13
+ """
14
+
15
+ def error(self, output: Any) -> None:
16
+ """ Report a message at error level """
17
+ pass
18
+
19
+ def warn(self, output: Any) -> None:
20
+ """ Report a message at warning level """
21
+ pass
22
+
23
+ def info(self, output: Any) -> None:
24
+ """ Report a message at info level """
25
+ pass
26
+
27
+ def debug(self, output: Any) -> None:
28
+ """ Report a message at debug level """
29
+ pass
30
+
31
+ NoLogging = 0
32
+ Error = 1
33
+ Warn = 2
34
+ Info = 3
35
+ Debug = 4
36
+
37
+ level: int = NoLogging
38
+
39
+ class CrossauthLogger(CrossauthLoggerInterface):
40
+ """
41
+ A very simple logging class with no dependencies.
42
+
43
+ Logs to console.
44
+
45
+ The logging API is designed so that you can replace this with other common loggers, eg Pino.
46
+ To change it, use the global :func:`CrossauthLogger.set_logger` function. This has a parameter to tell
47
+ Crossauth whether your logger accepts JSON input or not.
48
+
49
+ When writing logs, we use the helper function :func:`j` to send JSON to the logger if it is
50
+ supprted, and a stringified JSON otherwise.
51
+
52
+ **Crossauth logs**
53
+
54
+ All Crossauth log messages are JSON (or stringified JSON, depending on whether the logger supports
55
+ JSON input - this one does). The following fields may be present depending on context
56
+ (`msg` is always present):
57
+
58
+ - `msg` : main contents of the log
59
+ - `err` : an error object. If a subclass of Error, it wil contain at least `message` and
60
+ a stack trace in `stack`. If the error is of type :class:`crossauth_backend.CrossauthError`
61
+ it also will also contain `code` and `http_status`.
62
+ - `hashedSessionCookie` : for security reasons, session cookies are not included in logs.
63
+ However, so that errors can be correlated with each other, a hash
64
+ of it is included in errors originating from a session.
65
+ - `hashedCsrfCookie` : for security reasons, csrf cookies are not included in logs.
66
+ However, so that errors can be correlated with each other, a hash
67
+ of it is included in errors originating from a session.
68
+ - `user` : username
69
+ - `emailMessageId` : internal id of any email that is sent
70
+ - `email` : email address
71
+ - `userid` : sometimes provided in addition to username, or when username not available
72
+ - `hahedApiKey` : a hash of an API key. The unhashed version is not logged for security,
73
+ but a hash of it is logged for correlation purposes.
74
+ - `header` : an HTTP header that relates to an error (eg `Authorization`), only if
75
+ it is non-secret or invalid
76
+ - `accessTokenHash` : hash of the JTI of an access token. For security reasons, the
77
+ unhashed version is not logged.
78
+ - `method`: request method (GET, PUT etc)
79
+ - `url` : relevant URL
80
+ - `ip` : relevant IP address
81
+ - `scope` : OAuth scope
82
+ - `error_code` : Crossauth error code
83
+ - `error_code_name` : String version of Crossauth error code
84
+ - `http_status` : HTTP status that will be returned
85
+ - `port` port service is running on (only for starting a service)
86
+ - `prefix` prefix for endpoints (only when starting a service)
87
+ - `authorized` whether or not a valid OAuth access token was provided
88
+ """
89
+
90
+ levelName = ["NONE", "ERROR", "WARN", "INFO", "DEBUG"]
91
+
92
+ _instance : CrossauthLoggerInterface
93
+
94
+ @staticmethod
95
+ def logger() -> CrossauthLoggerInterface:
96
+ """ Returns the static logger instance"""
97
+ global _crossauth_logger, _crossauth_logger_accepts_json
98
+ return _crossauth_logger
99
+
100
+ def __init__(self, level: int|None = None):
101
+ """
102
+ Constructor
103
+
104
+ :param int|None level the level to report to
105
+
106
+ """
107
+ if level is not None:
108
+ self.level = level
109
+ elif "CROSSAUTH_LOG_LEVEL" in os.environ:
110
+ level_name = os.environ["CROSSAUTH_LOG_LEVEL"].upper()
111
+ if level_name in CrossauthLogger.levelName:
112
+ self.level = CrossauthLogger.levelName.index(level_name)
113
+ else:
114
+ self.level = CrossauthLogger.Error
115
+ else:
116
+ self.level = CrossauthLogger.Error
117
+ CrossauthLogger.rossauth_logger_accepts_json = True
118
+
119
+ def set_level(self, level: int) -> None:
120
+ """ Set the level to report down to """
121
+ self.level = level
122
+
123
+ def _log(self, level: int, output: Any) -> None:
124
+ if level <= self.level:
125
+ if isinstance(output, str):
126
+ print(f"Crossauth {CrossauthLogger.levelName[level]} {self._current_time_iso()} {output}")
127
+ else:
128
+ print(json.dumps({"level": CrossauthLogger.levelName[level], "time": self._current_time_iso(), **output}))
129
+
130
+ def error(self, output: Any) -> None:
131
+ """ Log an error """
132
+ self._log(CrossauthLogger.Error, output)
133
+
134
+ def warn(self, output: Any) -> None:
135
+ """ Log a warning """
136
+ self._log(CrossauthLogger.Warn, output)
137
+
138
+ def info(self, output: Any) -> None:
139
+ """ Log an info message """
140
+ self._log(CrossauthLogger.Info, output)
141
+
142
+ def debug(self, output: Any) -> None:
143
+ """ Log a debug message """
144
+ self._log(CrossauthLogger.Debug, output)
145
+
146
+ @staticmethod
147
+ def set_logger(logger: CrossauthLoggerInterface, accepts_json: bool) -> None:
148
+ """ Set the static logger instance """
149
+ global _crossauth_logger, _crossauth_logger_accepts_json
150
+ _crossauth_logger = logger
151
+ _crossauth_logger_accepts_json = accepts_json
152
+
153
+ @staticmethod
154
+ def _current_time_iso() -> str:
155
+ from datetime import datetime
156
+ return datetime.now().isoformat()
157
+
158
+
159
+ def j(arg: Mapping[str, Any] | str) -> Dict[str, Any] | str:
160
+ """ Helper function that returns JSON if the error log supports it,
161
+ otherwise a string """
162
+ global _crossauth_logger_accepts_json
163
+ argcopy : Mapping[str,Any] = {}
164
+ if (type(arg) == str):
165
+ argcopy = {"msg": arg}
166
+ elif (isinstance(arg, Mapping)):
167
+ argcopy = {**arg}
168
+ if isinstance(arg, dict) and "cerr" in arg and isinstance(arg["cerr"], CrossauthError):
169
+ argcopy["error_code"] = arg["cerr"].code.value
170
+ argcopy["error_code_name"] = arg["cerr"].code_name
171
+ argcopy["http_status"] = arg["cerr"].http_status
172
+ if "msg" not in argcopy:
173
+ argcopy["msg"] = argcopy["cerr"].message
174
+
175
+ if isinstance(arg, dict) and "err" in arg and isinstance(arg["err"], CrossauthError):
176
+ argcopy["error_code"] = arg["err"].code.value
177
+ argcopy["error_code_name"] = arg["err"].code_name
178
+ argcopy["http_status"] = arg["err"].http_status
179
+ if "msg" not in argcopy:
180
+ if ("cerr" in argcopy):
181
+ argcopy["msg"] = argcopy["cerr"].message
182
+ elif ("err" in argcopy):
183
+ argcopy["msg"] = argcopy["err"].message
184
+ argcopy["stack"] = str(traceback.format_exception(arg["err"]))
185
+
186
+ elif isinstance(arg, dict) and "err" in arg and isinstance(arg["err"], Exception):
187
+ argcopy["stack"] = str(traceback.format_exception(arg["err"]))
188
+ if "msg" not in argcopy:
189
+ argcopy["msg"] = str(str(arg["err"]))
190
+
191
+ if ("err" in argcopy): del argcopy["err"]
192
+ if ("cerr" in argcopy): del argcopy["cerr"]
193
+
194
+ if (type(arg) == str):
195
+ return arg
196
+ if (_crossauth_logger_accepts_json):
197
+ return argcopy
198
+ return json.dumps(argcopy)
199
+
200
+
201
+ _crossauth_logger = CrossauthLogger(None)
202
+ _crossauth_logger_accepts_json = True